netget/server/bluetooth_ble_file_transfer/actions.rs
1//! BLE file transfer - custom GATT control point plus data characteristic
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 file transfer - custom GATT control point plus data characteristic
24pub struct BluetoothBleFileTransferProtocol;
25
26impl BluetoothBleFileTransferProtocol {
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 BluetoothBleFileTransferProtocol {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl Protocol for BluetoothBleFileTransferProtocol {
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-FileTransfer)"
49 .to_string(),
50 required: false,
51 example: json!("NetGet-FileTransfer"),
52 default: None,
53 }]
54 }
55
56 /// Delegated: the base stack owns every action this server can execute.
57 fn get_async_actions(&self, state: &AppState) -> Vec<ActionDefinition> {
58 Self::base().get_async_actions(state)
59 }
60
61 /// Delegated: see `get_async_actions`. Returning `vec![]` here while the base emits
62 /// events would leave the model with no way to answer a read or a write.
63 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
64 Self::base().get_sync_actions()
65 }
66
67 /// Delegated: the base's event types are the only ones ever emitted for this server, and
68 /// they carry their own `.with_actions(...)` lists, which is what `call_llm` offers the
69 /// model. An event id declared here but not emitted by the base would silently match an
70 /// `event_handlers` pattern that can never fire.
71 fn get_event_types(&self) -> Vec<EventType> {
72 Self::base().get_event_types()
73 }
74
75 fn protocol_name(&self) -> &'static str {
76 "BLUETOOTH_BLE_FILE_TRANSFER"
77 }
78
79 fn stack_name(&self) -> &'static str {
80 "BLUETOOTH_BLE_FILE_TRANSFER"
81 }
82
83 fn keywords(&self) -> Vec<&'static str> {
84 // Deliberately no bare single words: bare "file" and "transfer" swallowed FTP's "file transfer" and NFS's "file server". A keyword is matched with
85 // word boundaries against the whole prompt, so a generic one silently wins
86 // requests meant for another protocol. "bluetooth"/"ble" are dropped too --
87 // they are shared by 18 BLE profiles and identify none of them.
88 vec!["ble file transfer", "ble ota", "ota update"]
89 }
90
91 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
92 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
93
94 ProtocolMetadataV2::builder()
95 .state(DevelopmentState::Experimental)
96 .implementation(
97 "bluetooth-ble base stack (ble-peripheral-rust) plus an instruction preamble for the custom file transfer service",
98 )
99 .llm_control(
100 "Base BLE GATT control (add_service, start_advertising, stop_advertising, respond_to_read, respond_to_write, send_notification); the LLM builds the custom file transfer service itself.",
101 )
102 .e2e_testing(
103 "Two automated suites, neither of which is evidence for a rating above Experimental. tests/server/bluetooth_ble_file_transfer/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.",
104 )
105 .notes(
106 "Thin profile wrapper over the bluetooth-ble base stack. It prepends an instruction describing the custom file transfer 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.",
107 )
108 .build()
109 }
110
111 fn description(&self) -> &'static str {
112 "BLE file transfer - custom GATT control point plus data characteristic"
113 }
114
115 fn example_prompt(&self) -> &'static str {
116 "Act as a BLE file server offering a 4 KB file named readme.txt in 180-byte chunks"
117 }
118
119 fn group_name(&self) -> &'static str {
120 "Network"
121 }
122
123 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
124 use crate::llm::actions::StartupExamples;
125
126 // Every event id and action name below is one the base stack really emits and really
127 // executes. UUIDs are written in full 128-bit form because the base parses them with
128 // `Uuid::parse_str`, which rejects the 16-bit shorthand.
129 StartupExamples::new(
130 // LLM mode: the model builds the GATT layout and answers reads itself.
131 json!({
132 "type": "open_server",
133 "port": 0,
134 "base_stack": "bluetooth-ble-file-transfer",
135 "instruction": "Act as a BLE file server offering a 4 KB file named readme.txt in 180-byte chunks",
136 "startup_params": {
137 "device_name": "NetGet-FileTransfer"
138 }
139 }),
140 // Script mode: a write is acknowledged in-process, with no model call. Neither
141 // characteristic is readable, so there is no read to answer here.
142 json!({
143 "type": "open_server",
144 "port": 0,
145 "base_stack": "bluetooth-ble-file-transfer",
146 "startup_params": {
147 "device_name": "NetGet-FileTransfer"
148 },
149 "event_handlers": [
150 {
151 "event_pattern": "bluetooth_write_request",
152 "handler": {
153 "type": "script",
154 "language": "python",
155 "code": "import json,sys\njson.load(sys.stdin)\nprint(json.dumps({'actions':[{'type':'respond_to_write','status':'success'}]}))"
156 }
157 }
158 ]
159 }),
160 // Static mode: a fixed GATT layout and a fixed write acknowledgement, with no model
161 // call. Nothing here is readable, so there is no read to answer.
162 json!({
163 "type": "open_server",
164 "port": 0,
165 "base_stack": "bluetooth-ble-file-transfer",
166 "startup_params": {
167 "device_name": "NetGet-FileTransfer"
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": "0000fe59-0000-1000-8000-00805f9b34fb",
178 "primary": true,
179 "characteristics": [
180 {
181 "uuid": "00008ec9-0000-1000-8000-00805f9b34fb",
182 "properties": [
183 "write",
184 "notify"
185 ],
186 "permissions": [
187 "writeable"
188 ]
189 },
190 {
191 "uuid": "00008ec8-0000-1000-8000-00805f9b34fb",
192 "properties": [
193 "write_without_response",
194 "notify"
195 ],
196 "permissions": [
197 "writeable"
198 ]
199 }
200 ]
201 },
202 {
203 "type": "start_advertising",
204 "device_name": "NetGet-FileTransfer",
205 "service_uuids": [
206 "0000fe59-0000-1000-8000-00805f9b34fb"
207 ]
208 }
209 ]
210 }
211 },
212 // A write handler, not a read one. Neither characteristic above is
213 // readable — both are write/notify, which is what a file-transfer
214 // control point and data pipe are — so the `bluetooth_read_request`
215 // handler this example used to carry could never match anything. It
216 // validated at startup and then sat there, an answer to a question the
217 // service cannot be asked, in the example a model copies verbatim.
218 //
219 // `respond_to_write` acknowledges the write; the transfer's own
220 // progress goes back over the notify characteristic with
221 // `send_notification`, which needs the model (or a script) to decide
222 // what to say, so it is not part of a static example.
223 {
224 "event_pattern": "bluetooth_write_request",
225 "handler": {
226 "type": "static",
227 "actions": [
228 {
229 "type": "respond_to_write",
230 "status": "success"
231 }
232 ]
233 }
234 }
235 ]
236 }),
237 )
238 }
239}
240
241impl Server for BluetoothBleFileTransferProtocol {
242 fn spawn(
243 &self,
244 ctx: crate::protocol::SpawnContext,
245 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
246 {
247 Box::pin(async move {
248 let device_name = ctx
249 .startup_params
250 .as_ref()
251 .map(|p| p.get_optional_string("device_name"))
252 .transpose()?
253 .flatten()
254 .unwrap_or_else(|| "NetGet-FileTransfer".to_string());
255
256 // The user's own instruction must reach the base stack; the profile preamble is
257 // added there, not substituted for it.
258 let instruction = ctx
259 .state
260 .get_server(ctx.server_id)
261 .await
262 .map(|s| s.instruction)
263 .unwrap_or_default();
264
265 crate::server::bluetooth_ble_file_transfer::BluetoothBleFileTransfer::spawn_with_llm_actions(
266 device_name,
267 ctx.llm_client,
268 ctx.state,
269 ctx.status_tx,
270 ctx.server_id,
271 instruction,
272 )
273 .await
274 })
275 }
276
277 /// Delegated: the base's executor is what actually runs, so validation must accept exactly
278 /// the base's action names and reject everything else rather than waving any action through.
279 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
280 Self::base().execute_action(action)
281 }
282}