Skip to main content

netget/client/bluetooth/
mod.rs

1//! Bluetooth Low Energy (BLE) client implementation
2pub mod actions;
3
4pub use actions::BluetoothClientProtocol;
5
6use anyhow::{Context, Result};
7use btleplug::api::{Central, CharPropFlags, Manager as _, Peripheral as _, ScanFilter, WriteType};
8use btleplug::platform::{Adapter, Manager, Peripheral};
9use futures::future::BoxFuture;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::{mpsc, Mutex};
14use tracing::{debug, error, info, trace, warn};
15use uuid::Uuid;
16
17use crate::client::bluetooth::actions::{
18    BLUETOOTH_CONNECTED_EVENT, BLUETOOTH_DATA_READ_EVENT, BLUETOOTH_NOTIFICATION_RECEIVED_EVENT,
19    BLUETOOTH_SCAN_COMPLETE_EVENT, BLUETOOTH_SERVICES_DISCOVERED_EVENT,
20};
21use crate::client::llm_budget::call_llm_for_client;
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ClientLlmResult;
24use crate::protocol::Event;
25use crate::state::app_state::AppState;
26use crate::state::{ClientId, ClientStatus};
27
28/// Connection state for LLM processing
29#[derive(Debug, Clone, PartialEq)]
30#[allow(dead_code)]
31enum ConnectionState {
32    Idle,
33    Processing,
34    Accumulating,
35}
36
37/// Per-client data for LLM handling
38struct ClientData {
39    #[allow(dead_code)]
40    state: ConnectionState,
41    memory: String,
42    peripheral: Option<Peripheral>,
43    #[allow(dead_code)]
44    manager: Manager,
45    adapter: Adapter,
46}
47
48/// What applying one action to the BLE adapter/peripheral actually did.
49///
50/// `Sent` is reported only for a completed GATT write — those bytes really went
51/// out on the radio. Every other verb reads, scans or subscribes, so it reports
52/// `Executed` with what it found.
53pub enum BluetoothApplied {
54    /// This many bytes were written to a characteristic.
55    Sent(usize),
56    /// The action ran but wrote nothing; the string says what it did.
57    Executed(String),
58    /// The peripheral was disconnected.
59    Disconnected,
60}
61
62/// Bluetooth Low Energy client that connects to BLE devices
63pub struct BluetoothClient;
64
65impl BluetoothClient {
66    /// Connect to a BLE device with integrated LLM actions
67    ///
68    /// Note: For BLE, the "remote_addr" parameter is actually the device name or address to connect to.
69    /// If empty or "scan", the client will scan for devices and wait for LLM to select one.
70    pub async fn connect_with_llm_actions(
71        remote_addr: String,
72        llm_client: OllamaClient,
73        app_state: Arc<AppState>,
74        status_tx: mpsc::UnboundedSender<String>,
75        client_id: ClientId,
76    ) -> Result<SocketAddr> {
77        info!("Bluetooth client {} initializing", client_id);
78
79        // Initialize BLE manager and adapter
80        let manager = Manager::new()
81            .await
82            .context("Failed to create BLE manager")?;
83
84        let adapters = manager.adapters().await?;
85        let adapter = adapters
86            .into_iter()
87            .next()
88            .context("No Bluetooth adapters found")?;
89
90        info!(
91            "Bluetooth client {} using adapter: {:?}",
92            client_id,
93            adapter.adapter_info().await?
94        );
95
96        // Update client state
97        app_state
98            .update_client_status(client_id, ClientStatus::Connected)
99            .await;
100        let _ = status_tx.send(format!(
101            "[CLIENT] Bluetooth client {} initialized",
102            client_id
103        ));
104        let _ = status_tx.send("__UPDATE_UI__".to_string());
105
106        // Initialize client data
107        let client_data = Arc::new(Mutex::new(ClientData {
108            state: ConnectionState::Idle,
109            memory: String::new(),
110            peripheral: None,
111            manager: manager.clone(),
112            adapter: adapter.clone(),
113        }));
114
115        // Command channel for injected actions (the dashboard's [ send ]).
116        // Registered BEFORE the scan/connect task below, whose very first step is an
117        // LLM call that a `*` -> manual rule can park for minutes: the operator must
118        // be able to drive the adapter while it waits.
119        let command_rx =
120            crate::client::command_support::register_command_channel(&app_state, client_id).await;
121        let cmd_task = tokio::spawn({
122            let client_data = client_data.clone();
123            let app_state = app_state.clone();
124            let status_tx = status_tx.clone();
125            let llm_client = llm_client.clone();
126            async move {
127                Self::command_loop(
128                    command_rx,
129                    client_data,
130                    app_state,
131                    status_tx,
132                    llm_client,
133                    client_id,
134                )
135                .await;
136            }
137        });
138        app_state.register_client_task(client_id, cmd_task).await;
139
140        // Spawn LLM integration task
141        let client_data_clone = client_data.clone();
142        let app_state_clone = app_state.clone();
143        let status_tx_clone = status_tx.clone();
144        let llm_client_clone = llm_client.clone();
145
146        // Registered with AppState so stop_client can abort this task —
147        // dropping a JoinHandle only detaches it in Tokio.
148        let task_registrar = app_state.clone();
149        let task_handle = tokio::spawn(async move {
150            // Initial scan if requested
151            if remote_addr.is_empty() || remote_addr == "scan" {
152                if let Err(e) = Self::perform_scan(
153                    &client_data_clone,
154                    &app_state_clone,
155                    &status_tx_clone,
156                    &llm_client_clone,
157                    client_id,
158                    5,
159                )
160                .await
161                {
162                    error!("Bluetooth scan error: {}", e);
163                }
164            } else {
165                // Try to connect to specified device
166                if let Err(e) = Self::connect_to_device(
167                    &client_data_clone,
168                    &app_state_clone,
169                    &status_tx_clone,
170                    &llm_client_clone,
171                    client_id,
172                    Some(remote_addr.clone()),
173                    None,
174                )
175                .await
176                {
177                    error!("Bluetooth connection error: {}", e);
178                    app_state_clone
179                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
180                        .await;
181                    let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
182                }
183            }
184
185            // Main event loop - wait for LLM actions
186            loop {
187                tokio::time::sleep(Duration::from_millis(100)).await;
188
189                // Check if client is still active
190                if let Some(client) = app_state_clone.get_client(client_id).await {
191                    if matches!(
192                        client.status,
193                        ClientStatus::Disconnected | ClientStatus::Error(_)
194                    ) {
195                        break;
196                    }
197                } else {
198                    break;
199                }
200            }
201
202            info!("Bluetooth client {} event loop ended", client_id);
203            // The scan/connect task is what tracks liveness; when it gives up, the
204            // command handle must go with it so the dashboard stops offering [ send ].
205            app_state_clone.remove_client_handle(client_id).await;
206            let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
207        });
208        task_registrar
209            .register_client_task(client_id, task_handle)
210            .await;
211
212        // Return a dummy socket address (BLE doesn't use IP sockets)
213        Ok("0.0.0.0:0".parse().unwrap())
214    }
215
216    /// Drain injected commands until the channel closes (the client was removed,
217    /// which drops the handle) or an injected `disconnect` ends the session.
218    ///
219    /// The generic `command_support::handle_stream_client_command` cannot serve this
220    /// client: it owns no socket, and every BLE verb yields a
221    /// `ClientActionResult::Custom` that only btleplug can carry out. So the action
222    /// goes through [`Self::execute_llm_action`] — the exact function the LLM path
223    /// uses, including its follow-up events — and the outcome is recorded and replied
224    /// the way the generic arm does it.
225    async fn command_loop(
226        mut command_rx: tokio::sync::mpsc::Receiver<crate::state::client_handles::ClientCommand>,
227        client_data: Arc<Mutex<ClientData>>,
228        app_state: Arc<AppState>,
229        status_tx: mpsc::UnboundedSender<String>,
230        llm_client: OllamaClient,
231        client_id: ClientId,
232    ) {
233        use crate::llm::actions::protocol_trait::Protocol;
234        use crate::state::client_handles::ClientSendOutcome;
235        use crate::state::AccessLogOwner;
236
237        let protocol = BluetoothClientProtocol::new();
238
239        while let Some(command) = command_rx.recv().await {
240            let action = command.action.clone();
241
242            // `execute_llm_action` returns Err both for a rejected action (unknown
243            // verb / bad params) and for a BLE failure. They are not distinguishable
244            // from here without duplicating `execute_action`, so run the decode first
245            // and let only its error be a rejection.
246            let decoded = {
247                use crate::llm::actions::client_trait::Client;
248                protocol.execute_action(action.clone())
249            };
250            let outcome = match decoded {
251                Err(e) => Ok(ClientSendOutcome::Rejected {
252                    error: e.to_string(),
253                }),
254                Ok(_) => Self::execute_llm_action(
255                    action.clone(),
256                    &client_data,
257                    &app_state,
258                    &status_tx,
259                    &llm_client,
260                    client_id,
261                )
262                .await
263                .map(|applied| match applied {
264                    BluetoothApplied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
265                    BluetoothApplied::Executed(detail) => ClientSendOutcome::Executed { detail },
266                    BluetoothApplied::Disconnected => ClientSendOutcome::Disconnected,
267                }),
268            };
269
270            let outcome_json = match &outcome {
271                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
272                Err(e) => serde_json::json!({"error": e.to_string()}),
273            };
274            app_state
275                .record_access_log(
276                    AccessLogOwner::Client(client_id.as_u32()),
277                    protocol.protocol_name(),
278                    None,
279                    "injected_action",
280                    action,
281                    vec![outcome_json],
282                )
283                .await;
284
285            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
286            if let Err(e) = &outcome {
287                error!(
288                    "Bluetooth client {} injected action failed: {}",
289                    client_id, e
290                );
291                let _ = status_tx.send(format!(
292                    "[WARN] Client {} injected action failed: {}",
293                    client_id, e
294                ));
295            }
296            let _ = status_tx.send("__UPDATE_UI__".to_string());
297            crate::client::command_support::reply(command, outcome);
298
299            if disconnect {
300                break;
301            }
302        }
303
304        app_state.remove_client_handle(client_id).await;
305        let _ = status_tx.send("__UPDATE_UI__".to_string());
306    }
307
308    /// Perform a BLE scan for nearby devices
309    async fn perform_scan(
310        client_data: &Arc<Mutex<ClientData>>,
311        app_state: &Arc<AppState>,
312        status_tx: &mpsc::UnboundedSender<String>,
313        llm_client: &OllamaClient,
314        client_id: ClientId,
315        duration_secs: u64,
316    ) -> Result<usize> {
317        let adapter = {
318            let data = client_data.lock().await;
319            data.adapter.clone()
320        };
321
322        info!(
323            "Bluetooth client {} starting scan for {} seconds",
324            client_id, duration_secs
325        );
326        let _ = status_tx.send(format!("[CLIENT] Scanning for BLE devices..."));
327
328        adapter.start_scan(ScanFilter::default()).await?;
329        tokio::time::sleep(Duration::from_secs(duration_secs)).await;
330        adapter.stop_scan().await?;
331
332        // Get discovered devices
333        let peripherals = adapter.peripherals().await?;
334
335        let mut devices = Vec::new();
336        for peripheral in peripherals {
337            if let Ok(Some(props)) = peripheral.properties().await {
338                let device_info = serde_json::json!({
339                    "address": props.address.to_string(),
340                    "name": props.local_name.unwrap_or_else(|| "Unknown".to_string()),
341                    "rssi": props.rssi,
342                });
343                devices.push(device_info);
344            }
345        }
346
347        info!(
348            "Bluetooth client {} found {} devices",
349            client_id,
350            devices.len()
351        );
352        let _ = status_tx.send(format!("[CLIENT] Found {} BLE devices", devices.len()));
353        let device_count = devices.len();
354
355        // Call LLM with scan results
356        let protocol = Arc::new(BluetoothClientProtocol::new());
357        let event = Event::new(
358            &BLUETOOTH_SCAN_COMPLETE_EVENT,
359            serde_json::json!({
360                "devices": devices,
361            }),
362        );
363
364        // Copy the memory out before the call: the command loop shares this
365        // mutex, and a guard held across an LLM round-trip (which a `*` manual
366        // rule can park for minutes) would stall every injected command.
367        let memory_snapshot = client_data.lock().await.memory.clone();
368        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
369            match call_llm_for_client(
370                llm_client,
371                app_state,
372                client_id.to_string(),
373                &instruction,
374                &memory_snapshot,
375                Some(&event),
376                protocol.as_ref(),
377                status_tx,
378            )
379            .await
380            {
381                Ok(ClientLlmResult {
382                    actions,
383                    memory_updates,
384                }) => {
385                    // Update memory
386                    if let Some(mem) = memory_updates {
387                        client_data.lock().await.memory = mem;
388                    }
389
390                    // Execute actions
391                    for action in actions {
392                        if let Err(e) = Self::execute_llm_action(
393                            action,
394                            client_data,
395                            app_state,
396                            status_tx,
397                            llm_client,
398                            client_id,
399                        )
400                        .await
401                        {
402                            error!("Error executing Bluetooth action: {}", e);
403                        }
404                    }
405                }
406                Err(e) => {
407                    error!("LLM error for Bluetooth client {}: {}", client_id, e);
408                }
409            }
410        }
411
412        Ok(device_count)
413    }
414
415    /// Connect to a specific BLE device
416    fn connect_to_device<'a>(
417        client_data: &'a Arc<Mutex<ClientData>>,
418        app_state: &'a Arc<AppState>,
419        status_tx: &'a mpsc::UnboundedSender<String>,
420        llm_client: &'a OllamaClient,
421        client_id: ClientId,
422        device_address: Option<String>,
423        device_name: Option<String>,
424    ) -> BoxFuture<'a, Result<()>> {
425        Box::pin(async move {
426            let adapter = {
427                let data = client_data.lock().await;
428                data.adapter.clone()
429            };
430
431            // Scan for devices if we need to find by name
432            if device_name.is_some() {
433                info!("Bluetooth client {} scanning for device by name", client_id);
434                adapter.start_scan(ScanFilter::default()).await?;
435                tokio::time::sleep(Duration::from_secs(5)).await;
436                adapter.stop_scan().await?;
437            }
438
439            let peripherals = adapter.peripherals().await?;
440
441            // Find the target device
442            let mut target_peripheral: Option<Peripheral> = None;
443            for peripheral in peripherals {
444                if let Ok(Some(props)) = peripheral.properties().await {
445                    let matches = if let Some(ref addr) = device_address {
446                        props.address.to_string().eq_ignore_ascii_case(addr)
447                    } else if let Some(ref name) = device_name {
448                        props
449                            .local_name
450                            .as_ref()
451                            .map_or(false, |n| n.contains(name))
452                    } else {
453                        false
454                    };
455
456                    if matches {
457                        target_peripheral = Some(peripheral);
458                        break;
459                    }
460                }
461            }
462
463            let peripheral = target_peripheral.context("Device not found")?;
464
465            // Connect to the device
466            info!("Bluetooth client {} connecting to device", client_id);
467            let _ = status_tx.send(format!("[CLIENT] Connecting to BLE device..."));
468
469            peripheral.connect().await?;
470            peripheral.discover_services().await?;
471
472            let device_props = peripheral
473                .properties()
474                .await?
475                .context("No device properties")?;
476            let device_addr = device_props.address.to_string();
477            let device_name_str = device_props
478                .local_name
479                .unwrap_or_else(|| "Unknown".to_string());
480
481            info!(
482                "Bluetooth client {} connected to {} ({})",
483                client_id, device_name_str, device_addr
484            );
485            let _ = status_tx.send(format!("[CLIENT] Connected to {}", device_name_str));
486
487            // Store peripheral
488            {
489                let mut data = client_data.lock().await;
490                data.peripheral = Some(peripheral.clone());
491            }
492
493            // Set up notification handler using stream-based API
494            let client_data_clone = client_data.clone();
495            let app_state_clone = app_state.clone();
496            let status_tx_clone = status_tx.clone();
497            let llm_client_clone = llm_client.clone();
498            let peripheral_clone = peripheral.clone();
499
500            // Spawn task to handle notification stream
501            // Tracked, not detached: stop_client must abort this task too.
502            let task_owner = app_state.clone();
503            task_owner
504                .spawn_client_task(client_id, async move {
505                    match peripheral_clone.notifications().await {
506                        Ok(mut notification_stream) => {
507                            use futures::StreamExt;
508                            while let Some(notification) = notification_stream.next().await {
509                                let client_data = client_data_clone.clone();
510                                let app_state = app_state_clone.clone();
511                                let status_tx = status_tx_clone.clone();
512                                let llm_client = llm_client_clone.clone();
513
514                                trace!(
515                                    "Bluetooth notification received from {:?}",
516                                    notification.uuid
517                                );
518
519                                // Call LLM with notification
520                                let protocol = Arc::new(BluetoothClientProtocol::new());
521                                let event = Event::new(
522                                    &BLUETOOTH_NOTIFICATION_RECEIVED_EVENT,
523                                    serde_json::json!({
524                                        "service_uuid": "unknown", // btleplug doesn't provide service UUID in notification
525                                        "characteristic_uuid": notification.uuid.to_string(),
526                                        "value_hex": hex::encode(&notification.value),
527                                    }),
528                                );
529
530                                // Copy the memory out before the call: the command loop shares this
531                                // mutex, and a guard held across an LLM round-trip (which a `*` manual
532                                // rule can park for minutes) would stall every injected command.
533                                let memory_snapshot = client_data.lock().await.memory.clone();
534                                if let Some(instruction) =
535                                    app_state.get_instruction_for_client(client_id).await
536                                {
537                                    match call_llm_for_client(
538                                        &llm_client,
539                                        &app_state,
540                                        client_id.to_string(),
541                                        &instruction,
542                                        &memory_snapshot,
543                                        Some(&event),
544                                        protocol.as_ref(),
545                                        &status_tx,
546                                    )
547                                    .await
548                                    {
549                                        Ok(ClientLlmResult {
550                                            actions,
551                                            memory_updates,
552                                        }) => {
553                                            // Update memory
554                                            if let Some(mem) = memory_updates {
555                                                client_data.lock().await.memory = mem;
556                                            }
557
558                                            // Execute actions
559                                            for action in actions {
560                                                if let Err(e) = Self::execute_llm_action(
561                                                    action,
562                                                    &client_data,
563                                                    &app_state,
564                                                    &status_tx,
565                                                    &llm_client,
566                                                    client_id,
567                                                )
568                                                .await
569                                                {
570                                                    error!(
571                                                        "Error executing Bluetooth action: {}",
572                                                        e
573                                                    );
574                                                }
575                                            }
576                                        }
577                                        Err(e) => {
578                                            error!("LLM error for Bluetooth notification: {}", e);
579                                        }
580                                    }
581                                }
582                            }
583                        }
584                        Err(e) => {
585                            error!("Failed to get notification stream: {}", e);
586                        }
587                    }
588                })
589                .await;
590
591            // Call LLM with connected event
592            let protocol = Arc::new(BluetoothClientProtocol::new());
593            let event = Event::new(
594                &BLUETOOTH_CONNECTED_EVENT,
595                serde_json::json!({
596                    "device_address": device_addr,
597                    "device_name": device_name_str,
598                }),
599            );
600
601            // Copy the memory out before the call: the command loop shares this
602            // mutex, and a guard held across an LLM round-trip (which a `*` manual
603            // rule can park for minutes) would stall every injected command.
604            let memory_snapshot = client_data.lock().await.memory.clone();
605            if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
606                match call_llm_for_client(
607                    llm_client,
608                    app_state,
609                    client_id.to_string(),
610                    &instruction,
611                    &memory_snapshot,
612                    Some(&event),
613                    protocol.as_ref(),
614                    status_tx,
615                )
616                .await
617                {
618                    Ok(ClientLlmResult {
619                        actions,
620                        memory_updates,
621                    }) => {
622                        // Update memory
623                        if let Some(mem) = memory_updates {
624                            client_data.lock().await.memory = mem;
625                        }
626
627                        // Execute actions
628                        for action in actions {
629                            if let Err(e) = Self::execute_llm_action(
630                                action,
631                                client_data,
632                                app_state,
633                                status_tx,
634                                llm_client,
635                                client_id,
636                            )
637                            .await
638                            {
639                                error!("Error executing Bluetooth action: {}", e);
640                            }
641                        }
642                    }
643                    Err(e) => {
644                        error!("LLM error for Bluetooth client {}: {}", client_id, e);
645                    }
646                }
647            }
648
649            Ok(())
650        })
651    }
652
653    /// Execute an action returned by the LLM
654    fn execute_llm_action<'a>(
655        action: serde_json::Value,
656        client_data: &'a Arc<Mutex<ClientData>>,
657        app_state: &'a Arc<AppState>,
658        status_tx: &'a mpsc::UnboundedSender<String>,
659        llm_client: &'a OllamaClient,
660        client_id: ClientId,
661    ) -> BoxFuture<'a, Result<BluetoothApplied>> {
662        Box::pin(async move {
663            use crate::llm::actions::client_trait::Client;
664            let protocol = BluetoothClientProtocol::new();
665
666            match protocol.execute_action(action)? {
667                crate::llm::actions::client_trait::ClientActionResult::Custom { name, data } => {
668                    match name.as_str() {
669                        "scan_devices" => {
670                            let duration_secs = data["duration_secs"].as_u64().unwrap_or(5);
671                            let found = Box::pin(Self::perform_scan(
672                                client_data,
673                                app_state,
674                                status_tx,
675                                llm_client,
676                                client_id,
677                                duration_secs,
678                            ))
679                            .await?;
680                            Ok(BluetoothApplied::Executed(format!(
681                                "scan_devices ({duration_secs}s): {found} devices found"
682                            )))
683                        }
684                        "connect_device" => {
685                            let device_address =
686                                data["device_address"].as_str().map(|s| s.to_string());
687                            let device_name = data["device_name"].as_str().map(|s| s.to_string());
688                            let target = device_address
689                                .clone()
690                                .or_else(|| device_name.clone())
691                                .unwrap_or_else(|| "<unspecified>".to_string());
692                            Self::connect_to_device(
693                                client_data,
694                                app_state,
695                                status_tx,
696                                llm_client,
697                                client_id,
698                                device_address,
699                                device_name,
700                            )
701                            .await?;
702                            Ok(BluetoothApplied::Executed(format!(
703                                "connect_device {target}: connected"
704                            )))
705                        }
706                        "discover_services" => {
707                            let count = Self::discover_services(
708                                client_data,
709                                app_state,
710                                status_tx,
711                                llm_client,
712                                client_id,
713                            )
714                            .await?;
715                            Ok(BluetoothApplied::Executed(format!(
716                                "discover_services: {count} services"
717                            )))
718                        }
719                        "read_characteristic" => {
720                            let service_uuid = Uuid::parse_str(
721                                data["service_uuid"]
722                                    .as_str()
723                                    .context("Missing service_uuid")?,
724                            )?;
725                            let char_uuid = Uuid::parse_str(
726                                data["characteristic_uuid"]
727                                    .as_str()
728                                    .context("Missing characteristic_uuid")?,
729                            )?;
730                            let read = Self::read_characteristic(
731                                client_data,
732                                app_state,
733                                status_tx,
734                                llm_client,
735                                client_id,
736                                service_uuid,
737                                char_uuid,
738                            )
739                            .await?;
740                            Ok(BluetoothApplied::Executed(format!(
741                                "read_characteristic {char_uuid}: {read} bytes read"
742                            )))
743                        }
744                        "write_characteristic" => {
745                            let service_uuid = Uuid::parse_str(
746                                data["service_uuid"]
747                                    .as_str()
748                                    .context("Missing service_uuid")?,
749                            )?;
750                            let char_uuid = Uuid::parse_str(
751                                data["characteristic_uuid"]
752                                    .as_str()
753                                    .context("Missing characteristic_uuid")?,
754                            )?;
755                            let value_bytes = crate::client::wire_values::bytes(
756                                data["value_bytes"]
757                                    .as_array()
758                                    .context("Missing value_bytes")?,
759                            )?;
760                            let with_response = data["with_response"].as_bool().unwrap_or(true);
761                            let written = value_bytes.len();
762                            Self::write_characteristic(
763                                client_data,
764                                service_uuid,
765                                char_uuid,
766                                value_bytes,
767                                with_response,
768                            )
769                            .await?;
770                            // A completed GATT write really put these bytes on the
771                            // radio, so this is the one BLE verb that can report Sent.
772                            Ok(BluetoothApplied::Sent(written))
773                        }
774                        "subscribe_notifications" => {
775                            let service_uuid = Uuid::parse_str(
776                                data["service_uuid"]
777                                    .as_str()
778                                    .context("Missing service_uuid")?,
779                            )?;
780                            let char_uuid = Uuid::parse_str(
781                                data["characteristic_uuid"]
782                                    .as_str()
783                                    .context("Missing characteristic_uuid")?,
784                            )?;
785                            Self::subscribe_notifications(client_data, service_uuid, char_uuid)
786                                .await?;
787                            Ok(BluetoothApplied::Executed(format!(
788                                "subscribe_notifications {char_uuid}: subscribed"
789                            )))
790                        }
791                        "unsubscribe_notifications" => {
792                            let service_uuid = Uuid::parse_str(
793                                data["service_uuid"]
794                                    .as_str()
795                                    .context("Missing service_uuid")?,
796                            )?;
797                            let char_uuid = Uuid::parse_str(
798                                data["characteristic_uuid"]
799                                    .as_str()
800                                    .context("Missing characteristic_uuid")?,
801                            )?;
802                            Self::unsubscribe_notifications(client_data, service_uuid, char_uuid)
803                                .await?;
804                            Ok(BluetoothApplied::Executed(format!(
805                                "unsubscribe_notifications {char_uuid}: unsubscribed"
806                            )))
807                        }
808                        _ => {
809                            warn!("Unknown custom action: {}", name);
810                            Ok(BluetoothApplied::Executed(format!(
811                                "custom result '{name}' has no Bluetooth handler"
812                            )))
813                        }
814                    }
815                }
816                crate::llm::actions::client_trait::ClientActionResult::Disconnect => {
817                    Self::disconnect(client_data, app_state, client_id).await?;
818                    // Drop the command handle here rather than only in the command
819                    // loop: the LLM can disconnect too, and a handle left behind
820                    // would offer [ send ] into a closed peripheral.
821                    app_state.remove_client_handle(client_id).await;
822                    let _ = status_tx.send("__UPDATE_UI__".to_string());
823                    Ok(BluetoothApplied::Disconnected)
824                }
825                other => {
826                    debug!("Unhandled action result type");
827                    Ok(BluetoothApplied::Executed(format!(
828                        "unhandled action result {other:?}"
829                    )))
830                }
831            }
832        })
833    }
834
835    /// Discover GATT services and characteristics
836    async fn discover_services(
837        client_data: &Arc<Mutex<ClientData>>,
838        app_state: &Arc<AppState>,
839        status_tx: &mpsc::UnboundedSender<String>,
840        llm_client: &OllamaClient,
841        client_id: ClientId,
842    ) -> Result<usize> {
843        let peripheral = {
844            let data = client_data.lock().await;
845            data.peripheral.clone().context("Not connected to device")?
846        };
847
848        info!("Bluetooth client {} discovering services", client_id);
849
850        let services = peripheral.services();
851        let mut services_data = Vec::new();
852
853        for service in services {
854            let mut characteristics_data = Vec::new();
855
856            for char in service.characteristics {
857                let mut properties = Vec::new();
858                if char.properties.contains(CharPropFlags::READ) {
859                    properties.push("read".to_string());
860                }
861                if char.properties.contains(CharPropFlags::WRITE) {
862                    properties.push("write".to_string());
863                }
864                if char
865                    .properties
866                    .contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
867                {
868                    properties.push("write_without_response".to_string());
869                }
870                if char.properties.contains(CharPropFlags::NOTIFY) {
871                    properties.push("notify".to_string());
872                }
873                if char.properties.contains(CharPropFlags::INDICATE) {
874                    properties.push("indicate".to_string());
875                }
876
877                characteristics_data.push(serde_json::json!({
878                    "uuid": char.uuid.to_string(),
879                    "properties": properties,
880                }));
881            }
882
883            services_data.push(serde_json::json!({
884                "uuid": service.uuid.to_string(),
885                "primary": service.primary,
886                "characteristics": characteristics_data,
887            }));
888        }
889
890        info!(
891            "Bluetooth client {} discovered {} services",
892            client_id,
893            services_data.len()
894        );
895        let service_count = services_data.len();
896
897        // Call LLM with services discovered event
898        let protocol = Arc::new(BluetoothClientProtocol::new());
899        let event = Event::new(
900            &BLUETOOTH_SERVICES_DISCOVERED_EVENT,
901            serde_json::json!({
902                "services": services_data,
903            }),
904        );
905
906        // Copy the memory out before the call: the command loop shares this
907        // mutex, and a guard held across an LLM round-trip (which a `*` manual
908        // rule can park for minutes) would stall every injected command.
909        let memory_snapshot = client_data.lock().await.memory.clone();
910        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
911            match call_llm_for_client(
912                llm_client,
913                app_state,
914                client_id.to_string(),
915                &instruction,
916                &memory_snapshot,
917                Some(&event),
918                protocol.as_ref(),
919                status_tx,
920            )
921            .await
922            {
923                Ok(ClientLlmResult {
924                    actions,
925                    memory_updates,
926                }) => {
927                    // Update memory
928                    if let Some(mem) = memory_updates {
929                        client_data.lock().await.memory = mem;
930                    }
931
932                    // Execute actions
933                    for action in actions {
934                        if let Err(e) = Self::execute_llm_action(
935                            action,
936                            client_data,
937                            app_state,
938                            status_tx,
939                            llm_client,
940                            client_id,
941                        )
942                        .await
943                        {
944                            error!("Error executing Bluetooth action: {}", e);
945                        }
946                    }
947                }
948                Err(e) => {
949                    error!("LLM error for Bluetooth client {}: {}", client_id, e);
950                }
951            }
952        }
953
954        Ok(service_count)
955    }
956
957    /// Read a characteristic value
958    async fn read_characteristic(
959        client_data: &Arc<Mutex<ClientData>>,
960        app_state: &Arc<AppState>,
961        status_tx: &mpsc::UnboundedSender<String>,
962        llm_client: &OllamaClient,
963        client_id: ClientId,
964        service_uuid: Uuid,
965        char_uuid: Uuid,
966    ) -> Result<usize> {
967        let peripheral = {
968            let data = client_data.lock().await;
969            data.peripheral.clone().context("Not connected to device")?
970        };
971
972        debug!(
973            "Reading characteristic {} from service {}",
974            char_uuid, service_uuid
975        );
976
977        // Find the characteristic
978        let services = peripheral.services();
979        let characteristic = services
980            .iter()
981            .find(|s| s.uuid == service_uuid)
982            .and_then(|s| s.characteristics.iter().find(|c| c.uuid == char_uuid))
983            .context("Characteristic not found")?;
984
985        let value = peripheral.read(characteristic).await?;
986
987        info!(
988            "Read {} bytes from characteristic {}",
989            value.len(),
990            char_uuid
991        );
992
993        // Call LLM with read data
994        let protocol = Arc::new(BluetoothClientProtocol::new());
995        let event = Event::new(
996            &BLUETOOTH_DATA_READ_EVENT,
997            serde_json::json!({
998                "service_uuid": service_uuid.to_string(),
999                "characteristic_uuid": char_uuid.to_string(),
1000                "value_hex": hex::encode(&value),
1001            }),
1002        );
1003
1004        // Copy the memory out before the call: the command loop shares this
1005        // mutex, and a guard held across an LLM round-trip (which a `*` manual
1006        // rule can park for minutes) would stall every injected command.
1007        let memory_snapshot = client_data.lock().await.memory.clone();
1008        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
1009            match call_llm_for_client(
1010                llm_client,
1011                app_state,
1012                client_id.to_string(),
1013                &instruction,
1014                &memory_snapshot,
1015                Some(&event),
1016                protocol.as_ref(),
1017                status_tx,
1018            )
1019            .await
1020            {
1021                Ok(ClientLlmResult {
1022                    actions,
1023                    memory_updates,
1024                }) => {
1025                    // Update memory
1026                    if let Some(mem) = memory_updates {
1027                        client_data.lock().await.memory = mem;
1028                    }
1029
1030                    // Execute actions
1031                    for action in actions {
1032                        if let Err(e) = Self::execute_llm_action(
1033                            action,
1034                            client_data,
1035                            app_state,
1036                            status_tx,
1037                            llm_client,
1038                            client_id,
1039                        )
1040                        .await
1041                        {
1042                            error!("Error executing Bluetooth action: {}", e);
1043                        }
1044                    }
1045                }
1046                Err(e) => {
1047                    error!("LLM error for Bluetooth client {}: {}", client_id, e);
1048                }
1049            }
1050        }
1051
1052        Ok(value.len())
1053    }
1054
1055    /// Write a characteristic value
1056    async fn write_characteristic(
1057        client_data: &Arc<Mutex<ClientData>>,
1058        service_uuid: Uuid,
1059        char_uuid: Uuid,
1060        value: Vec<u8>,
1061        with_response: bool,
1062    ) -> Result<()> {
1063        let peripheral = {
1064            let data = client_data.lock().await;
1065            data.peripheral.clone().context("Not connected to device")?
1066        };
1067
1068        debug!(
1069            "Writing {} bytes to characteristic {} from service {}",
1070            value.len(),
1071            char_uuid,
1072            service_uuid
1073        );
1074
1075        // Find the characteristic
1076        let services = peripheral.services();
1077        let characteristic = services
1078            .iter()
1079            .find(|s| s.uuid == service_uuid)
1080            .and_then(|s| s.characteristics.iter().find(|c| c.uuid == char_uuid))
1081            .context("Characteristic not found")?;
1082
1083        let write_type = if with_response {
1084            WriteType::WithResponse
1085        } else {
1086            WriteType::WithoutResponse
1087        };
1088
1089        peripheral.write(characteristic, &value, write_type).await?;
1090
1091        info!(
1092            "Wrote {} bytes to characteristic {}",
1093            value.len(),
1094            char_uuid
1095        );
1096
1097        Ok(())
1098    }
1099
1100    /// Subscribe to notifications from a characteristic
1101    async fn subscribe_notifications(
1102        client_data: &Arc<Mutex<ClientData>>,
1103        service_uuid: Uuid,
1104        char_uuid: Uuid,
1105    ) -> Result<()> {
1106        let peripheral = {
1107            let data = client_data.lock().await;
1108            data.peripheral.clone().context("Not connected to device")?
1109        };
1110
1111        debug!(
1112            "Subscribing to characteristic {} from service {}",
1113            char_uuid, service_uuid
1114        );
1115
1116        // Find the characteristic
1117        let services = peripheral.services();
1118        let characteristic = services
1119            .iter()
1120            .find(|s| s.uuid == service_uuid)
1121            .and_then(|s| s.characteristics.iter().find(|c| c.uuid == char_uuid))
1122            .context("Characteristic not found")?;
1123
1124        peripheral.subscribe(characteristic).await?;
1125
1126        info!(
1127            "Subscribed to notifications from characteristic {}",
1128            char_uuid
1129        );
1130
1131        Ok(())
1132    }
1133
1134    /// Unsubscribe from notifications from a characteristic
1135    async fn unsubscribe_notifications(
1136        client_data: &Arc<Mutex<ClientData>>,
1137        service_uuid: Uuid,
1138        char_uuid: Uuid,
1139    ) -> Result<()> {
1140        let peripheral = {
1141            let data = client_data.lock().await;
1142            data.peripheral.clone().context("Not connected to device")?
1143        };
1144
1145        debug!(
1146            "Unsubscribing from characteristic {} from service {}",
1147            char_uuid, service_uuid
1148        );
1149
1150        // Find the characteristic
1151        let services = peripheral.services();
1152        let characteristic = services
1153            .iter()
1154            .find(|s| s.uuid == service_uuid)
1155            .and_then(|s| s.characteristics.iter().find(|c| c.uuid == char_uuid))
1156            .context("Characteristic not found")?;
1157
1158        peripheral.unsubscribe(characteristic).await?;
1159
1160        info!(
1161            "Unsubscribed from notifications from characteristic {}",
1162            char_uuid
1163        );
1164
1165        Ok(())
1166    }
1167
1168    /// Disconnect from the BLE device
1169    async fn disconnect(
1170        client_data: &Arc<Mutex<ClientData>>,
1171        app_state: &Arc<AppState>,
1172        client_id: ClientId,
1173    ) -> Result<()> {
1174        let peripheral = {
1175            let mut data = client_data.lock().await;
1176            data.peripheral.take()
1177        };
1178
1179        if let Some(peripheral) = peripheral {
1180            peripheral.disconnect().await?;
1181            info!("Bluetooth client {} disconnected", client_id);
1182            app_state
1183                .update_client_status(client_id, ClientStatus::Disconnected)
1184                .await;
1185        }
1186
1187        Ok(())
1188    }
1189}