/ web / src / pages / panels / bluetooth_panel.rs
bluetooth_panel.rs
  1  use dioxus::prelude::*;
  2  use ui::components::button::{Button, ButtonVariant};
  3  
  4  #[component]
  5  pub fn BluetoothPanel() -> Element {
  6      let mut initialized = use_signal(|| false);
  7  
  8      use_effect(move || {
  9          if *initialized.peek() {
 10              return;
 11          }
 12          initialized.set(true);
 13  
 14          document::eval(
 15              r#"
 16              setTimeout(function() {
 17                  const panel = document.getElementById('bluetooth-panel');
 18                  if (!panel || panel.dataset.initialized) return;
 19                  panel.dataset.initialized = 'true';
 20  
 21                  const pairBtn = document.getElementById('bt-pair-btn');
 22                  const disconnectBtn = document.getElementById('bt-disconnect-btn');
 23                  const statusEl = document.getElementById('bt-status');
 24                  const termEl = document.getElementById('bt-terminal');
 25  
 26                  let device = null;
 27                  let server = null;
 28                  let rxChar = null;
 29                  let txChar = null;
 30                  let term = null;
 31  
 32                  const NUS_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
 33                  const NUS_RX      = '6e400002-b5a3-f393-e0a9-e50e24dcca9e';
 34                  const NUS_TX      = '6e400003-b5a3-f393-e0a9-e50e24dcca9e';
 35  
 36                  function setStatus(text) { statusEl.textContent = text; }
 37  
 38                  function showConnected() {
 39                      pairBtn.style.display = 'none';
 40                      disconnectBtn.style.display = '';
 41                      termEl.style.display = '';
 42                  }
 43  
 44                  function showDisconnected() {
 45                      pairBtn.style.display = '';
 46                      disconnectBtn.style.display = 'none';
 47                  }
 48  
 49                  function initTerminal() {
 50                      if (term) return;
 51                      if (typeof Terminal === 'undefined') return;
 52                      term = new Terminal({
 53                          cursorBlink: true, fontSize: 13,
 54                          fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
 55                          theme: {
 56                              background: '#0a0a0c', foreground: '#d4a84b', cursor: '#f5b72b',
 57                              selectionBackground: 'rgba(245, 183, 43, 0.3)',
 58                              black: '#0a0a0c', red: '#e06c6c', green: '#6cc070',
 59                              yellow: '#f5b72b', blue: '#6c9ee0', magenta: '#c06cc0',
 60                              cyan: '#6cc0c0', white: '#d4a84b'
 61                          }
 62                      });
 63                      if (typeof FitAddon !== 'undefined') {
 64                          const fit = new FitAddon.FitAddon();
 65                          term.loadAddon(fit);
 66                          term.open(termEl);
 67                          fit.fit();
 68                          window.addEventListener('resize', () => fit.fit());
 69                      } else {
 70                          term.open(termEl);
 71                      }
 72                  }
 73  
 74                  pairBtn.addEventListener('click', async function() {
 75                      if (!navigator.bluetooth) {
 76                          setStatus('Web Bluetooth not supported. Use Chrome/Edge on HTTPS.');
 77                          return;
 78                      }
 79                      try {
 80                          setStatus('Scanning...');
 81                          device = await navigator.bluetooth.requestDevice({
 82                              filters: [{ services: [NUS_SERVICE] }],
 83                              optionalServices: [NUS_SERVICE]
 84                          });
 85  
 86                          device.addEventListener('gattserverdisconnected', function() {
 87                              setStatus('Disconnected');
 88                              showDisconnected();
 89                              if (term) term.write('\r\n\x1b[31m[BLE disconnected]\x1b[0m\r\n');
 90                          });
 91  
 92                          setStatus('Connecting to ' + device.name + '...');
 93                          server = await device.gatt.connect();
 94                          const service = await server.getPrimaryService(NUS_SERVICE);
 95  
 96                          rxChar = await service.getCharacteristic(NUS_RX);
 97                          txChar = await service.getCharacteristic(NUS_TX);
 98  
 99                          await txChar.startNotifications();
100                          txChar.addEventListener('characteristicvaluechanged', function(event) {
101                              const decoder = new TextDecoder();
102                              const text = decoder.decode(event.target.value);
103                              if (term) term.write(text);
104                          });
105  
106                          initTerminal();
107                          showConnected();
108                          setStatus('Connected: ' + (device.name || 'BLE Device'));
109                          if (term) term.write('\x1b[32m[BLE connected: ' + device.name + ']\x1b[0m\r\n');
110  
111                          term.onData(function(data) {
112                              if (rxChar) {
113                                  const encoder = new TextEncoder();
114                                  rxChar.writeValueWithoutResponse(encoder.encode(data));
115                              }
116                          });
117  
118                      } catch (err) {
119                          setStatus('Error: ' + err.message);
120                      }
121                  });
122  
123                  disconnectBtn.addEventListener('click', function() {
124                      if (device && device.gatt.connected) {
125                          device.gatt.disconnect();
126                      }
127                      showDisconnected();
128                      setStatus('Disconnected');
129                  });
130  
131                  showDisconnected();
132                  setStatus('Ready to pair');
133              }, 100);
134          "#,
135          );
136      });
137  
138      rsx! {
139          section { id: "bluetooth-panel", class: "border border-border rounded-lg bg-card p-4",
140              div { class: "flex items-center gap-2 mb-3",
141                  h2 { class: "text-xl font-semibold", "Bluetooth" }
142                  span { id: "bt-status", class: "text-sm text-muted-foreground", role: "status", aria_live: "polite" }
143              }
144  
145              div { class: "flex gap-2 mb-3",
146                  Button { id: "bt-pair-btn", variant: ButtonVariant::Outline,
147                      class: "px-3 py-1.5 text-sm hover:bg-muted/50".to_string(),
148                      aria_label: "Pair via Bluetooth",
149                      lucide_dioxus::Bluetooth { class: "w-4 h-4" }
150                      "Pair via Bluetooth"
151                  }
152                  Button { id: "bt-disconnect-btn", variant: ButtonVariant::Outline,
153                      class: "px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted/50".to_string(), style: "display:none",
154                      aria_label: "Disconnect Bluetooth",
155                      "Disconnect"
156                  }
157              }
158  
159              div { id: "bt-terminal", class: "h-[250px] bg-[#0a0a0c] rounded-lg overflow-hidden", style: "display:none" }
160          }
161      }
162  }