Skip to main content

netget/server/bluetooth_ble_data_stream/
actions.rs

1//! BLE data streaming - custom GATT service pushing real-time sensor/telemetry data
2//!
3//! Profile wrapper over the `bluetooth-ble` base stack.
4//!
5//! The base server (`BluetoothBle::spawn_with_llm_actions`) hardcodes `BluetoothBleProtocol`
6//! when it calls `call_llm`, so the events the model actually sees and the actions it may
7//! answer with are always the base's. Declaring profile-specific actions or events here would
8//! document a vocabulary that no code path can ever emit or execute, so this protocol forwards
9//! the base's set verbatim - the same shape `doh`/`dot` use to forward `DnsProtocol`'s actions.
10//! The profile identity lives in the instruction preamble and in the GATT layout suggested by
11//! the startup examples below.
12
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, ParameterDefinition,
16};
17use crate::protocol::EventType;
18use crate::server::bluetooth_ble::actions::BluetoothBleProtocol;
19use crate::state::app_state::AppState;
20use anyhow::Result;
21use serde_json::json;
22
23/// BLE data streaming - custom GATT service pushing real-time sensor/telemetry data
24pub struct BluetoothBleDataStreamProtocol;
25
26impl BluetoothBleDataStreamProtocol {
27    pub fn new() -> Self {
28        Self
29    }
30
31    /// The base protocol this profile delegates its whole vocabulary to.
32    fn base() -> BluetoothBleProtocol {
33        BluetoothBleProtocol::new()
34    }
35}
36
37impl Default for BluetoothBleDataStreamProtocol {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Protocol for BluetoothBleDataStreamProtocol {
44    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
45        vec![ParameterDefinition {
46            name: "device_name".to_string(),
47            type_hint: "string".to_string(),
48            description: "Bluetooth device name to advertise (default: NetGet-Stream)".to_string(),
49            required: false,
50            example: json!("NetGet-Stream"),
51            default: None,
52        }]
53    }
54
55    /// Delegated: the base stack owns every action this server can execute.
56    fn get_async_actions(&self, state: &AppState) -> Vec<ActionDefinition> {
57        Self::base().get_async_actions(state)
58    }
59
60    /// Delegated: see `get_async_actions`. Returning `vec![]` here while the base emits
61    /// events would leave the model with no way to answer a read or a write.
62    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
63        Self::base().get_sync_actions()
64    }
65
66    /// Delegated: the base's event types are the only ones ever emitted for this server, and
67    /// they carry their own `.with_actions(...)` lists, which is what `call_llm` offers the
68    /// model. An event id declared here but not emitted by the base would silently match an
69    /// `event_handlers` pattern that can never fire.
70    fn get_event_types(&self) -> Vec<EventType> {
71        Self::base().get_event_types()
72    }
73
74    fn protocol_name(&self) -> &'static str {
75        "BLUETOOTH_BLE_DATA_STREAM"
76    }
77
78    fn stack_name(&self) -> &'static str {
79        "BLUETOOTH_BLE_DATA_STREAM"
80    }
81
82    fn keywords(&self) -> Vec<&'static str> {
83        // Deliberately no bare single words: bare "stream" is too generic to own. A keyword is matched with
84        // word boundaries against the whole prompt, so a generic one silently wins
85        // requests meant for another protocol. "bluetooth"/"ble" are dropped too --
86        // they are shared by 18 BLE profiles and identify none of them.
87        vec!["ble data stream", "ble telemetry", "ble sensor"]
88    }
89
90    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
91        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
92
93        ProtocolMetadataV2::builder()
94            .state(DevelopmentState::Experimental)
95            .implementation(
96                "bluetooth-ble base stack (ble-peripheral-rust) plus an instruction preamble for the custom streaming service",
97            )
98            .llm_control(
99                "Base BLE GATT control (add_service, start_advertising, stop_advertising, respond_to_read, respond_to_write, send_notification); the LLM builds the custom streaming service itself.",
100            )
101            .e2e_testing(
102                "Two automated suites, neither of which is evidence for a rating above Experimental. tests/server/bluetooth_ble_data_stream/e2e_test.rs covers the wiring only: open_server reaches this protocol's spawn, the base brings the radio up, and a bluetooth_ble_started event is raised and answered - it builds no service and puts no byte on the wire, and it claims the machine's Bluetooth adapter. gatt_examples_test.rs needs no adapter and checks the startup examples for internal coherence (this profile's UUIDs are custom, so there is no SIG layout to check the bytes against). Proving the profile works still needs a real central (nRF Connect, btleplug) exercising a service this profile built; nothing in the tree does that.",
103            )
104            .notes(
105                "Thin profile wrapper over the bluetooth-ble base stack. It prepends an instruction describing the custom streaming service and otherwise reuses the base entirely: the base hardcodes BluetoothBleProtocol when it calls the LLM, so the action vocabulary, the event types and the executor are the base's. This protocol deliberately declares no actions or events of its own - one that did would be documented to the model but never reachable at runtime.",
106            )
107            .build()
108    }
109
110    fn description(&self) -> &'static str {
111        "BLE data streaming - custom GATT service pushing real-time sensor/telemetry data"
112    }
113
114    fn example_prompt(&self) -> &'static str {
115        "Stream IMU accelerometer samples at 10 Hz over a custom notify characteristic"
116    }
117
118    fn group_name(&self) -> &'static str {
119        "Network"
120    }
121
122    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
123        use crate::llm::actions::StartupExamples;
124
125        // Every event id and action name below is one the base stack really emits and really
126        // executes. UUIDs are written in full 128-bit form because the base parses them with
127        // `Uuid::parse_str`, which rejects the 16-bit shorthand.
128        StartupExamples::new(
129            // LLM mode: the model builds the GATT layout and answers reads itself.
130            json!({
131                "type": "open_server",
132                "port": 0,
133                "base_stack": "bluetooth-ble-data-stream",
134                "instruction": "Stream IMU accelerometer samples at 10 Hz over a custom notify characteristic",
135                "startup_params": {
136                    "device_name": "NetGet-Stream"
137                }
138            }),
139            // Script mode: a read is answered in-process, with no model call.
140            json!({
141                "type": "open_server",
142                "port": 0,
143                "base_stack": "bluetooth-ble-data-stream",
144                "startup_params": {
145                    "device_name": "NetGet-Stream"
146                },
147                "event_handlers": [
148                    {
149                        "event_pattern": "bluetooth_read_request",
150                        "handler": {
151                            "type": "script",
152                            "language": "python",
153                            "code": "import json,sys\ne=json.load(sys.stdin)['event']\nv={'0000f00f-0000-1000-8000-00805f9b34fb':'00'}.get(str(e.get('characteristic_uuid','')).lower())\nprint(json.dumps({'actions':[{'type':'respond_to_read','value':v}] if v else []}))"
154                        }
155                    }
156                ]
157            }),
158            // Static mode: fixed GATT layout and a fixed read response, with no model call.
159            json!({
160                "type": "open_server",
161                "port": 0,
162                "base_stack": "bluetooth-ble-data-stream",
163                "startup_params": {
164                    "device_name": "NetGet-Stream"
165                },
166                "event_handlers": [
167                    {
168                        "event_pattern": "bluetooth_ble_started",
169                        "handler": {
170                            "type": "static",
171                            "actions": [
172                                {
173                                    "type": "add_service",
174                                    "uuid": "0000f00d-0000-1000-8000-00805f9b34fb",
175                                    "primary": true,
176                                    "characteristics": [
177                                        {
178                                            "uuid": "0000f00e-0000-1000-8000-00805f9b34fb",
179                                            "properties": [
180                                                "notify"
181                                            ],
182                                            "permissions": []
183                                        },
184                                        {
185                                            "uuid": "0000f00f-0000-1000-8000-00805f9b34fb",
186                                            "properties": [
187                                                "read",
188                                                "write"
189                                            ],
190                                            "permissions": [
191                                                "readable",
192                                                "writeable"
193                                            ],
194                                            "initial_value": "00"
195                                        }
196                                    ]
197                                },
198                                {
199                                    "type": "start_advertising",
200                                    "device_name": "NetGet-Stream",
201                                    "service_uuids": [
202                                        "0000f00d-0000-1000-8000-00805f9b34fb"
203                                    ]
204                                }
205                            ]
206                        }
207                    },
208                    {
209                        "event_pattern": "bluetooth_read_request",
210                        "handler": {
211                            "type": "static",
212                            "actions": [
213                                {
214                                    "type": "respond_to_read",
215                                    "value": "01"
216                                }
217                            ]
218                        }
219                    }
220                ]
221            }),
222        )
223    }
224}
225
226impl Server for BluetoothBleDataStreamProtocol {
227    fn spawn(
228        &self,
229        ctx: crate::protocol::SpawnContext,
230    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
231    {
232        Box::pin(async move {
233            let device_name = ctx
234                .startup_params
235                .as_ref()
236                .map(|p| p.get_optional_string("device_name"))
237                .transpose()?
238                .flatten()
239                .unwrap_or_else(|| "NetGet-Stream".to_string());
240
241            // The user's own instruction must reach the base stack; the profile preamble is
242            // added there, not substituted for it.
243            let instruction = ctx
244                .state
245                .get_server(ctx.server_id)
246                .await
247                .map(|s| s.instruction)
248                .unwrap_or_default();
249
250            crate::server::bluetooth_ble_data_stream::BluetoothBleDataStream::spawn_with_llm_actions(
251                device_name,
252                ctx.llm_client,
253                ctx.state,
254                ctx.status_tx,
255                ctx.server_id,
256                instruction,
257            )
258            .await
259        })
260    }
261
262    /// Delegated: the base's executor is what actually runs, so validation must accept exactly
263    /// the base's action names and reject everything else rather than waving any action through.
264    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
265        Self::base().execute_action(action)
266    }
267}