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}