Skip to main content

netget/server/bluetooth_ble_remote/
actions.rs

1//! BLE media remote - HID Service (0x1812) consumer control (play/pause, volume, track)
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 media remote - HID Service (0x1812) consumer control (play/pause, volume, track)
24pub struct BluetoothBleRemoteProtocol;
25
26impl BluetoothBleRemoteProtocol {
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 BluetoothBleRemoteProtocol {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Protocol for BluetoothBleRemoteProtocol {
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-Remote)".to_string(),
49            required: false,
50            example: json!("NetGet-Remote"),
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_REMOTE"
76    }
77
78    fn stack_name(&self) -> &'static str {
79        "BLUETOOTH_BLE_REMOTE"
80    }
81
82    fn keywords(&self) -> Vec<&'static str> {
83        // No bare "remote" or "media": both are too generic to own, and
84        // "bluetooth"/"ble" are shared by 18 BLE profiles and identify none of them.
85        vec!["ble remote", "ble media control", "bluetooth_ble_remote"]
86    }
87
88    /// HID-over-GATT caveat: a host will only treat this as an input device once
89    /// it bonds, and ble-peripheral-rust 0.2 exposes no pairing or bonding control.
90    /// The GATT layout below is correct and readable; whether a given OS accepts it
91    /// as a real HID device is platform dependent and untested.
92    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
93        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
94
95        ProtocolMetadataV2::builder()
96            .state(DevelopmentState::Experimental)
97            // Answers on failure: the base stack replies ATT Unlikely Error (0x0E) to a read it
98            // cannot satisfy, which the central surfaces as a failed read. No key press is invented.
99            .answers_on_failure()
100            .implementation(
101                "bluetooth-ble base stack (ble-peripheral-rust) plus an instruction preamble for the HID Service (0x1812) as a consumer control device",
102            )
103            .llm_control(
104                "Base BLE GATT control (add_service, start_advertising, stop_advertising, respond_to_read, respond_to_write, send_notification); the LLM builds the HID Service (0x1812) as a consumer control device itself.",
105            )
106            .e2e_testing(
107                "tests/server/bluetooth_ble_remote/report_descriptor_test.rs pins the HID report descriptor, build_remote_report's bit assignments and the GATT values in the startup examples against literal spec bytes; it is a pure unit test, so it claims no adapter and is not #[ignore]d. e2e_test.rs additionally starts the server against a mocked model, which proves startup and the bluetooth_ble_started round trip and nothing at all about HID. Whether a real host accepts this as an input device is untested and needs an adapter plus an independent central (nRF Connect, btleplug); until that exists the rating cannot rise above Experimental.",
108            )
109            .notes(
110                "Thin profile wrapper over the bluetooth-ble base stack. It prepends an instruction describing the HID Service (0x1812) as a consumer control device 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.",
111            )
112            .build()
113    }
114
115    fn description(&self) -> &'static str {
116        "BLE media remote - HID Service (0x1812) consumer control (play/pause, volume, track)"
117    }
118
119    fn example_prompt(&self) -> &'static str {
120        "Act as a Bluetooth media remote. When a host subscribes, send play/pause then volume up."
121    }
122
123    fn group_name(&self) -> &'static str {
124        "Network"
125    }
126
127    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
128        use crate::llm::actions::StartupExamples;
129
130        use crate::server::bluetooth_ble_remote::{
131            HID_REMOTE_INPUT_REPORT_LEN, HID_REMOTE_REPORT_DESCRIPTOR,
132        };
133
134        // The report map is hex-encoded from the descriptor const rather than written out
135        // again here. A model copies these examples verbatim onto a real GATT table, so a
136        // second hand-maintained copy is a report map that drifts from the one the profile
137        // documents — which is exactly what happened: the literal that used to sit here
138        // declared nine controls against a Report Count of eight (so Stop was silently
139        // dropped), assigned the remaining eight to entirely different bits from
140        // `build_remote_report`, and described a one-byte report where the descriptor const
141        // describes two.
142        let report_map = hex::encode(HID_REMOTE_REPORT_DESCRIPTOR);
143        // An all-zeroes report of the exact length the descriptor declares: "no control is
144        // pressed". Sized from the const so it cannot disagree with the report map.
145        let empty_report = hex::encode(vec![0u8; HID_REMOTE_INPUT_REPORT_LEN]);
146        let read_script = format!(
147            "actions = [{{'type': 'respond_to_read', 'value': '{}'}}]",
148            empty_report
149        );
150
151        // Every event id and action name below is one the base stack really emits and really
152        // executes. UUIDs are written in full 128-bit form because the base parses them with
153        // `Uuid::parse_str`, which rejects the 16-bit shorthand.
154        StartupExamples::new(
155            // LLM mode: the model builds the GATT layout and answers reads itself.
156            json!({
157                "type": "open_server",
158                "port": 0,
159                "base_stack": "bluetooth-ble-remote",
160                "instruction": "Act as a Bluetooth media remote. When a host subscribes, send play/pause then volume up.",
161                "startup_params": {
162                    "device_name": "NetGet-Remote"
163                }
164            }),
165            // Script mode: a read is answered in-process, with no model call.
166            json!({
167                "type": "open_server",
168                "port": 0,
169                "base_stack": "bluetooth-ble-remote",
170                "startup_params": {
171                    "device_name": "NetGet-Remote"
172                },
173                "event_handlers": [
174                    {
175                        "event_pattern": "bluetooth_read_request",
176                        "handler": {
177                            "type": "script",
178                            "language": "python",
179                            "code": read_script
180                        }
181                    }
182                ]
183            }),
184            // Static mode: fixed GATT layout and a fixed read response, with no model call.
185            json!({
186                "type": "open_server",
187                "port": 0,
188                "base_stack": "bluetooth-ble-remote",
189                "startup_params": {
190                    "device_name": "NetGet-Remote"
191                },
192                "event_handlers": [
193                    {
194                        "event_pattern": "bluetooth_ble_started",
195                        "handler": {
196                            "type": "static",
197                            "actions": [
198                                {
199                                    "type": "add_service",
200                                    "uuid": "00001812-0000-1000-8000-00805f9b34fb",
201                                    "primary": true,
202                                    "characteristics": [
203                                        {
204                                            "uuid": "00002a4a-0000-1000-8000-00805f9b34fb",
205                                            "properties": [
206                                                "read"
207                                            ],
208                                            "permissions": [
209                                                "readable"
210                                            ],
211                                            // HID Information: bcdHID 0x0111 (v1.11) as a
212                                            // little-endian uint16, then bCountryCode 0x00
213                                            // and Flags 0x02 (NormallyConnectable). Every
214                                            // GATT integer is little-endian, so the version
215                                            // is `1101` and not `0111` — written the other
216                                            // way round a host reads HID version 17.01.
217                                            "initial_value": "11010002"
218                                        },
219                                        {
220                                            "uuid": "00002a4b-0000-1000-8000-00805f9b34fb",
221                                            "properties": [
222                                                "read"
223                                            ],
224                                            "permissions": [
225                                                "readable"
226                                            ],
227                                            "initial_value": report_map
228                                        },
229                                        {
230                                            "uuid": "00002a4d-0000-1000-8000-00805f9b34fb",
231                                            "properties": [
232                                                "read",
233                                                "notify"
234                                            ],
235                                            "permissions": [
236                                                "readable"
237                                            ],
238                                            "initial_value": empty_report
239                                        },
240                                        {
241                                            "uuid": "00002a4c-0000-1000-8000-00805f9b34fb",
242                                            "properties": [
243                                                "write_without_response"
244                                            ],
245                                            "permissions": [
246                                                "writeable"
247                                            ]
248                                        }
249                                    ]
250                                },
251                                {
252                                    "type": "start_advertising",
253                                    "device_name": "NetGet-Remote",
254                                    "service_uuids": [
255                                        "00001812-0000-1000-8000-00805f9b34fb"
256                                    ]
257                                }
258                            ]
259                        }
260                    },
261                    {
262                        "event_pattern": "bluetooth_read_request",
263                        "handler": {
264                            "type": "static",
265                            "actions": [
266                                {
267                                    "type": "respond_to_read",
268                                    "value": empty_report
269                                }
270                            ]
271                        }
272                    }
273                ]
274            }),
275        )
276    }
277}
278
279impl Server for BluetoothBleRemoteProtocol {
280    fn spawn(
281        &self,
282        ctx: crate::protocol::SpawnContext,
283    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
284    {
285        Box::pin(async move {
286            let device_name = ctx
287                .startup_params
288                .as_ref()
289                .map(|p| p.get_optional_string("device_name"))
290                .transpose()?
291                .flatten()
292                .unwrap_or_else(|| "NetGet-Remote".to_string());
293
294            // The user's own instruction must reach the base stack; the profile preamble is
295            // added there, not substituted for it.
296            let instruction = ctx
297                .state
298                .get_server(ctx.server_id)
299                .await
300                .map(|s| s.instruction)
301                .unwrap_or_default();
302
303            crate::server::bluetooth_ble_remote::BluetoothBleRemote::spawn_with_llm_actions(
304                device_name,
305                ctx.llm_client,
306                ctx.state,
307                ctx.status_tx,
308                ctx.server_id,
309                instruction,
310            )
311            .await
312        })
313    }
314
315    /// Delegated: the base's executor is what actually runs, so validation must accept exactly
316    /// the base's action names and reject everything else rather than waving any action through.
317    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
318        Self::base().execute_action(action)
319    }
320}