Skip to main content

netget/server/bluetooth_ble_beacon/
mod.rs

1//! BLE beacon server (iBeacon / Eddystone).
2//!
3//! A beacon is not a GATT server. It accepts no connections, exposes no characteristics, and
4//! answers no reads: it *is* its advertising payload. That is why this protocol no longer wraps
5//! the `bluetooth-ble` base stack — the base can only advertise a device name and a service-UUID
6//! list, which is precisely the one thing a beacon cannot be built out of.
7//!
8//! Instead it owns a [`advertise::BeaconAdvertiser`], which on Linux registers an
9//! `org.bluez.LEAdvertisement1` object with `ManufacturerData` / `ServiceData` on
10//! `org.bluez.LEAdvertisingManager1`, and on every other platform refuses to start.
11//!
12//! # Shape of the server
13//!
14//! There is no accept loop and no event loop, because nothing ever arrives: a legacy beacon
15//! advertisement is one-way. So there is no `JoinHandle` to hand to
16//! `AppState::register_server_task()`. What there *is* is a live-instance handle
17//! ([`BeaconServer`]) registered with `AppState::register_server_handle()`, which is how the
18//! protocol's actions reach the running adapter — and which `AppState::teardown_server` drops
19//! on stop, taking the `bluer` advertisement handle with it and unregistering the advertisement
20//! from `bluetoothd`.
21//!
22//! One event is emitted, `beacon_started`, exactly once, from `spawn`. It is the only event this
23//! protocol declares, because declaring one it never emits would advertise actions to the model
24//! that can never fire.
25
26pub mod actions;
27pub mod advertise;
28pub mod payload;
29
30use anyhow::Result;
31use std::sync::Arc;
32use tokio::sync::{mpsc, Mutex};
33use tracing::info;
34
35use crate::llm::action_helper::call_llm;
36use crate::llm::ollama_client::OllamaClient;
37use crate::protocol::Event;
38use crate::state::app_state::AppState;
39use crate::{console_error, console_info, console_warn};
40use actions::{BluetoothBleBeaconProtocol, BEACON_STARTED_EVENT};
41use advertise::BeaconAdvertiser;
42use payload::BeaconFrame;
43
44/// Live-instance handle for a running beacon server.
45///
46/// Registered with `AppState::register_server_handle()` in `spawn` and looked up by
47/// `BluetoothBleBeaconProtocol::execute_action_with_state`, which is the only way an action can
48/// reach the adapter — the protocol object the registry holds is zero-sized and has no adapter
49/// of its own.
50pub struct BeaconServer {
51    advertiser: Mutex<BeaconAdvertiser>,
52    status_tx: mpsc::UnboundedSender<String>,
53}
54
55impl BeaconServer {
56    /// Put `frame` on air, replacing whatever was there.
57    pub async fn start_beacon(&self, frame: BeaconFrame) -> Result<String> {
58        let description = frame.describe();
59        let mut advertiser = self.advertiser.lock().await;
60        advertiser.start(frame).await?;
61        let adapter = advertiser.adapter_name().to_string();
62        drop(advertiser);
63
64        console_info!(
65            self.status_tx,
66            "Beacon advertising on {}: {}",
67            adapter,
68            description
69        );
70        Ok(description)
71    }
72
73    /// Stop advertising. Idempotent — stopping an idle beacon is not an error.
74    pub async fn stop_beacon(&self) -> Option<String> {
75        let mut advertiser = self.advertiser.lock().await;
76        let previous = advertiser.current().map(BeaconFrame::describe);
77        advertiser.stop().await;
78        drop(advertiser);
79
80        match &previous {
81            Some(what) => console_info!(self.status_tx, "Beacon stopped advertising: {}", what),
82            None => console_info!(self.status_tx, "Beacon was not advertising"),
83        }
84        previous
85    }
86
87    /// What is currently on air, if anything.
88    pub async fn current(&self) -> Option<BeaconFrame> {
89        self.advertiser.lock().await.current().cloned()
90    }
91
92    /// The adapter this server is bound to.
93    pub async fn adapter_name(&self) -> String {
94        self.advertiser.lock().await.adapter_name().to_string()
95    }
96}
97
98/// The single source of the text used when the handler could not configure the beacon.
99///
100/// Kept in one place, and `pub`, for the same reason as
101/// [`advertise::UNSUPPORTED_PLATFORM_MESSAGE`]: it is the only thing a user or a test can
102/// observe about this failure, so it must say what happened *and* what the consequence is —
103/// "nothing is being advertised" — rather than leaving a bare `anyhow` chain to be read as a
104/// transient hiccup. The project forbids `#[cfg(test)]` modules in `src/`, and on macOS and
105/// Windows `spawn` refuses before it ever reaches the model call, so this is the only part of
106/// the fail-closed path a test can reach off Linux.
107///
108/// It carries the `decision=` tag as well as the prose, because a beacon puts nothing on the
109/// wire on any failure path: the log line *is* the whole distinction between a saturated
110/// backend, a broken one, and a handler that deliberately configured no frame.
111pub fn beacon_configuration_failure(
112    device_name: &str,
113    adapter: &str,
114    err: &anyhow::Error,
115) -> String {
116    let (decision, overloaded) = if crate::llm::is_overload_error(err) {
117        (
118            "decision=fail_closed_llm_overloaded",
119            " The LLM backend was saturated rather than broken, so starting the server again may \
120             succeed.",
121        )
122    } else {
123        ("decision=fail_closed_llm_error", "")
124    };
125    format!(
126        "BLE beacon '{device_name}' on adapter {adapter} could not be configured \
127         ({decision}): the handler failed ({err}). NOTHING is being advertised and the server \
128         is not running - a beacon is its advertising payload, so there is no useful default \
129         to fall back to and inventing one would put an unattributable frame on the \
130         air.{overloaded}"
131    )
132}
133
134/// BLE Beacon server
135pub struct BluetoothBleBeacon;
136
137impl BluetoothBleBeacon {
138    /// Start the beacon server.
139    ///
140    /// Fails — rather than reporting `Running` — when the platform cannot set an advertising
141    /// payload, when no adapter is present, or when `bluetoothd` is unreachable. All three are
142    /// detected before `spawn` returns, so `server_startup.rs` records `ServerStatus::Error`
143    /// with the reason instead of a server that is up and broadcasting nothing.
144    pub async fn spawn_with_llm_actions(
145        device_name: String,
146        adapter: Option<String>,
147        llm_client: OllamaClient,
148        app_state: Arc<AppState>,
149        status_tx: mpsc::UnboundedSender<String>,
150        server_id: crate::state::ServerId,
151        instruction: String,
152    ) -> Result<std::net::SocketAddr> {
153        info!("Starting BLE beacon server");
154
155        // Open the adapter first: everything below assumes an advertisement can be registered,
156        // and a failure here is the honest "this platform/host cannot do it" answer.
157        //
158        // Tagged with its own token rather than a `fail_closed_llm_*` one: no model was asked,
159        // and conflating "the radio cannot do this at all" with "the backend was down" would
160        // send someone to restart Ollama over a macOS CoreBluetooth limit.
161        let advertiser = match BeaconAdvertiser::open(device_name.clone(), adapter).await {
162            Ok(advertiser) => advertiser,
163            Err(e) => {
164                console_error!(
165                    status_tx,
166                    "BLE beacon '{}' cannot start (decision=refused_adapter_unavailable): {}. \
167                     NOTHING is being advertised and the server is not running.",
168                    device_name,
169                    e
170                );
171                return Err(e);
172            }
173        };
174        let adapter_name = advertiser.adapter_name().to_string();
175
176        console_info!(
177            status_tx,
178            "BLE beacon ready on adapter {} (device name '{}')",
179            adapter_name,
180            device_name
181        );
182
183        let server = Arc::new(BeaconServer {
184            advertiser: Mutex::new(advertiser),
185            status_tx: status_tx.clone(),
186        });
187
188        // Must be registered *before* the LLM call: the actions that call answers with are
189        // dispatched through `execute_action_with_state`, which looks the handle up by
190        // server_id and would otherwise find nothing.
191        app_state
192            .register_server_handle(server_id, server.clone())
193            .await;
194
195        let protocol = BluetoothBleBeaconProtocol::new();
196        let started_event = Event::new(
197            &BEACON_STARTED_EVENT,
198            serde_json::json!({
199                "device_name": device_name,
200                "adapter": adapter_name,
201                "instruction": instruction,
202            }),
203        );
204
205        // A failure here is fatal to the server, deliberately.
206        //
207        // For a GATT server the model answers *traffic*, so the base `bluetooth-ble` stack
208        // keeps a powered adapter up when this call fails. A beacon has no traffic: the
209        // configuration produced by this one call *is* the entire server. Reporting `Running`
210        // with nothing on air would be the fail-open shape the root CLAUDE.md warns about —
211        // indistinguishable, to anyone reading `list_servers`, from a beacon that is
212        // broadcasting. So the adapter is released and `server_startup` records
213        // `ServerStatus::Error` with the reason.
214        //
215        // `stop_beacon()` first because it is cheap and unconditional: `call_llm` reports a
216        // failure before executing any action, but a partially-configured radio left
217        // transmitting an unattributable frame is exactly the outcome that must not be
218        // possible, and asserting that from here costs one lock.
219        let result = match call_llm(
220            &llm_client,
221            &app_state,
222            server_id,
223            None, // beacons have no connections
224            &started_event,
225            &protocol,
226        )
227        .await
228        {
229            Ok(result) => result,
230            Err(e) => {
231                server.stop_beacon().await;
232                let reason = beacon_configuration_failure(&device_name, &adapter_name, &e);
233                console_error!(status_tx, "{}", reason);
234                return Err(e.context(reason));
235            }
236        };
237
238        // No fallback beacon. If the model (or the configured handler) named no frame, nothing
239        // is broadcast and that is said out loud — inventing a default UUID would put a beacon
240        // on the air that nobody asked for and that no scanner could attribute.
241        //
242        // Which *kind* of nothing it is has to come from the log, because the air is identical
243        // in all three cases. What is on air is the authority — an action that claimed to start
244        // a frame and failed leaves `current()` empty — so the classification is taken from
245        // `failures` and `raw_actions` only once that has been checked.
246        let refused: Vec<String> = result
247            .failures
248            .iter()
249            .map(|f| format!("{}: {}", f.action, f.error))
250            .collect();
251        let asked_to_stop = result
252            .raw_actions
253            .iter()
254            .any(|a| a.get("type").and_then(|v| v.as_str()) == Some("stop_beacon"));
255        let action_count = result.raw_actions.len();
256
257        match server.current().await {
258            Some(frame) => {
259                console_info!(
260                    status_tx,
261                    "BLE beacon '{}' on adapter {} is advertising (decision=model_answer): {}",
262                    device_name,
263                    adapter_name,
264                    frame.describe()
265                );
266            }
267            None if !refused.is_empty() => {
268                console_error!(
269                    status_tx,
270                    "BLE beacon '{}' on adapter {} is idle \
271                     (decision=fail_closed_bad_action): the handler answered, but no action it \
272                     produced could be executed ({}). NOTHING is being advertised.",
273                    device_name,
274                    adapter_name,
275                    refused.join("; ")
276                );
277            }
278            None if asked_to_stop => {
279                console_info!(
280                    status_tx,
281                    "BLE beacon '{}' on adapter {} is idle (decision=model_reject): the handler \
282                     answered with stop_beacon, so nothing is advertised on purpose.",
283                    device_name,
284                    adapter_name
285                );
286            }
287            None => {
288                console_warn!(
289                    status_tx,
290                    "BLE beacon '{}' on adapter {} is idle (decision=model_silent): no \
291                     start_ibeacon / start_eddystone_uid / start_eddystone_url action was \
292                     produced ({} action(s) returned). NOTHING is being advertised; use one of \
293                     those actions to begin broadcasting.",
294                    device_name,
295                    adapter_name,
296                    action_count
297                );
298            }
299        }
300
301        // BLE has no IP address or port; the registry's display layer wants a SocketAddr, so
302        // report the "binds no listening socket" placeholder the same way the bluetooth-ble
303        // base does. Both used to return `127.0.0.1:{5900 + server_id % 100}`, which
304        // `server_startup::is_bound_addr` accepts (it only rejects port 0), so the TUI showed
305        // a loopback endpoint nothing had bound — on VNC's port, no less.
306        Ok(std::net::SocketAddr::from((
307            std::net::Ipv4Addr::UNSPECIFIED,
308            0,
309        )))
310    }
311}