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}