1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter, ParameterDefinition,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::{Context, Result};
13use serde_json::json;
14use std::sync::LazyLock;
15
16pub static BLUETOOTH_BLE_STARTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
18 EventType::new(
19 "bluetooth_ble_started",
20 "Bluetooth Low Energy GATT server started and ready for configuration",
21 json!({
22 "type": "add_service",
23 "uuid": "180D",
24 "primary": true,
25 "characteristics": [{
26 "uuid": "2A37",
27 "properties": ["read", "notify"],
28 "permissions": ["readable"],
29 "initial_value": "0048"
30 }]
31 }),
32 )
33 .with_actions(vec![add_service_action(), start_advertising_action()])
38 .with_parameters(vec![
39 Parameter {
40 name: "device_name".to_string(),
41 type_hint: "string".to_string(),
42 description: "Name of the BLE device for advertising".to_string(),
43 required: true,
44 },
45 Parameter {
46 name: "instruction".to_string(),
47 type_hint: "string".to_string(),
48 description: "User instruction for server behavior".to_string(),
49 required: true,
50 },
51 ])
52 .with_log_template(
53 LogTemplate::new()
54 .with_info("BLE GATT server started: {device_name}")
55 .with_debug("BLE GATT server started: device={device_name}")
56 .with_trace("BLE started: {json_pretty(.)}"),
57 )
58});
59
60pub static BLUETOOTH_STATE_CHANGED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
62 EventType::new(
63 "bluetooth_state_changed",
64 "Bluetooth adapter state changed (powered on/off, advertising started/stopped, etc.)",
65 json!({
66 "type": "start_advertising"
67 }),
68 )
69 .with_actions(vec![start_advertising_action(), stop_advertising_action()])
71 .with_parameters(vec![Parameter {
72 name: "state".to_string(),
73 type_hint: "string".to_string(),
74 description: "Current state description".to_string(),
75 required: true,
76 }])
77 .with_log_template(
78 LogTemplate::new()
79 .with_info("BLE state: {state}")
80 .with_debug("BLE adapter state changed: {state}"),
81 )
82});
83
84pub static BLUETOOTH_READ_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
86 EventType::new(
87 "bluetooth_read_request",
88 "Client is reading from a GATT characteristic - respond with data",
89 json!({
90 "type": "respond_to_read",
91 "value": "0048"
92 }),
93 )
94 .with_actions(vec![respond_to_read_action()])
97 .with_parameters(vec![
98 Parameter {
99 name: "characteristic_uuid".to_string(),
100 type_hint: "string".to_string(),
101 description: "UUID of the characteristic being read".to_string(),
102 required: true,
103 },
104 Parameter {
105 name: "offset".to_string(),
106 type_hint: "number".to_string(),
107 description: "Byte offset for long reads (usually 0)".to_string(),
108 required: true,
109 },
110 ])
111 .with_log_template(
112 LogTemplate::new()
113 .with_info("BLE read: {characteristic_uuid}")
114 .with_debug("BLE read request: char={characteristic_uuid}, offset={offset}")
115 .with_trace("BLE read request: {json_pretty(.)}"),
116 )
117});
118
119pub static BLUETOOTH_WRITE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
121 EventType::new(
122 "bluetooth_write_request",
123 "Client wrote data to a GATT characteristic",
124 json!({
125 "type": "respond_to_write"
126 }),
127 )
128 .with_actions(vec![respond_to_write_action(), send_notification_action()])
131 .with_parameters(vec![
132 Parameter {
133 name: "characteristic_uuid".to_string(),
134 type_hint: "string".to_string(),
135 description: "UUID of the characteristic written to".to_string(),
136 required: true,
137 },
138 Parameter {
139 name: "value".to_string(),
140 type_hint: "string".to_string(),
141 description: "Hex-encoded data written by client".to_string(),
142 required: true,
143 },
144 Parameter {
145 name: "offset".to_string(),
146 type_hint: "number".to_string(),
147 description: "Byte offset for long writes (usually 0)".to_string(),
148 required: true,
149 },
150 Parameter {
151 name: "with_response".to_string(),
152 type_hint: "boolean".to_string(),
153 description: "Whether client expects a response".to_string(),
154 required: true,
155 },
156 ])
157 .with_log_template(
158 LogTemplate::new()
159 .with_info("BLE write: {characteristic_uuid}")
160 .with_debug("BLE write request: char={characteristic_uuid}, value={value}")
161 .with_trace("BLE write request: {json_pretty(.)}"),
162 )
163});
164
165pub static BLUETOOTH_SUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
167 EventType::new(
168 "bluetooth_subscribe",
169 "Client subscribed or unsubscribed from characteristic notifications",
170 json!({
171 "type": "send_notification",
172 "characteristic_uuid": "2A37",
173 "value": "0048"
174 }),
175 )
176 .with_actions(vec![send_notification_action()])
179 .with_parameters(vec![
180 Parameter {
181 name: "characteristic_uuid".to_string(),
182 type_hint: "string".to_string(),
183 description: "UUID of the characteristic".to_string(),
184 required: true,
185 },
186 Parameter {
187 name: "subscribed".to_string(),
188 type_hint: "boolean".to_string(),
189 description: "true if subscribed, false if unsubscribed".to_string(),
190 required: true,
191 },
192 ])
193 .with_log_template(
194 LogTemplate::new()
195 .with_info("BLE {subscribed} notify: {characteristic_uuid}")
196 .with_debug("BLE subscribe: char={characteristic_uuid}, subscribed={subscribed}"),
197 )
198});
199
200pub struct BluetoothBleProtocol;
202
203impl BluetoothBleProtocol {
204 pub fn new() -> Self {
205 Self
206 }
207}
208
209impl Protocol for BluetoothBleProtocol {
211 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
212 vec![ParameterDefinition {
213 name: "device_name".to_string(),
214 type_hint: "string".to_string(),
215 description: "Bluetooth device name for advertising (default: NetGet-BLE)".to_string(),
216 required: false,
217 example: json!("MyDevice"),
218 default: None,
219 }]
220 }
221
222 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
223 vec![
224 add_service_action(),
225 start_advertising_action(),
226 stop_advertising_action(),
227 send_notification_action(),
228 ]
229 }
230
231 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
232 vec![respond_to_read_action(), respond_to_write_action()]
233 }
234
235 fn protocol_name(&self) -> &'static str {
236 "BLUETOOTH_BLE"
237 }
238
239 fn get_event_types(&self) -> Vec<EventType> {
240 vec![
241 BLUETOOTH_BLE_STARTED_EVENT.clone(),
242 BLUETOOTH_STATE_CHANGED_EVENT.clone(),
243 BLUETOOTH_READ_REQUEST_EVENT.clone(),
244 BLUETOOTH_WRITE_REQUEST_EVENT.clone(),
245 BLUETOOTH_SUBSCRIBE_EVENT.clone(),
246 ]
247 }
248
249 fn stack_name(&self) -> &'static str {
250 "BLUETOOTH_BLE"
251 }
252
253 fn keywords(&self) -> Vec<&'static str> {
254 vec!["bluetooth", "ble", "gatt", "peripheral", "bluetooth_ble"]
255 }
256
257 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
258 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
259
260 ProtocolMetadataV2::builder()
261 .state(DevelopmentState::Experimental)
262 .implementation("ble-peripheral-rust (cross-platform: Windows/WinRT, macOS/CoreBluetooth, Linux/BlueZ)")
263 .llm_control("Full GATT server control: services, characteristics, read/write/notify")
264 .e2e_testing(
265 "Split, and the split is the point. The decision logic is covered without a \
266 radio, because `BluetoothBle::run_event_loop_without_radio` runs the real event \
267 loop over an injected event stream: the ATT read/write fail-closed paths \
268 (tests/server/bluetooth_ble/llm_failure_test.rs), the stored-value fallback and \
269 its refusal to substitute an undecodable answer (read_default_value_test.rs), \
270 and characteristic routing across servers sharing the one radio \
271 (shared_peripheral_routing_test.rs). None of those is #[ignore]d. What needs \
272 hardware is everything that transmits — advertising, service registration and a \
273 real central completing a GATT exchange — and those tests \
274 (tests/server/bluetooth_ble/e2e_test.rs) are #[ignore]d because they claim the \
275 machine's single BLE adapter and would deadlock a --test-threads=100 run.",
276 )
277 .notes(
278 "VERIFIED without a radio: that an LLM failure answers ATT Unlikely Error (0x0E) \
279 rather than inventing a characteristic value or acknowledging a write that never \
280 took effect; that an explicit 'error' status on respond_to_write is honoured; \
281 that a respond_to_read whose value will not decode fails closed instead of \
282 silently serving the stored value; and that every one of those outcomes is \
283 distinguishable in the log by its decision= tag, which is the only place ATT can \
284 carry the distinction. NOT VERIFIED: nothing in the automated suite has ever put \
285 a byte on a radio. Advertising, service registration and any real central's \
286 GATT exchange are exercised only by the #[ignore]d tests, run by hand on macOS \
287 (see docs/archive/MACOS_SUPPORT.md). A rating above Experimental would need \
288 those to be neither ignored nor hardware-bound, which the single-adapter \
289 constraint currently prevents.",
290 )
291 .build()
292 }
293
294 fn description(&self) -> &'static str {
295 "Bluetooth Low Energy (BLE) GATT server - act as a Bluetooth peripheral device"
296 }
297
298 fn example_prompt(&self) -> &'static str {
299 "Act as a BLE heart rate monitor. Create Heart Rate Service (0x180D) with Measurement characteristic (0x2A37). Start at 72 BPM, increase by 1 every 2 seconds, send notifications."
300 }
301
302 fn group_name(&self) -> &'static str {
303 "Network"
304 }
305 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
306 use crate::llm::actions::StartupExamples;
307 use serde_json::json;
308
309 let script = r#"import json, sys
312data = json.load(sys.stdin)
313event = data["event"]
314if data["event_type_id"] == "bluetooth_ble_started":
315 actions = [{"type": "add_service", "uuid": "180D", "primary": True,
316 "characteristics": [{"uuid": "2A37",
317 "properties": ["read", "notify"],
318 "permissions": ["readable"],
319 "initial_value": "0048"}]},
320 {"type": "start_advertising", "device_name": "NetGet"}]
321else:
322 actions = []
323print(json.dumps({"actions": actions}))"#;
324
325 StartupExamples::new(
326 json!({
328 "type": "open_server",
329 "port": 0,
330 "base_stack": "bluetooth-ble",
331 "instruction": "Act as a BLE heart rate monitor with Heart Rate Service (0x180D)",
332 "startup_params": {
333 "device_name": "NetGet-HeartRate"
334 }
335 }),
336 json!({
338 "type": "open_server",
339 "port": 0,
340 "base_stack": "bluetooth-ble",
341 "startup_params": {
342 "device_name": "NetGet-BLE"
343 },
344 "event_handlers": [{
345 "event_pattern": "bluetooth_ble_started",
346 "handler": {
347 "type": "script",
348 "language": "python",
349 "code": script
350 }
351 }]
352 }),
353 json!({
355 "type": "open_server",
356 "port": 0,
357 "base_stack": "bluetooth-ble",
358 "startup_params": {
359 "device_name": "NetGet-BLE"
360 },
361 "event_handlers": [
362 {
363 "event_pattern": "bluetooth_ble_started",
364 "handler": {
365 "type": "static",
366 "actions": [{
367 "type": "add_service",
368 "uuid": "180D",
369 "primary": true,
370 "characteristics": [{
371 "uuid": "2A37",
372 "properties": ["read", "notify"],
373 "permissions": ["readable"],
374 "initial_value": "0048"
375 }]
376 }]
377 }
378 },
379 {
380 "event_pattern": "bluetooth_read_request",
381 "handler": {
382 "type": "static",
383 "actions": [{
384 "type": "respond_to_read",
385 "value": "0048"
386 }]
387 }
388 }
389 ]
390 }),
391 )
392 }
393}
394
395impl Server for BluetoothBleProtocol {
397 fn spawn(
398 &self,
399 ctx: crate::protocol::SpawnContext,
400 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
401 {
402 Box::pin(async move {
403 let device_name = ctx
404 .startup_params
405 .as_ref()
406 .map(|p| p.get_optional_string("device_name"))
407 .transpose()?
408 .flatten()
409 .map(|s| s.to_string())
410 .unwrap_or_else(|| "NetGet-BLE".to_string());
411
412 let instruction = "Act as a Bluetooth Low Energy GATT server".to_string();
413
414 crate::server::bluetooth_ble::BluetoothBle::spawn_with_llm_actions(
415 device_name,
416 ctx.llm_client,
417 ctx.state,
418 ctx.status_tx,
419 ctx.server_id,
420 instruction,
421 )
422 .await
423 })
424 }
425
426 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
427 let action_type = action["type"]
430 .as_str()
431 .context("Action must have 'type' field")?;
432
433 match action_type {
434 "add_service" | "start_advertising" | "stop_advertising" | "send_notification"
435 | "respond_to_read" | "respond_to_write" => Ok(ActionResult::Custom {
436 name: action_type.to_string(),
437 data: action,
438 }),
439 _ => Err(anyhow::anyhow!(
440 "Unknown Bluetooth action type: {}",
441 action_type
442 )),
443 }
444 }
445}
446
447fn add_service_action() -> ActionDefinition {
450 ActionDefinition {
451 name: "add_service".to_string(),
452 description: "Add a GATT service with characteristics to the BLE server".to_string(),
453 parameters: vec![
454 Parameter {
455 name: "uuid".to_string(),
456 type_hint: "string".to_string(),
457 description: "Service UUID (standard 16-bit like '180D' or full 128-bit UUID)"
458 .to_string(),
459 required: true,
460 },
461 Parameter {
462 name: "primary".to_string(),
463 type_hint: "boolean".to_string(),
464 description: "Whether this is a primary service (default: true)".to_string(),
465 required: false,
466 },
467 Parameter {
468 name: "characteristics".to_string(),
469 type_hint: "array".to_string(),
470 description: "Array of characteristic definitions".to_string(),
471 required: true,
472 },
473 ],
474 example: json!({
475 "type": "add_service",
476 "uuid": "180D",
477 "primary": true,
478 "characteristics": [{
479 "uuid": "2A37",
480 "properties": ["read", "notify"],
481 "permissions": ["readable"],
482 "initial_value": "0048"
483 }]
484 }),
485 log_template: Some(
486 LogTemplate::new()
487 .with_info("-> BLE add service: {uuid}")
488 .with_debug("BLE add service: uuid={uuid}, chars={characteristics_len}"),
489 ),
490 }
491}
492
493fn start_advertising_action() -> ActionDefinition {
494 ActionDefinition {
495 name: "start_advertising".to_string(),
496 description: "Start BLE advertising to make the device discoverable".to_string(),
497 parameters: vec![
498 Parameter {
499 name: "device_name".to_string(),
500 type_hint: "string".to_string(),
501 description:
502 "Device name to advertise (optional, uses server default if not specified)"
503 .to_string(),
504 required: false,
505 },
506 Parameter {
510 name: "service_uuids".to_string(),
511 type_hint: "array".to_string(),
512 description: "Service UUIDs to advertise, so centrals scanning for a service \
513 can find this device. 16-bit shorthand (\"180D\") or full 128-bit form. \
514 Defaults to advertising the name only."
515 .to_string(),
516 required: false,
517 },
518 ],
519 example: json!({
520 "type": "start_advertising",
521 "device_name": "NetGet-HR",
522 "service_uuids": ["180D"]
523 }),
524 log_template: Some(LogTemplate::new().with_info("-> BLE start advertising")),
525 }
526}
527
528fn stop_advertising_action() -> ActionDefinition {
529 ActionDefinition {
530 name: "stop_advertising".to_string(),
531 description: "Stop BLE advertising".to_string(),
532 parameters: vec![],
533 example: json!({
534 "type": "stop_advertising"
535 }),
536 log_template: Some(LogTemplate::new().with_info("-> BLE stop advertising")),
537 }
538}
539
540fn send_notification_action() -> ActionDefinition {
541 ActionDefinition {
542 name: "send_notification".to_string(),
543 description: "Send a notification to subscribed clients for a characteristic".to_string(),
544 parameters: vec![
545 Parameter {
546 name: "characteristic_uuid".to_string(),
547 type_hint: "string".to_string(),
548 description: "UUID of the characteristic to update".to_string(),
549 required: true,
550 },
551 Parameter {
552 name: "value".to_string(),
553 type_hint: "string".to_string(),
554 description: "Hex-encoded value to send (e.g., '0048' for 72 in decimal)"
555 .to_string(),
556 required: true,
557 },
558 ],
559 example: json!({
560 "type": "send_notification",
561 "characteristic_uuid": "example_characteristic_uuid",
562 "value": "example_value"
563 }),
564 log_template: Some(
565 LogTemplate::new()
566 .with_info("-> BLE notify: {characteristic_uuid}")
567 .with_debug("BLE send notification: char={characteristic_uuid}, value={value}"),
568 ),
569 }
570}
571
572fn respond_to_read_action() -> ActionDefinition {
573 ActionDefinition {
574 name: "respond_to_read".to_string(),
575 description: "Respond to a client's read request with data (use in response to bluetooth_read_request event)".to_string(),
576 parameters: vec![
577 Parameter {
578 name: "value".to_string(),
579 type_hint: "string".to_string(),
580 description: "Hex-encoded value to return to client".to_string(),
581 required: true,
582 },
583 ],
584 example: json!({
585 "type": "respond_to_read",
586 "value": "example_value"
587 }),
588 log_template: Some(
589 LogTemplate::new()
590 .with_info("-> BLE read response")
591 .with_debug("BLE respond to read: value={value}"),
592 ),
593 }
594}
595
596fn respond_to_write_action() -> ActionDefinition {
597 ActionDefinition {
598 name: "respond_to_write".to_string(),
599 description: "Acknowledge a client's write request (use in response to bluetooth_write_request event)".to_string(),
600 parameters: vec![
601 Parameter {
602 name: "status".to_string(),
603 type_hint: "string".to_string(),
604 description: "Response status: 'success' or 'error' (default: success)".to_string(),
605 required: false,
606 },
607 ],
608 example: json!({
609 "type": "respond_to_write"
610 }),
611 log_template: Some(
612 LogTemplate::new()
613 .with_info("-> BLE write response")
614 .with_debug("BLE respond to write: status={status}"),
615 ),
616 }
617}