Skip to main content

netget/server/bluetooth_ble_running/
actions.rs

1//! BLE Running Speed and Cadence Service (0x1814) - pace, cadence and stride length
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 Running Speed and Cadence Service (0x1814) - pace, cadence and stride length
24pub struct BluetoothBleRunningProtocol;
25
26impl BluetoothBleRunningProtocol {
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 BluetoothBleRunningProtocol {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Protocol for BluetoothBleRunningProtocol {
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-Running)".to_string(),
49            required: false,
50            example: json!("NetGet-Running"),
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_RUNNING"
76    }
77
78    fn stack_name(&self) -> &'static str {
79        "BLUETOOTH_BLE_RUNNING"
80    }
81
82    fn keywords(&self) -> Vec<&'static str> {
83        vec![
84            "bluetooth",
85            "ble",
86            "running",
87            "jogging",
88            "cadence",
89            "fitness",
90        ]
91    }
92
93    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
94        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
95
96        ProtocolMetadataV2::builder()
97            .state(DevelopmentState::Experimental)
98            .implementation(
99                "bluetooth-ble base stack (ble-peripheral-rust) plus an instruction preamble for the Running Speed and Cadence Service (0x1814)",
100            )
101            .llm_control(
102                "Base BLE GATT control (add_service, start_advertising, stop_advertising, respond_to_read, respond_to_write, send_notification); the LLM builds the Running Speed and Cadence Service (0x1814) itself.",
103            )
104            .e2e_testing(
105                "Two automated suites, neither of which is evidence for a rating above Experimental. tests/server/bluetooth_ble_running/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 pins every UUID and value byte in the startup examples against the Bluetooth SIG layout, byte order included. Proving the profile works still needs a real central (nRF Connect, btleplug) completing a read or a subscription against a service this profile built; nothing in the tree does that.",
106            )
107            .notes(
108                "Thin profile wrapper over the bluetooth-ble base stack. It prepends an instruction describing the Running Speed and Cadence Service (0x1814) 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.",
109            )
110            .build()
111    }
112
113    fn description(&self) -> &'static str {
114        "BLE Running Speed and Cadence Service (0x1814) - pace, cadence and stride length"
115    }
116
117    fn example_prompt(&self) -> &'static str {
118        "Act as a running pod reporting a 5:30 min/km pace at 180 steps per minute"
119    }
120
121    fn group_name(&self) -> &'static str {
122        "Network"
123    }
124
125    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
126        use crate::llm::actions::StartupExamples;
127
128        // Every event id and action name below is one the base stack really emits and really
129        // executes. UUIDs are written in full 128-bit form because the base parses them with
130        // `Uuid::parse_str`, which rejects the 16-bit shorthand.
131        StartupExamples::new(
132            // LLM mode: the model builds the GATT layout and answers reads itself.
133            json!({
134                "type": "open_server",
135                "port": 0,
136                "base_stack": "bluetooth-ble-running",
137                "instruction": "Act as a running pod reporting a 5:30 min/km pace at 180 steps per minute",
138                "startup_params": {
139                    "device_name": "NetGet-Running"
140                }
141            }),
142            // Script mode: a read is answered in-process, with no model call.
143            json!({
144                "type": "open_server",
145                "port": 0,
146                "base_stack": "bluetooth-ble-running",
147                "startup_params": {
148                    "device_name": "NetGet-Running"
149                },
150                "event_handlers": [
151                    {
152                        "event_pattern": "bluetooth_read_request",
153                        "handler": {
154                            "type": "script",
155                            "language": "python",
156                            "code": "import json,sys\ne=json.load(sys.stdin)['event']\nv={'00002a54-0000-1000-8000-00805f9b34fb':'0300'}.get(str(e.get('characteristic_uuid','')).lower())\nprint(json.dumps({'actions':[{'type':'respond_to_read','value':v}] if v else []}))"
157                        }
158                    }
159                ]
160            }),
161            // Static mode: fixed GATT layout and a fixed read response, with no model call.
162            json!({
163                "type": "open_server",
164                "port": 0,
165                "base_stack": "bluetooth-ble-running",
166                "startup_params": {
167                    "device_name": "NetGet-Running"
168                },
169                "event_handlers": [
170                    {
171                        "event_pattern": "bluetooth_ble_started",
172                        "handler": {
173                            "type": "static",
174                            "actions": [
175                                {
176                                    "type": "add_service",
177                                    "uuid": "00001814-0000-1000-8000-00805f9b34fb",
178                                    "primary": true,
179                                    "characteristics": [
180                                        {
181                                            "uuid": "00002a53-0000-1000-8000-00805f9b34fb",
182                                            "properties": [
183                                                "notify"
184                                            ],
185                                            "permissions": []
186                                        },
187                                        {
188                                            "uuid": "00002a54-0000-1000-8000-00805f9b34fb",
189                                            "properties": [
190                                                "read"
191                                            ],
192                                            "permissions": [
193                                                "readable"
194                                            ],
195                                            "initial_value": "0300"
196                                        }
197                                    ]
198                                },
199                                {
200                                    "type": "start_advertising",
201                                    "device_name": "NetGet-Running",
202                                    "service_uuids": [
203                                        "00001814-0000-1000-8000-00805f9b34fb"
204                                    ]
205                                }
206                            ]
207                        }
208                    },
209                    {
210                        "event_pattern": "bluetooth_read_request",
211                        "handler": {
212                            "type": "static",
213                            "actions": [
214                                {
215                                    "type": "respond_to_read",
216                                    "value": "0300"
217                                }
218                            ]
219                        }
220                    }
221                ]
222            }),
223        )
224    }
225}
226
227impl Server for BluetoothBleRunningProtocol {
228    fn spawn(
229        &self,
230        ctx: crate::protocol::SpawnContext,
231    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
232    {
233        Box::pin(async move {
234            let device_name = ctx
235                .startup_params
236                .as_ref()
237                .map(|p| p.get_optional_string("device_name"))
238                .transpose()?
239                .flatten()
240                .unwrap_or_else(|| "NetGet-Running".to_string());
241
242            // The user's own instruction must reach the base stack; the profile preamble is
243            // added there, not substituted for it.
244            let instruction = ctx
245                .state
246                .get_server(ctx.server_id)
247                .await
248                .map(|s| s.instruction)
249                .unwrap_or_default();
250
251            crate::server::bluetooth_ble_running::BluetoothBleRunning::spawn_with_llm_actions(
252                device_name,
253                ctx.llm_client,
254                ctx.state,
255                ctx.status_tx,
256                ctx.server_id,
257                instruction,
258            )
259            .await
260        })
261    }
262
263    /// Delegated: the base's executor is what actually runs, so validation must accept exactly
264    /// the base's action names and reject everything else rather than waving any action through.
265    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
266        Self::base().execute_action(action)
267    }
268}