Skip to main content

netget/server/bluetooth_ble/
actions.rs

1//! Bluetooth Low Energy (BLE) GATT server protocol actions
2//!
3//! Cross-platform BLE peripheral using ble-peripheral-rust
4
5use 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
16/// Bluetooth server started event
17pub 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    // The GATT server is empty at startup, so this event is answered by building it out:
34    // add_service for each service, then start_advertising to become discoverable.
35    // `call_llm` builds the model's tool list from the event type rather than from
36    // get_sync_actions(), so before this the model was offered none of the BLE actions.
37    .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
60/// Bluetooth adapter state changed event
61pub 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    // Powering on is answered by advertising, powering off by standing down.
70    .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
84/// Read request event
85pub 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    // A read is answered with the characteristic's value and nothing else - the server sends
95    // whatever respond_to_read carries straight back to the client on this request's responder.
96    .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
119/// Write request event
120pub 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    // Acknowledge the write; send_notification lets a write also push the new value out to
129    // anyone subscribed to the characteristic.
130    .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
165/// Subscribe/unsubscribe to notifications event
166pub 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    // A fresh subscriber is answered with an initial send_notification; an unsubscribe is
177    // answered with no action at all.
178    .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
200/// Bluetooth server protocol handler
201pub struct BluetoothBleProtocol;
202
203impl BluetoothBleProtocol {
204    pub fn new() -> Self {
205        Self
206    }
207}
208
209// Implement Protocol trait (common functionality)
210impl 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        // Deterministic: on startup, register a heart-rate service and begin
310        // advertising, no LLM call.
311        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            // LLM mode: LLM handles BLE GATT server
327            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            // Script mode: Code-based BLE handling
337            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            // Static mode: Fixed BLE action
354            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
395// Implement Server trait (server-specific functionality)
396impl 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        // Actions are executed directly in the server event loop
428        // This is just for validation
429        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
447// Action definitions
448
449fn 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            // Read by execute_start_advertising and passed to the radio, but never declared,
507            // so the model could not advertise which services this device offers - the field
508            // centrals filter their scans on.
509            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}