Skip to main content

netget/server/bluetooth_ble/
mod.rs

1//! Bluetooth Low Energy (BLE) GATT server implementation
2//!
3//! Cross-platform peripheral/server mode using ble-peripheral-rust
4//! Platforms: Windows (WinRT), macOS (CoreBluetooth), Linux (BlueZ)
5
6pub mod actions;
7
8use anyhow::{Context, Result};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, warn};
13
14use crate::llm::action_helper::call_llm;
15use crate::llm::ollama_client::OllamaClient;
16use crate::logging::emit::Log;
17use crate::protocol::Event;
18use crate::state::app_state::AppState;
19use crate::{console_error, console_info, console_trace};
20use actions::{
21    BluetoothBleProtocol, BLUETOOTH_BLE_STARTED_EVENT, BLUETOOTH_READ_REQUEST_EVENT,
22    BLUETOOTH_STATE_CHANGED_EVENT, BLUETOOTH_SUBSCRIBE_EVENT, BLUETOOTH_WRITE_REQUEST_EVENT,
23};
24
25#[cfg(all(unix, feature = "bluetooth"))]
26use ble_peripheral_rust::gatt::characteristic::Characteristic;
27#[cfg(all(unix, feature = "bluetooth"))]
28use ble_peripheral_rust::gatt::peripheral_event::{
29    PeripheralEvent, ReadRequestResponse, RequestResponse, WriteRequestResponse,
30};
31#[cfg(all(unix, feature = "bluetooth"))]
32use ble_peripheral_rust::gatt::properties::{AttributePermission, CharacteristicProperty};
33#[cfg(all(unix, feature = "bluetooth"))]
34use ble_peripheral_rust::gatt::service::Service;
35#[cfg(all(unix, feature = "bluetooth"))]
36use ble_peripheral_rust::{Peripheral, PeripheralImpl};
37#[cfg(all(unix, feature = "bluetooth"))]
38use uuid::Uuid;
39
40/// The longest LocalName that fits an AD structure inside a 31-octet legacy advertisement,
41/// after its length and type bytes. A name past this is refused by the radio rather than
42/// shortened, so NetGet shortens it first.
43const MAX_LOCAL_NAME_CHARS: usize = 29;
44
45/// Per-characteristic data for tracking pending requests
46#[derive(Debug)]
47struct CharacteristicData {
48    #[allow(dead_code)]
49    uuid: String,
50    #[allow(dead_code)]
51    properties: Vec<String>,
52    #[allow(dead_code)]
53    permissions: Vec<String>,
54    current_value: Vec<u8>,
55}
56
57/// Server data for BLE peripheral
58struct ServerData {
59    /// The process-wide shared radio, or `None` in the radio-free test loop.
60    hub: Option<Arc<BleHub>>,
61    /// This server's own slice of the shared event stream, used to register characteristic
62    /// routes with the hub as services are added. `None` in the radio-free test loop.
63    event_tx: Option<mpsc::Sender<PeripheralEvent>>,
64    memory: String,
65    characteristics: HashMap<String, CharacteristicData>,
66}
67
68/// Parse a BLE UUID, expanding the 16- and 32-bit shorthands.
69///
70/// Bluetooth assigns short UUIDs (`180D` for Heart Rate, `2A37` for its
71/// measurement characteristic) that stand for the full 128-bit value
72/// `0000XXXX-0000-1000-8000-00805F9B34FB`. Every example in this protocol's
73/// actions and CLAUDE.md uses that shorthand, and the docs stated it was
74/// "expanded to" the long form — but nothing expanded it, and
75/// `Uuid::parse_str("180D")` fails, so a model copying the protocol's own
76/// documented example got `Invalid service UUID`.
77///
78/// Accepts a 4-hex-digit (16-bit) or 8-hex-digit (32-bit) shorthand, or any
79/// form `Uuid::parse_str` already understands.
80///
81/// `pub` so `tests/` can exercise it directly — CLAUDE.md forbids unit-test
82/// modules in `src/`, so an internal helper has to be reachable to be tested.
83/// The ATT Write Response a `respond_to_write` action asks for.
84///
85/// `respond_to_write` has always declared a `status` of `'success'` or `'error'`, and nothing
86/// anywhere read it — both consumption sites skip the action as "handled inline" and the
87/// inline handler answered `Success` unconditionally. So a model rejecting a write (value out
88/// of range, wrong length, characteristic not writable in this state) had its decision
89/// discarded and the central was told the write had been accepted. The ATT Write Response is
90/// the only signal the central gets.
91///
92/// Silence still means success: most writes are accepted, and a handler that answers a
93/// `bluetooth_write_request` without naming a status has consented to it. Only an explicit
94/// `'error'` refuses. An LLM *failure* is handled separately by the caller and stays
95/// distinct — that path logs `the handler failed` where this one logs `model_reject`.
96///
97/// `pub` so `tests/` can exercise it without a Bluetooth adapter.
98#[cfg(all(unix, feature = "bluetooth"))]
99pub fn write_response_status(raw_actions: &[serde_json::Value]) -> RequestResponse {
100    let refused = raw_actions.iter().any(|action| {
101        matches!(
102            action.get("type").and_then(|v| v.as_str()),
103            Some("respond_to_write") | Some("send_write_response")
104        ) && action
105            .get("status")
106            .and_then(|v| v.as_str())
107            .is_some_and(|status| status.eq_ignore_ascii_case("error"))
108    });
109
110    if refused {
111        RequestResponse::UnlikelyError
112    } else {
113        RequestResponse::Success
114    }
115}
116
117/// What a successful handler round-trip decided about a `bluetooth_read_request`.
118///
119/// Kept separate from the event loop so the decision is testable without a Bluetooth adapter,
120/// and so the three outcomes cannot silently collapse into one another.
121#[cfg(all(unix, feature = "bluetooth"))]
122#[derive(Debug, PartialEq, Eq)]
123pub enum ReadDecision {
124    /// The handler named a value: serve exactly it. `decision=model_value`.
125    Value(Vec<u8>),
126    /// The handler answered without naming a value. `decision=model_silent` — the stored
127    /// characteristic value is served *if there is one*; there is nothing to invent if not.
128    UseStored,
129    /// The handler produced a `respond_to_read` whose value cannot be used (not hex, or no
130    /// `value` field at all). `decision=fail_closed_bad_value` — an answer that cannot be
131    /// decoded is not an answer, and must not silently become the stored value.
132    Unusable(String),
133}
134
135/// Classify the handler's answer to a read request.
136///
137/// The dangerous case this exists to keep out is an unparseable `respond_to_read` degrading into
138/// the stored-value fallback: the model *tried* to say what the characteristic holds, we could
139/// not decode it, and the central would otherwise be handed a different value under
140/// `RequestResponse::Success` with nothing on the wire to say so.
141///
142/// `pub` so `tests/` can exercise it without a Bluetooth adapter.
143#[cfg(all(unix, feature = "bluetooth"))]
144pub fn read_decision(raw_actions: &[serde_json::Value]) -> ReadDecision {
145    let Some(action) = raw_actions.iter().find(|a| {
146        matches!(
147            a.get("type").and_then(|v| v.as_str()),
148            Some("respond_to_read") | Some("send_read_response")
149        )
150    }) else {
151        return ReadDecision::UseStored;
152    };
153
154    let Some(value) = action.get("value").and_then(|v| v.as_str()) else {
155        return ReadDecision::Unusable(
156            "respond_to_read carried no 'value' field (hex-encoded bytes)".to_string(),
157        );
158    };
159
160    match hex::decode(value.trim_start_matches("0x")) {
161        Ok(bytes) => ReadDecision::Value(bytes),
162        Err(e) => ReadDecision::Unusable(format!(
163            "respond_to_read 'value' is not hex-encoded ({e}); \
164             expected something like \"0048\" or \"0x0048\""
165        )),
166    }
167}
168
169#[cfg(all(unix, feature = "bluetooth"))]
170pub fn parse_ble_uuid(s: &str) -> Result<Uuid> {
171    let t = s.trim();
172    let is_hex = |v: &str| v.chars().all(|c| c.is_ascii_hexdigit());
173
174    if (t.len() == 4 || t.len() == 8) && is_hex(t) {
175        // Left-pad the 16-bit form to 32 bits, then splice into the BLE base UUID.
176        let short = format!("{:0>8}", t.to_ascii_lowercase());
177        let full = format!("{short}-0000-1000-8000-00805f9b34fb");
178        return Uuid::parse_str(&full)
179            .with_context(|| format!("Invalid BLE short UUID {t:?} (expanded to {full})"));
180    }
181
182    Uuid::parse_str(t).with_context(|| {
183        format!(
184            "Invalid UUID {t:?}. Use a 16-bit shorthand like \"180D\", a 32-bit one, \
185             or a full 128-bit UUID."
186        )
187    })
188}
189
190/// The single key a characteristic is filed and looked up under.
191///
192/// Always the canonical lowercase-hyphenated 128-bit form, which is exactly what
193/// `request.characteristic.to_string()` yields on the radio's read/write/subscribe events. So
194/// every spelling of the same characteristic — the `"2A37"` shorthand every documented example
195/// in this protocol uses, the 32-bit form, and the full 128-bit UUID — lands on one entry.
196///
197/// Three different spellings were in play before, and none of the tests noticed because every
198/// one of them used the full form:
199///
200/// * `add_service` filed the stored value under **the model's own spelling**
201///   (`characteristics.insert(char_uuid_str, ..)`), while the read and write paths looked it up
202///   under **the radio's canonical form**. So a service added with the protocol's own
203///   documented `"uuid": "2A37"` was unreachable: a read the handler declined to answer fell
204///   through to `decision=fail_closed_model_silent_no_value` and replied ATT Unlikely Error
205///   although `initial_value` had supplied one, and a write never updated the stored value.
206/// * `send_notification` used the model's spelling again, a third variant that agreed with
207///   `add_service` only when both happened to be written the same way.
208/// * [`BleRouter::register_characteristic`] lowercased without expanding, so a route registered
209///   as `"2a37"` was never found by a lookup for `"00002a37-0000-1000-8000-00805f9b34fb"`. That
210///   one failed quietly into the "newest live server" fallback, which is why it survived: with
211///   a single BLE server the fallback is the right answer anyway. With two — a base server and
212///   any of the fifteen profiles that delegate to this event loop — the second server captured
213///   the first server's characteristic traffic.
214///
215/// An unparseable UUID falls back to the trimmed lowercase input rather than panicking.
216/// `add_service` rejects those before they can be filed, so this is only reachable from a
217/// lookup for a characteristic that was never added, where a miss is the correct outcome.
218#[cfg(all(unix, feature = "bluetooth"))]
219pub fn characteristic_key(uuid: &str) -> String {
220    match parse_ble_uuid(uuid) {
221        Ok(parsed) => parsed.to_string(),
222        Err(_) => uuid.trim().to_lowercase(),
223    }
224}
225
226/// Process-wide shared BLE radio.
227///
228/// `ble-peripheral-rust`'s CoreBluetooth backend funnels *every* `Peripheral` through a single
229/// process-global manager thread guarded by its own `static PERIPHERAL_THREAD: OnceCell<()>`
230/// (`peripheral_manager.rs:50`). Only the **first** `Peripheral::new()` in a process actually
231/// spawns that thread and wires up its command channel; every later `Peripheral::new()` builds a
232/// fresh `manager_tx` whose receiver is dropped on the spot, so all of its commands — including
233/// `is_powered()` — fail silently. The second BLE server start therefore saw `is_powered()`
234/// return `false` forever and bailed after the 20×500ms wait with a bogus "adapter failed to
235/// power on after 10 seconds", while the adapter was fine (IMPROVEMENTS item 2).
236///
237/// CoreBluetooth is genuinely one-manager-per-process anyway (one GATT database, one advertising
238/// state, one radio), so the correct model is a single shared `Peripheral` reused across every
239/// BLE server start. That is what `BleHub` is. It is created exactly once, on the first start,
240/// via [`BLE_HUB`]; the adapter power-on wait happens once there, so subsequent starts reuse the
241/// live radio with no wait and no hang.
242///
243/// The single shared radio also means a single shared event stream: `Peripheral::new` takes one
244/// `PeripheralEvent` sender for the whole process. The hub owns that stream and a dispatcher task
245/// hands each event to its [`BleRouter`], which fans it out to the owning server by characteristic
246/// UUID, falling back to the most-recently-started live server, and broadcasts adapter
247/// `StateUpdate`s to all of them.
248#[cfg(all(unix, feature = "bluetooth"))]
249struct BleHub {
250    /// The one real radio. Its mutating methods take `&mut self`, so calls are serialised here.
251    peripheral: Mutex<Peripheral>,
252    /// Where the single event stream is fanned out to per-server channels.
253    router: BleRouter,
254}
255
256/// Routes one shared radio's events to the right per-server channel.
257///
258/// Split out of [`BleHub`] so it is exercisable without a Bluetooth adapter: it holds no
259/// `Peripheral`, only channels, so a test can register stub servers, dispatch events, and assert
260/// which server received what. It is `pub` for the same reason `run_event_loop_without_radio` and
261/// `parse_ble_uuid` are — the project forbids `#[cfg(test)]` modules in `src/`.
262#[cfg(all(unix, feature = "bluetooth"))]
263#[derive(Default)]
264pub struct BleRouter {
265    /// characteristic UUID (lowercased) -> the event channel of the server that added it.
266    routes: std::sync::Mutex<HashMap<String, mpsc::Sender<PeripheralEvent>>>,
267    /// Every started server's event channel, for events with no characteristic (StateUpdate)
268    /// and as the routing fallback.
269    servers: std::sync::Mutex<Vec<mpsc::Sender<PeripheralEvent>>>,
270}
271
272#[cfg(all(unix, feature = "bluetooth"))]
273static BLE_HUB: tokio::sync::OnceCell<Arc<BleHub>> = tokio::sync::OnceCell::const_new();
274
275#[cfg(all(unix, feature = "bluetooth"))]
276impl BleRouter {
277    /// A router with no registered servers.
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// Register a freshly started server so adapter-level events and unrouted requests can reach
283    /// it.
284    pub fn register_server(&self, tx: mpsc::Sender<PeripheralEvent>) {
285        self.servers.lock().unwrap().push(tx);
286    }
287
288    /// Point a characteristic's future read/write/subscribe events at the server that owns it.
289    ///
290    /// Keyed through [`characteristic_key`], so a route registered from the `"2A37"` shorthand
291    /// is found by a lookup for the canonical form the radio reports. Lowercasing alone was not
292    /// enough and failed silently — see [`characteristic_key`].
293    pub fn register_characteristic(&self, char_uuid: &str, tx: mpsc::Sender<PeripheralEvent>) {
294        self.routes
295            .lock()
296            .unwrap()
297            .insert(characteristic_key(char_uuid), tx);
298    }
299
300    /// Choose where a characteristic event goes: its registered owner, else the newest live
301    /// server. Closed channels (stopped servers) are skipped so a dead server never captures
302    /// traffic.
303    fn route_target(&self, char_uuid_lc: &str) -> Option<mpsc::Sender<PeripheralEvent>> {
304        if let Some(tx) = self.routes.lock().unwrap().get(char_uuid_lc) {
305            if !tx.is_closed() {
306                return Some(tx.clone());
307            }
308        }
309        self.servers
310            .lock()
311            .unwrap()
312            .iter()
313            .rev()
314            .find(|tx| !tx.is_closed())
315            .cloned()
316    }
317
318    /// Every still-open server channel.
319    fn live_servers(&self) -> Vec<mpsc::Sender<PeripheralEvent>> {
320        self.servers
321            .lock()
322            .unwrap()
323            .iter()
324            .filter(|tx| !tx.is_closed())
325            .cloned()
326            .collect()
327    }
328
329    /// Fan one radio event out to the owning server(s). Never holds a std lock across an await —
330    /// senders are cloned out first.
331    pub async fn dispatch(&self, event: PeripheralEvent) {
332        match event {
333            // No characteristic: an adapter state change concerns every server on the radio.
334            // `PeripheralEvent` is not `Clone` (read/write carry a `oneshot` responder), but
335            // `StateUpdate` is trivially rebuildable, so it can be fanned out.
336            PeripheralEvent::StateUpdate { is_powered } => {
337                for tx in self.live_servers() {
338                    let _ = tx.send(PeripheralEvent::StateUpdate { is_powered }).await;
339                }
340            }
341            // Everything else names a characteristic and carries at most one responder, so it
342            // goes to exactly one server.
343            ev => {
344                let char_uuid_lc = match &ev {
345                    PeripheralEvent::ReadRequest { request, .. }
346                    | PeripheralEvent::WriteRequest { request, .. }
347                    | PeripheralEvent::CharacteristicSubscriptionUpdate { request, .. } => {
348                        characteristic_key(&request.characteristic.to_string())
349                    }
350                    PeripheralEvent::StateUpdate { .. } => unreachable!(),
351                };
352                match self.route_target(&char_uuid_lc) {
353                    Some(tx) => {
354                        let _ = tx.send(ev).await;
355                    }
356                    None => {
357                        // No live server owns this characteristic. A read/write responder is
358                        // dropped here, which the central surfaces as a failed operation — the
359                        // honest outcome when nothing is behind the GATT entry.
360                        warn!(
361                            "BLE event for characteristic {} has no live server to handle it; \
362                             dropping",
363                            char_uuid_lc
364                        );
365                    }
366                }
367            }
368        }
369    }
370}
371
372/// Bluetooth Low Energy GATT server
373pub struct BluetoothBle;
374
375impl BluetoothBle {
376    /// Spawn the BLE GATT server with integrated LLM actions
377    #[cfg(all(unix, feature = "bluetooth"))]
378    pub async fn spawn_with_llm_actions(
379        device_name: String,
380        llm_client: OllamaClient,
381        app_state: Arc<AppState>,
382        status_tx: mpsc::UnboundedSender<String>,
383        server_id: crate::state::ServerId,
384        instruction: String,
385    ) -> Result<std::net::SocketAddr> {
386        // Acquire the process-wide shared radio. It is created (and the adapter powered on)
387        // exactly once, on the first BLE server start; every later start reuses it with no
388        // wait. Creating a fresh `Peripheral` per start is what IMPROVEMENTS item 2 describes:
389        // the crate's global manager singleton leaves the second peripheral's command channel
390        // dead, so `is_powered()` never returns true and the start falsely times out. See
391        // [`BleHub`].
392        let hub = Self::shared_hub(&status_tx)
393            .await
394            .context("Failed to bring up the shared BLE radio")?;
395
396        info!("Bluetooth server created on shared radio, adapter powered on");
397        Log::new(Some(&status_tx)).info(format!(
398            "Bluetooth server created for device '{}'",
399            device_name
400        ));
401
402        // This server's slice of the shared event stream. The hub's dispatcher forwards this
403        // server's characteristic events here; adapter state changes are broadcast here too.
404        let (event_tx, event_rx) = mpsc::channel::<PeripheralEvent>(256);
405        hub.router.register_server(event_tx.clone());
406
407        // Create server data
408        let server_data = Arc::new(Mutex::new(ServerData {
409            hub: Some(hub.clone()),
410            event_tx: Some(event_tx.clone()),
411            memory: String::new(),
412            characteristics: HashMap::new(),
413        }));
414
415        let protocol = Arc::new(BluetoothBleProtocol::new());
416
417        // Call LLM with server started event to get initial configuration
418        let started_event = Event::new(
419            &BLUETOOTH_BLE_STARTED_EVENT,
420            serde_json::json!({
421                "device_name": device_name,
422                "instruction": instruction,
423            }),
424        );
425
426        info!("Calling LLM for initial Bluetooth server configuration");
427
428        // Bringing the adapter up must not require the model to be reachable: the LLM answers
429        // traffic, it does not open the radio. This call used to propagate with `?`, so an
430        // Ollama outage made `spawn()` return `Err` and the server never started.
431        //
432        // Unlike NFC and the USB smart card reader there is no useful default here — the
433        // configuration *is* the services and the advertisement — so a failure leaves a
434        // powered adapter advertising nothing. That is a much better outcome than a server
435        // that will not start, but it is not a working one, so it is logged at ERROR on both
436        // channels saying exactly that. The individual actions are non-fatal for the same
437        // reason `executor::execute_actions` does not abort a batch on one bad action:
438        // dropping the rest would suppress the services that were fine.
439        match call_llm(
440            &llm_client,
441            &app_state,
442            server_id,
443            None, // No connection_id for server-level actions
444            &started_event,
445            protocol.as_ref(),
446        )
447        .await
448        {
449            Ok(llm_result) => {
450                // Execute initial actions (add services, start advertising, etc.)
451                let mut failed = 0usize;
452                let mut attempted = 0usize;
453                for action in llm_result.raw_actions {
454                    attempted += 1;
455                    debug!(
456                        "Executing initial Bluetooth action: {:?}",
457                        action.get("type")
458                    );
459                    if let Err(e) =
460                        Self::execute_action(&server_data, &device_name, action, &status_tx).await
461                    {
462                        failed += 1;
463                        Log::new(Some(&status_tx)).error(format!(
464                            "Bluetooth startup for '{device_name}' \
465                             decision=fail_closed_bad_action: initial action failed: {e}"
466                        ));
467                    }
468                }
469
470                // Startup is the one BLE outcome no per-event tag can reach, and it is the
471                // one that leaves the server looking healthy while it is not: a powered
472                // adapter with no services and no advertisement, reported as `Running`.
473                // Tagging it is what lets `grep decision=` find that state at all.
474                let log = Log::new(Some(&status_tx));
475                if attempted == 0 {
476                    log.warn(format!(
477                        "Bluetooth startup for '{device_name}' decision=model_silent: the \
478                         model configured nothing, so the adapter is powered with no services \
479                         and is NOT advertising"
480                    ));
481                } else if failed == attempted {
482                    log.error(format!(
483                        "Bluetooth startup for '{device_name}' \
484                         decision=fail_closed_no_action: every initial action failed; the \
485                         adapter is powered with no services and is NOT advertising"
486                    ));
487                } else {
488                    log.info(format!(
489                        "Bluetooth startup for '{device_name}' decision=model_answer: \
490                         {} of {attempted} initial action(s) applied",
491                        attempted - failed
492                    ));
493                }
494            }
495            Err(e) => {
496                // Deliberately not fatal — the adapter is up and the event loop below can
497                // still answer reads — but the server reports `Running` with nothing on air,
498                // so this must be greppable as a backend failure rather than as a decision
499                // anybody took.
500                let decision = if crate::utils::WireFailure::classify(&e).is_overloaded() {
501                    "fail_closed_llm_overloaded"
502                } else {
503                    "fail_closed_llm_error"
504                };
505                error!(
506                    "Bluetooth startup configuration failed for '{}' decision={}: {}; the \
507                     adapter is powered but has no services and is NOT advertising",
508                    device_name, decision, e
509                );
510                Log::new(Some(&status_tx)).error(format!(
511                    "Bluetooth startup for '{device_name}' decision={decision}: the adapter \
512                     is up but no services were added and it is not advertising."
513                ));
514            }
515        }
516
517        // Spawn event processing loop
518        let llm_client_clone = llm_client.clone();
519        let app_state_clone = app_state.clone();
520        let status_tx_clone = status_tx.clone();
521        let server_data_clone = server_data.clone();
522        let protocol_clone = protocol.clone();
523
524        // Tracked, not detached: stop_server must abort this task too.
525        let task_owner = app_state.clone();
526        task_owner
527            .spawn_server_task(server_id, async move {
528                Self::event_loop(
529                    event_rx,
530                    server_id,
531                    llm_client_clone,
532                    app_state_clone,
533                    status_tx_clone,
534                    server_data_clone,
535                    protocol_clone,
536                )
537                .await;
538            })
539            .await;
540
541        // BLE speaks to a radio, not a socket, so there is no endpoint to report.
542        //
543        // This used to return `127.0.0.1:{5900 + server_id % 100}` "for display purposes".
544        // `server_startup::is_bound_addr` only rejects port 0, so that address was recorded as
545        // `local_addr` and the TUI, `server_status` and every log reader were told the BLE
546        // server was listening on a loopback port that nothing had bound — and on 5900, which
547        // is VNC's port, so it could also collide with a real server in the display. Port 0 is
548        // this codebase's "binds no listening socket" placeholder and is recognised as such.
549        Ok(std::net::SocketAddr::from((
550            std::net::Ipv4Addr::UNSPECIFIED,
551            0,
552        )))
553    }
554
555    /// Get (creating once) the process-wide shared BLE radio.
556    ///
557    /// The first call builds the single `Peripheral`, waits for the adapter to power on, and
558    /// spawns the dispatcher task that fans radio events out to per-server channels. Every later
559    /// call returns the same `Arc<BleHub>` immediately. On failure the `OnceCell` stays empty so
560    /// a subsequent start retries rather than caching the failure.
561    #[cfg(all(unix, feature = "bluetooth"))]
562    async fn shared_hub(status_tx: &mpsc::UnboundedSender<String>) -> Result<Arc<BleHub>> {
563        let status_tx = status_tx.clone();
564        BLE_HUB
565            .get_or_try_init(move || async move {
566                info!("Creating shared BLE peripheral (first BLE server in this process)");
567
568                // The single process-wide event stream. `Peripheral::new` bakes this sender into
569                // the crate's global manager; the hub dispatcher owns the receiver.
570                let (event_tx, mut event_rx) = mpsc::channel::<PeripheralEvent>(256);
571                let mut peripheral = Peripheral::new(event_tx)
572                    .await
573                    .context("Failed to create BLE peripheral")?;
574
575                // Wait for the adapter to power on — once, here, not per server start.
576                let mut retries = 0;
577                while !peripheral.is_powered().await.unwrap_or(false) {
578                    if retries == 0 {
579                        Log::new(Some(&status_tx))
580                            .warn("Bluetooth adapter is not powered on, waiting...");
581                    }
582                    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
583                    retries += 1;
584                    if retries > 20 {
585                        anyhow::bail!("Bluetooth adapter failed to power on after 10 seconds");
586                    }
587                }
588
589                info!("Bluetooth adapter powered on (shared radio)");
590                Log::new(Some(&status_tx)).info("Bluetooth adapter powered on");
591
592                let hub = Arc::new(BleHub {
593                    peripheral: Mutex::new(peripheral),
594                    router: BleRouter::new(),
595                });
596
597                // Single dispatcher for the whole process: route each radio event to the server
598                // that owns the characteristic (or broadcast, for adapter state).
599                let dispatch_hub = hub.clone();
600                tokio::spawn(async move {
601                    while let Some(event) = event_rx.recv().await {
602                        dispatch_hub.router.dispatch(event).await;
603                    }
604                    warn!("BLE shared radio event stream ended; no further events will be routed");
605                });
606
607                Ok::<Arc<BleHub>, anyhow::Error>(hub)
608            })
609            .await
610            .cloned()
611    }
612
613    /// Execute a single LLM action
614    #[cfg(all(unix, feature = "bluetooth"))]
615    async fn execute_action(
616        server_data: &Arc<Mutex<ServerData>>,
617        device_name: &str,
618        action: serde_json::Value,
619        status_tx: &mpsc::UnboundedSender<String>,
620    ) -> Result<()> {
621        let action_type = action["type"]
622            .as_str()
623            .context("Action must have 'type' field")?;
624
625        match action_type {
626            "add_service" => Self::execute_add_service(server_data, action, status_tx).await,
627            "start_advertising" => {
628                Self::execute_start_advertising(server_data, device_name, action, status_tx).await
629            }
630            "stop_advertising" => Self::execute_stop_advertising(server_data, status_tx).await,
631            "send_notification" => {
632                Self::execute_send_notification(server_data, action, status_tx).await
633            }
634            "respond_to_read" | "send_read_response" => {
635                // Read responses are handled inline in event loop
636                Ok(())
637            }
638            "respond_to_write" | "send_write_response" => {
639                // Write responses are handled inline in event loop
640                Ok(())
641            }
642            _ => {
643                // Not `Ok(())`. An unrecognised action did nothing, and reporting success for it
644                // made a misspelled or invented action name indistinguishable from one that ran:
645                // the only trace was a `warn!` in `netget.log`, nothing on the status channel.
646                // The callers log the error and continue with the rest of the batch, so this
647                // stays non-fatal — it is now merely honest about what happened.
648                Err(anyhow::anyhow!(
649                    "Unknown Bluetooth action type {action_type:?}; nothing was done. Known \
650                     actions: add_service, start_advertising, stop_advertising, \
651                     send_notification, respond_to_read, respond_to_write."
652                ))
653            }
654        }
655    }
656
657    /// Add a GATT service
658    #[cfg(all(unix, feature = "bluetooth"))]
659    async fn execute_add_service(
660        server_data: &Arc<Mutex<ServerData>>,
661        action: serde_json::Value,
662        status_tx: &mpsc::UnboundedSender<String>,
663    ) -> Result<()> {
664        let uuid_str = action["uuid"]
665            .as_str()
666            .context("add_service requires 'uuid' field")?;
667        let primary = action["primary"].as_bool().unwrap_or(true);
668
669        let uuid = parse_ble_uuid(uuid_str).context("Invalid service UUID")?;
670
671        let chars_json = action["characteristics"]
672            .as_array()
673            .context("add_service requires 'characteristics' array")?;
674
675        let mut characteristics = Vec::new();
676        let mut char_uuids: Vec<String> = Vec::new();
677        let mut server_data_guard = server_data.lock().await;
678
679        for char_json in chars_json {
680            let char_uuid_str = char_json["uuid"]
681                .as_str()
682                .context("characteristic requires 'uuid' field")?;
683            let char_uuid = parse_ble_uuid(char_uuid_str).context("Invalid characteristic UUID")?;
684
685            // Parse properties
686            let props_json = char_json["properties"]
687                .as_array()
688                .context("characteristic requires 'properties' array")?;
689            let mut properties = Vec::new();
690            for prop in props_json {
691                let prop_str = prop.as_str().context("property must be string")?;
692                properties.push(match prop_str.to_lowercase().as_str() {
693                    "read" => CharacteristicProperty::Read,
694                    "write" => CharacteristicProperty::Write,
695                    "notify" => CharacteristicProperty::Notify,
696                    "indicate" => CharacteristicProperty::Indicate,
697                    "write_without_response" => CharacteristicProperty::WriteWithoutResponse,
698                    _ => {
699                        warn!("Unknown property: {}, defaulting to Read", prop_str);
700                        CharacteristicProperty::Read
701                    }
702                });
703            }
704
705            // Parse permissions
706            let empty_perms = Vec::new();
707            let perms_json = char_json["permissions"].as_array().unwrap_or(&empty_perms);
708            let mut permissions = Vec::new();
709            for perm in perms_json {
710                let perm_str = perm.as_str().context("permission must be string")?;
711                permissions.push(match perm_str.to_lowercase().as_str() {
712                    "readable" => AttributePermission::Readable,
713                    "writeable" => AttributePermission::Writeable,
714                    _ => {
715                        warn!("Unknown permission: {}", perm_str);
716                        continue;
717                    }
718                });
719            }
720
721            // Parse initial value (hex-encoded).
722            //
723            // A value that will not decode is refused, not silently dropped. `unwrap_or_default()`
724            // turned a malformed `initial_value` into *zero bytes stored for that
725            // characteristic*, and the read path then served exactly those zero bytes under
726            // `RequestResponse::Success` on `decision=model_silent` — telling the central "this
727            // characteristic holds nothing" on the strength of a value nobody could read. That is
728            // the same defect `ReadDecision::Unusable` exists to prevent one layer up: an answer
729            // that cannot be decoded is not an answer. Failing here surfaces the typo to the
730            // model instead of baking it into the GATT database.
731            let initial_value = parse_initial_value(&char_uuid_str, &char_json["initial_value"])?;
732
733            // Store characteristic data for tracking, keyed canonically so the read, write and
734            // notification paths can find it whatever spelling the model used here.
735            server_data_guard.characteristics.insert(
736                characteristic_key(char_uuid_str),
737                CharacteristicData {
738                    // The model's own spelling, kept for diagnostics.
739                    uuid: char_uuid_str.to_string(),
740                    properties: props_json
741                        .iter()
742                        .filter_map(|v| v.as_str().map(String::from))
743                        .collect(),
744                    permissions: perms_json
745                        .iter()
746                        .filter_map(|v| v.as_str().map(String::from))
747                        .collect(),
748                    current_value: initial_value.clone(),
749                },
750            );
751
752            // `mut` is only used by the Apple-specific guard below.
753            #[allow(unused_mut)]
754            let mut cached_value = if initial_value.is_empty() {
755                None
756            } else {
757                Some(initial_value)
758            };
759
760            // CoreBluetooth (macOS/iOS) raises NSInvalidArgumentException from
761            // -[CBMutableCharacteristic initWithType:properties:value:permissions:] when a
762            // cached value is combined with anything other than read-only properties and
763            // permissions. That Objective-C exception crosses the FFI boundary and aborts the
764            // whole process ("fatal runtime error: Rust cannot catch foreign exceptions"), so it
765            // cannot be caught or reported - it must be avoided. ble-peripheral-rust documents
766            // the same constraint in its CoreBluetooth backend (peripheral_manager.rs:205).
767            //
768            // Dropping the cached value costs nothing: reads are served through the
769            // bluetooth_read_request -> respond_to_read event path, never from this cache.
770            #[cfg(target_vendor = "apple")]
771            if cached_value.is_some() {
772                let read_only = properties
773                    .iter()
774                    .all(|p| matches!(p, CharacteristicProperty::Read))
775                    && permissions
776                        .iter()
777                        .all(|p| matches!(p, AttributePermission::Readable));
778                if !read_only {
779                    warn!(
780                        "CoreBluetooth: ignoring initial_value on characteristic {} - a cached \
781                         value is only legal on a read-only characteristic. Reads are answered \
782                         via bluetooth_read_request instead.",
783                        char_uuid_str
784                    );
785                    Log::new(Some(&status_tx)).warn(format!(
786                        "BLE {}: initial_value ignored (macOS allows a cached value only \
787                         on read-only characteristics); reads are answered by the LLM",
788                        char_uuid_str
789                    ));
790                    cached_value = None;
791                }
792            }
793
794            characteristics.push(Characteristic {
795                uuid: char_uuid,
796                properties,
797                permissions,
798                value: cached_value,
799                descriptors: Vec::new(), // TODO: support descriptors if needed
800            });
801            char_uuids.push(char_uuid_str.to_string());
802        }
803
804        // Capture the shared radio and this server's event channel, then drop the ServerData
805        // guard: the radio call below awaits I/O and must not be made holding that lock (and
806        // never with the two locks nested, to keep a consistent order).
807        let hub = server_data_guard.hub.clone();
808        let event_tx = server_data_guard.event_tx.clone();
809        drop(server_data_guard);
810
811        // Point this characteristic's future read/write/subscribe events at this server.
812        if let (Some(hub), Some(tx)) = (hub.as_ref(), event_tx.as_ref()) {
813            for cu in &char_uuids {
814                hub.router.register_characteristic(cu, tx.clone());
815            }
816        }
817
818        let service = Service {
819            uuid,
820            primary,
821            characteristics,
822        };
823
824        if let Some(hub) = hub.as_ref() {
825            hub.peripheral
826                .lock()
827                .await
828                .add_service(&service)
829                .await
830                .context("Failed to add service to peripheral")?;
831
832            Log::new(Some(&status_tx)).info(format!(
833                "Added BLE service {} with {} characteristics",
834                uuid_str,
835                chars_json.len()
836            ));
837        }
838
839        Ok(())
840    }
841
842    /// Start BLE advertising
843    #[cfg(all(unix, feature = "bluetooth"))]
844    async fn execute_start_advertising(
845        server_data: &Arc<Mutex<ServerData>>,
846        device_name: &str,
847        action: serde_json::Value,
848        status_tx: &mpsc::UnboundedSender<String>,
849    ) -> Result<()> {
850        // The advertised LocalName is model-supplied and goes straight onto the air, into the
851        // status channel, and into whatever scanner UI displays it. A control character there
852        // is a forged line in someone else's log or a forged screen in someone else's
853        // terminal, so it is stripped here rather than trusted. 29 characters is what fits an
854        // AD structure in a 31-octet legacy advertisement once its length and type bytes are
855        // accounted for; a longer name would be refused by the radio, not truncated politely.
856        let name = crate::utils::sanitize::token(
857            action["device_name"].as_str().unwrap_or(device_name),
858            MAX_LOCAL_NAME_CHARS,
859        );
860        let name = name.as_str();
861
862        // Parse service UUIDs if provided
863        let service_uuids: Vec<Uuid> = action["service_uuids"]
864            .as_array()
865            .map(|arr| {
866                arr.iter()
867                    .filter_map(|v| v.as_str())
868                    .filter_map(|s| parse_ble_uuid(s).ok())
869                    .collect()
870            })
871            .unwrap_or_else(Vec::new);
872
873        let hub = server_data.lock().await.hub.clone();
874        if let Some(hub) = hub.as_ref() {
875            hub.peripheral
876                .lock()
877                .await
878                .start_advertising(name, &service_uuids)
879                .await
880                .context("Failed to start advertising")?;
881
882            console_info!(
883                status_tx,
884                "Started BLE advertising as '{}' with {} service(s)",
885                name,
886                service_uuids.len()
887            );
888        } else {
889            // No radio attached (the radio-free event loop used by tests). Nothing is being
890            // advertised, and previously nothing said so — the action reported success and the
891            // device was simply undiscoverable.
892            Log::new(Some(status_tx)).warn(format!(
893                "start_advertising as '{name}' had no BLE radio attached; the device is NOT \
894                 discoverable"
895            ));
896        }
897
898        Ok(())
899    }
900
901    /// Stop BLE advertising
902    #[cfg(all(unix, feature = "bluetooth"))]
903    async fn execute_stop_advertising(
904        server_data: &Arc<Mutex<ServerData>>,
905        status_tx: &mpsc::UnboundedSender<String>,
906    ) -> Result<()> {
907        let hub = server_data.lock().await.hub.clone();
908        if let Some(hub) = hub.as_ref() {
909            hub.peripheral
910                .lock()
911                .await
912                .stop_advertising()
913                .await
914                .context("Failed to stop advertising")?;
915
916            Log::new(Some(&status_tx)).info("Stopped BLE advertising");
917        }
918
919        Ok(())
920    }
921
922    /// Send notification to subscribed clients
923    #[cfg(all(unix, feature = "bluetooth"))]
924    async fn execute_send_notification(
925        server_data: &Arc<Mutex<ServerData>>,
926        action: serde_json::Value,
927        status_tx: &mpsc::UnboundedSender<String>,
928    ) -> Result<()> {
929        let char_uuid_str = action["characteristic_uuid"]
930            .as_str()
931            .context("send_notification requires 'characteristic_uuid' field")?;
932        let value_str = action["value"]
933            .as_str()
934            .context("send_notification requires 'value' field (hex-encoded)")?;
935
936        let char_uuid = parse_ble_uuid(char_uuid_str).context("Invalid characteristic UUID")?;
937        let value_str = value_str.trim_start_matches("0x");
938        let value = hex::decode(value_str).context("Value must be hex-encoded")?;
939
940        // Update stored value and capture the shared radio, then drop the guard before I/O.
941        let hub = {
942            let mut server_data_guard = server_data.lock().await;
943            if let Some(char_data) = server_data_guard
944                .characteristics
945                .get_mut(&characteristic_key(char_uuid_str))
946            {
947                char_data.current_value = value.clone();
948            }
949            server_data_guard.hub.clone()
950        };
951
952        if let Some(hub) = hub.as_ref() {
953            hub.peripheral
954                .lock()
955                .await
956                .update_characteristic(char_uuid, value.clone())
957                .await
958                .context("Failed to send notification")?;
959
960            Log::new(Some(&status_tx)).debug(format!(
961                "Sent BLE notification on {} ({} bytes)",
962                char_uuid_str,
963                value.len()
964            ));
965        }
966
967        Ok(())
968    }
969
970    /// Run the GATT event loop over an externally supplied event stream, with no radio.
971    ///
972    /// This is the same [`Self::event_loop`] `spawn_with_llm_actions` runs; the only difference
973    /// is that the `ServerData` it builds holds no `Peripheral`, so the actions that would
974    /// transmit are no-ops while the request/response paths are byte-for-byte the ones a real
975    /// central drives.
976    ///
977    /// It exists so `tests/` can exercise the ATT error paths. The project forbids
978    /// `#[cfg(test)]` modules in `src/`, and the alternative — a Bluetooth adapter plus a second
979    /// radio acting as a central — is why every test in `tests/server/bluetooth_ble/e2e_test.rs`
980    /// is `#[ignore]`d. `parse_ble_uuid` above is `pub` for the same reason.
981    #[cfg(all(unix, feature = "bluetooth"))]
982    pub async fn run_event_loop_without_radio(
983        event_rx: mpsc::Receiver<PeripheralEvent>,
984        server_id: crate::state::ServerId,
985        llm_client: OllamaClient,
986        app_state: Arc<AppState>,
987        status_tx: mpsc::UnboundedSender<String>,
988        seed_characteristics: Vec<(String, Vec<u8>)>,
989    ) {
990        // `seed_characteristics` gives a test a GATT table that already exists, as `(uuid,
991        // stored value)` pairs.
992        //
993        // Without it there is no legitimate way to reach the stored-value read path here: the
994        // table is built by `add_service`, which is declared on `bluetooth_ble_started` and on
995        // no other event, and this radio-free loop never raises that event. Seeding it through
996        // the *read* response instead — which is what the tests used to do — is a mock a real
997        // model could never produce, because `call_llm` offers only the firing event's actions,
998        // and `tests/helpers/mock_action_names.rs` rejects it for exactly that reason.
999        let characteristics = seed_characteristics
1000            .into_iter()
1001            .map(|(uuid, current_value)| {
1002                (
1003                    // Keyed through `characteristic_key`, exactly as the production
1004                    // `add_service` path does at its insert site — so a test may seed in any
1005                    // spelling a model could use and gets the same table a real `add_service`
1006                    // would build. That symmetry is the point: seeding pre-canonicalised was
1007                    // what hid the key mismatch `characteristic_key` documents, because the
1008                    // tests then only ever exercised the one spelling that happened to work.
1009                    characteristic_key(&uuid),
1010                    CharacteristicData {
1011                        uuid: uuid.clone(),
1012                        properties: vec!["read".to_string(), "write".to_string()],
1013                        permissions: vec!["readable".to_string(), "writable".to_string()],
1014                        current_value,
1015                    },
1016                )
1017            })
1018            .collect();
1019
1020        let server_data = Arc::new(Mutex::new(ServerData {
1021            hub: None,
1022            event_tx: None,
1023            memory: String::new(),
1024            characteristics,
1025        }));
1026
1027        Self::event_loop(
1028            event_rx,
1029            server_id,
1030            llm_client,
1031            app_state,
1032            status_tx,
1033            server_data,
1034            Arc::new(BluetoothBleProtocol::new()),
1035        )
1036        .await;
1037    }
1038
1039    /// Main event processing loop
1040    #[cfg(all(unix, feature = "bluetooth"))]
1041    async fn event_loop(
1042        mut event_rx: mpsc::Receiver<PeripheralEvent>,
1043        server_id: crate::state::ServerId,
1044        llm_client: OllamaClient,
1045        app_state: Arc<AppState>,
1046        status_tx: mpsc::UnboundedSender<String>,
1047        server_data: Arc<Mutex<ServerData>>,
1048        protocol: Arc<BluetoothBleProtocol>,
1049    ) {
1050        while let Some(event) = event_rx.recv().await {
1051            match event {
1052                PeripheralEvent::StateUpdate { is_powered, .. } => {
1053                    Log::new(Some(&status_tx))
1054                        .info(format!("Bluetooth state update: powered = {}", is_powered));
1055
1056                    // Create event for LLM
1057                    let llm_event = Event::new(
1058                        &BLUETOOTH_STATE_CHANGED_EVENT,
1059                        serde_json::json!({
1060                            "state": if is_powered { "powered_on" } else { "powered_off" },
1061                        }),
1062                    );
1063
1064                    // Call LLM with state change. There is no peer waiting on an adapter
1065                    // state change, so silence towards the radio is the only possible
1066                    // behaviour - but the failure must still be visible. This used to be
1067                    // `let _ = ...`, which discarded the error with no log on either channel.
1068                    if let Err(e) = Self::call_llm_for_event(
1069                        &server_id,
1070                        &llm_client,
1071                        &app_state,
1072                        &status_tx,
1073                        &server_data,
1074                        &protocol,
1075                        llm_event,
1076                    )
1077                    .await
1078                    {
1079                        console_error!(
1080                            status_tx,
1081                            "BLE adapter state change (powered = {}) was not handled: the \
1082                             handler failed ({}). Nothing was reconfigured.",
1083                            is_powered,
1084                            e
1085                        );
1086                    }
1087                }
1088                PeripheralEvent::ReadRequest {
1089                    request,
1090                    offset,
1091                    responder,
1092                } => {
1093                    let char_uuid_str = request.characteristic.to_string();
1094
1095                    Log::new(Some(&status_tx)).debug(format!(
1096                        "BLE read request on {} (offset: {})",
1097                        char_uuid_str, offset
1098                    ));
1099
1100                    // Create read request event
1101                    let llm_event = Event::new(
1102                        &BLUETOOTH_READ_REQUEST_EVENT,
1103                        serde_json::json!({
1104                            "characteristic_uuid": char_uuid_str,
1105                            "offset": offset,
1106                        }),
1107                    );
1108
1109                    // Call LLM
1110                    match Self::call_llm_for_event(
1111                        &server_id,
1112                        &llm_client,
1113                        &app_state,
1114                        &status_tx,
1115                        &server_data,
1116                        &protocol,
1117                        llm_event,
1118                    )
1119                    .await
1120                    {
1121                        Ok(llm_result) => {
1122                            // Three outcomes, kept apart on purpose. ATT carries only
1123                            // "here is a value" or an error code, so what the wire
1124                            // cannot distinguish the log must: every branch below names
1125                            // its `decision=`.
1126                            //
1127                            // The stored-value fallback is deliberate and is *not* an
1128                            // invented answer: the characteristic's current value is
1129                            // this server's own state, set by `add_service`'s
1130                            // `initial_value`, by `send_notification`, or by the last
1131                            // write. Serving it when the handler declined to name a
1132                            // value is ordinary GATT behaviour. What is not allowed is
1133                            // conjuring a value where there is none, or substituting the
1134                            // stored value for an answer we failed to decode.
1135                            //
1136                            // The lookup is `.await`ed, not
1137                            // `futures::executor::block_on(server_data.lock())` inside a
1138                            // synchronous closure as it once was — a blocking lock on a
1139                            // tokio worker thread, which can panic ("Cannot block the
1140                            // current thread from within a runtime") into the
1141                            // `tokio::spawn` that swallows it, leaving the server
1142                            // looking healthy while the read died.
1143                            let reply = match read_decision(&llm_result.raw_actions) {
1144                                ReadDecision::Value(value) => {
1145                                    Log::new(Some(&status_tx)).debug(format!(
1146                                        "BLE read of {} answered by handler \
1147                                                 (decision=model_value, {} bytes)",
1148                                        char_uuid_str,
1149                                        value.len()
1150                                    ));
1151                                    ReadRequestResponse {
1152                                        value,
1153                                        response: RequestResponse::Success,
1154                                    }
1155                                }
1156                                ReadDecision::UseStored => {
1157                                    let stored = {
1158                                        let guard = server_data.lock().await;
1159                                        guard
1160                                            .characteristics
1161                                            .get(&characteristic_key(&char_uuid_str))
1162                                            .map(|c| c.current_value.clone())
1163                                    };
1164                                    match stored {
1165                                        Some(value) => {
1166                                            Log::new(Some(&status_tx)).debug(format!(
1167                                                "BLE read of {} not answered by the \
1168                                                         handler (decision=model_silent); \
1169                                                         serving the stored value ({} bytes)",
1170                                                char_uuid_str,
1171                                                value.len()
1172                                            ));
1173                                            ReadRequestResponse {
1174                                                value,
1175                                                response: RequestResponse::Success,
1176                                            }
1177                                        }
1178                                        None => {
1179                                            // Nothing was said and nothing is stored:
1180                                            // an empty Success here would assert that
1181                                            // this characteristic holds zero bytes,
1182                                            // which nothing is in a position to claim.
1183                                            console_error!(
1184                                                status_tx,
1185                                                "BLE read of {} could not be answered \
1186                                                         (decision=fail_closed_model_silent_no_\
1187                                                         value): the handler named no value and \
1188                                                         this characteristic has none stored. \
1189                                                         Replying ATT Unlikely Error (0x0E).",
1190                                                char_uuid_str
1191                                            );
1192                                            ReadRequestResponse {
1193                                                value: Vec::new(),
1194                                                response: RequestResponse::UnlikelyError,
1195                                            }
1196                                        }
1197                                    }
1198                                }
1199                                ReadDecision::Unusable(reason) => {
1200                                    console_error!(
1201                                        status_tx,
1202                                        "BLE read of {} could not be answered \
1203                                                 (decision=fail_closed_bad_value): {}. Replying \
1204                                                 ATT Unlikely Error (0x0E); the stored value is \
1205                                                 NOT substituted for an answer that failed to \
1206                                                 decode.",
1207                                        char_uuid_str,
1208                                        reason
1209                                    );
1210                                    ReadRequestResponse {
1211                                        value: Vec::new(),
1212                                        response: RequestResponse::UnlikelyError,
1213                                    }
1214                                }
1215                            };
1216
1217                            let _ = responder.send(reply);
1218                        }
1219                        Err(e) => {
1220                            // Fail closed, and say so on both channels. ATT has no
1221                            // "try again later"; `UnlikelyError` (0x0E) is the generic
1222                            // "the server could not do it" the central will surface as
1223                            // a failed read. This is a *backend* failure and stays
1224                            // distinct from the Ok branch's decisions above: no handler
1225                            // ran, so not even the stored value is served — it would be
1226                            // a claim about the characteristic that nothing here is in
1227                            // a position to make.
1228                            console_error!(
1229                                status_tx,
1230                                "BLE read of {} could not be answered \
1231                                         (decision=fail_closed_llm_error): the handler failed \
1232                                         ({}). Replying ATT Unlikely Error (0x0E); no \
1233                                         characteristic value is being invented.",
1234                                char_uuid_str,
1235                                e
1236                            );
1237                            let _ = responder.send(ReadRequestResponse {
1238                                value: Vec::new(),
1239                                response: RequestResponse::UnlikelyError,
1240                            });
1241                        }
1242                    }
1243                }
1244                PeripheralEvent::WriteRequest {
1245                    request,
1246                    value,
1247                    offset,
1248                    responder,
1249                } => {
1250                    let char_uuid_str = request.characteristic.to_string();
1251                    let value_hex = hex::encode(&value);
1252
1253                    Log::new(Some(&status_tx)).debug(format!(
1254                        "BLE write request on {} ({} bytes)",
1255                        char_uuid_str,
1256                        value.len()
1257                    ));
1258                    console_trace!(status_tx, "BLE write data (hex): {}", value_hex);
1259
1260                    // Update stored value
1261                    {
1262                        let mut guard = server_data.lock().await;
1263                        if let Some(char_data) = guard
1264                            .characteristics
1265                            .get_mut(&characteristic_key(&char_uuid_str))
1266                        {
1267                            char_data.current_value = value.clone();
1268                        }
1269                    }
1270
1271                    // Create write request event
1272                    let llm_event = Event::new(
1273                        &BLUETOOTH_WRITE_REQUEST_EVENT,
1274                        serde_json::json!({
1275                            "characteristic_uuid": char_uuid_str,
1276                            "value": value_hex,
1277                            "offset": offset,
1278                        }),
1279                    );
1280
1281                    // Call LLM
1282                    match Self::call_llm_for_event(
1283                        &server_id,
1284                        &llm_client,
1285                        &app_state,
1286                        &status_tx,
1287                        &server_data,
1288                        &protocol,
1289                        llm_event,
1290                    )
1291                    .await
1292                    {
1293                        Ok(llm_result) => {
1294                            // `respond_to_write` declares a `status` of 'success' or
1295                            // 'error', and nothing read it: every answered write was
1296                            // acknowledged Success, so a model rejecting a value -
1297                            // out of range, wrong length, not writable right now -
1298                            // was told the peer it had been accepted. The ATT Write
1299                            // Response is the only signal the central gets.
1300                            let response = write_response_status(&llm_result.raw_actions);
1301                            if matches!(response, RequestResponse::UnlikelyError) {
1302                                Log::new(Some(&status_tx)).info(format!(
1303                                    "BLE write to {} rejected by handler \
1304                                             (decision=model_reject); replying ATT Unlikely \
1305                                             Error (0x0E)",
1306                                    char_uuid_str
1307                                ));
1308                            } else if !llm_result.raw_actions.iter().any(|a| {
1309                                matches!(
1310                                    a.get("type").and_then(|v| v.as_str()),
1311                                    Some("respond_to_write") | Some("send_write_response")
1312                                )
1313                            }) {
1314                                // The write itself already took effect above — the
1315                                // stored value was updated before the handler ran — so
1316                                // acknowledging it is accurate rather than permissive.
1317                                // But silence and an explicit success are the same
1318                                // Write Response on the wire, so the log has to hold
1319                                // them apart from each other and from `model_reject`.
1320                                Log::new(Some(&status_tx)).debug(format!(
1321                                    "BLE write to {} not answered by the handler \
1322                                             (decision=model_silent); the value was stored, so \
1323                                             replying ATT Success",
1324                                    char_uuid_str
1325                                ));
1326                            }
1327                            let _ = responder.send(WriteRequestResponse { response });
1328                        }
1329                        Err(e) => {
1330                            // Fail closed: the central must be told the write did not
1331                            // take effect. An ATT Write Response is an acknowledgement,
1332                            // so answering Success here would tell the peer its value
1333                            // was accepted by a handler that never ran.
1334                            console_error!(
1335                                status_tx,
1336                                "BLE write to {} could not be answered: the handler \
1337                                         failed ({}). Replying ATT Unlikely Error (0x0E); the \
1338                                         write is NOT acknowledged.",
1339                                char_uuid_str,
1340                                e
1341                            );
1342                            let _ = responder.send(WriteRequestResponse {
1343                                response: RequestResponse::UnlikelyError,
1344                            });
1345                        }
1346                    }
1347                }
1348                PeripheralEvent::CharacteristicSubscriptionUpdate {
1349                    request,
1350                    subscribed,
1351                } => {
1352                    let char_uuid_str = request.characteristic.to_string();
1353                    if subscribed {
1354                        console_info!(
1355                            status_tx,
1356                            "Client subscribed to notifications on {}",
1357                            char_uuid_str
1358                        );
1359                    } else {
1360                        console_info!(
1361                            status_tx,
1362                            "Client unsubscribed from notifications on {}",
1363                            char_uuid_str
1364                        );
1365                    }
1366
1367                    let llm_event = Event::new(
1368                        &BLUETOOTH_SUBSCRIBE_EVENT,
1369                        serde_json::json!({
1370                            "characteristic_uuid": char_uuid_str,
1371                            "subscribed": subscribed,
1372                        }),
1373                    );
1374
1375                    // A CCCD subscription update is reported after the fact - the stack has
1376                    // already acknowledged the descriptor write and there is no responder here,
1377                    // so there is nothing to answer. What there is to do is say the
1378                    // subscription will not be served, because the notifications the central is
1379                    // now waiting for were never set up. This used to be `let _ = ...`.
1380                    if let Err(e) = Self::call_llm_for_event(
1381                        &server_id,
1382                        &llm_client,
1383                        &app_state,
1384                        &status_tx,
1385                        &server_data,
1386                        &protocol,
1387                        llm_event,
1388                    )
1389                    .await
1390                    {
1391                        console_error!(
1392                            status_tx,
1393                            "BLE subscription change on {} (subscribed = {}) was not handled: \
1394                             the handler failed ({}). No notifications will be sent for it.",
1395                            char_uuid_str,
1396                            subscribed,
1397                            e
1398                        );
1399                    }
1400                }
1401            }
1402        }
1403    }
1404
1405    /// Call LLM with an event and execute resulting actions
1406    #[cfg(all(unix, feature = "bluetooth"))]
1407    async fn call_llm_for_event(
1408        server_id: &crate::state::ServerId,
1409        llm_client: &OllamaClient,
1410        app_state: &Arc<AppState>,
1411        status_tx: &mpsc::UnboundedSender<String>,
1412        server_data: &Arc<Mutex<ServerData>>,
1413        protocol: &Arc<BluetoothBleProtocol>,
1414        event: Event,
1415    ) -> Result<crate::llm::actions::executor::ExecutionResult> {
1416        let _memory = server_data.lock().await.memory.clone();
1417
1418        let llm_result = call_llm(
1419            llm_client,
1420            app_state,
1421            *server_id,
1422            None, // No connection_id for server-level events
1423            &event,
1424            protocol.as_ref(),
1425        )
1426        .await?;
1427
1428        // Execute returned actions (except read/write responses which are handled inline)
1429        for action in &llm_result.raw_actions {
1430            let action_type = action.get("type").and_then(|v| v.as_str()).unwrap_or("");
1431            match action_type {
1432                "respond_to_read"
1433                | "send_read_response"
1434                | "respond_to_write"
1435                | "send_write_response" => {
1436                    // These are handled inline in the event match arms
1437                    continue;
1438                }
1439                _ => {
1440                    if let Err(e) =
1441                        Self::execute_action(server_data, "NetGet-BLE", action.clone(), status_tx)
1442                            .await
1443                    {
1444                        error!("Failed to execute action: {}", e);
1445                    }
1446                }
1447            }
1448        }
1449
1450        Ok(llm_result)
1451    }
1452}
1453
1454#[cfg(any(all(unix, not(feature = "bluetooth")), not(unix)))]
1455impl BluetoothBle {
1456    pub async fn spawn_with_llm_actions(
1457        _device_name: String,
1458        _llm_client: OllamaClient,
1459        _app_state: Arc<AppState>,
1460        _status_tx: mpsc::UnboundedSender<String>,
1461        _server_id: crate::state::ServerId,
1462        _instruction: String,
1463    ) -> Result<std::net::SocketAddr> {
1464        anyhow::bail!(
1465            "Bluetooth server support not enabled - compile with --features bluetooth-ble"
1466        )
1467    }
1468}
1469
1470/// Decode a characteristic's `initial_value` from `add_service`.
1471///
1472/// Absent is `Vec::new()` — a characteristic may legitimately start empty. Present but not
1473/// hex is an **error**, not zero bytes: `unwrap_or_default()` here used to turn a malformed
1474/// `initial_value` into zero bytes stored for that characteristic, and the read path then
1475/// served exactly those zero bytes under `RequestResponse::Success` on `decision=model_silent`
1476/// — telling the central "this characteristic holds nothing" on the strength of a value nobody
1477/// could read. That is the defect `ReadDecision::Unusable` exists to prevent one layer up: an
1478/// answer that cannot be decoded is not an answer.
1479///
1480/// Extracted so the property is testable. `add_service` is declared only on
1481/// `bluetooth_ble_started`, which the radio-free test loop never raises, so there is no way to
1482/// reach this through `run_event_loop_without_radio` — and reaching it by returning
1483/// `add_service` from a *read* response is a mock no real model could produce.
1484pub fn parse_initial_value(char_uuid: &str, value: &serde_json::Value) -> Result<Vec<u8>> {
1485    let Some(val_str) = value.as_str() else {
1486        return Ok(Vec::new());
1487    };
1488    hex::decode(val_str.trim_start_matches("0x")).with_context(|| {
1489        format!(
1490            "characteristic {char_uuid}: initial_value {val_str:?} is not hex-encoded bytes \
1491             (expected something like \"0048\" or \"0x0048\")"
1492        )
1493    })
1494}