Skip to main content

netget/client/bluetooth/
actions.rs

1//! Bluetooth Low Energy (BLE) client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Bluetooth client scan complete event
15pub static BLUETOOTH_SCAN_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "bluetooth_scan_complete",
18        "BLE device scan completed with list of discovered devices",
19        json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
20    )
21    .with_parameters(vec![Parameter {
22        name: "devices".to_string(),
23        type_hint: "array".to_string(),
24        description: "List of discovered BLE devices with address, name, and RSSI".to_string(),
25        required: true,
26    }])
27});
28
29/// Bluetooth client connected event
30pub static BLUETOOTH_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
31    EventType::new(
32        "bluetooth_connected",
33        "Successfully connected to BLE device",
34        json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
35    )
36    .with_parameters(vec![
37        Parameter {
38            name: "device_address".to_string(),
39            type_hint: "string".to_string(),
40            description: "MAC address of connected device".to_string(),
41            required: true,
42        },
43        Parameter {
44            name: "device_name".to_string(),
45            type_hint: "string".to_string(),
46            description: "Name of connected device".to_string(),
47            required: false,
48        },
49    ])
50});
51
52/// Bluetooth client services discovered event
53pub static BLUETOOTH_SERVICES_DISCOVERED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
54    EventType::new(
55        "bluetooth_services_discovered",
56        "GATT services and characteristics discovered",
57        json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
58    )
59    .with_parameters(vec![Parameter {
60        name: "services".to_string(),
61        type_hint: "array".to_string(),
62        description: "List of GATT services with their characteristics".to_string(),
63        required: true,
64    }])
65});
66
67/// Bluetooth client data read event
68pub static BLUETOOTH_DATA_READ_EVENT: LazyLock<EventType> = LazyLock::new(|| {
69    EventType::new(
70        "bluetooth_data_read",
71        "Data read from BLE characteristic",
72        json!({"type": "placeholder", "event_id": "bluetooth_data_read"}),
73    )
74    .with_parameters(vec![
75        Parameter {
76            name: "service_uuid".to_string(),
77            type_hint: "string".to_string(),
78            description: "UUID of the service".to_string(),
79            required: true,
80        },
81        Parameter {
82            name: "characteristic_uuid".to_string(),
83            type_hint: "string".to_string(),
84            description: "UUID of the characteristic".to_string(),
85            required: true,
86        },
87        Parameter {
88            name: "value".to_string(),
89            type_hint: "string".to_string(),
90            description: "Human-readable value (if applicable)".to_string(),
91            required: false,
92        },
93        Parameter {
94            name: "value_hex".to_string(),
95            type_hint: "string".to_string(),
96            description: "Hex-encoded raw bytes".to_string(),
97            required: true,
98        },
99    ])
100});
101
102/// Bluetooth client notification received event
103pub static BLUETOOTH_NOTIFICATION_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
104    EventType::new(
105        "bluetooth_notification_received",
106        "Notification received from subscribed BLE characteristic",
107        json!({"type": "write_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb", "value_hex": "01", "with_response": true}),
108    )
109    .with_parameters(vec![
110        Parameter {
111            name: "service_uuid".to_string(),
112            type_hint: "string".to_string(),
113            description: "UUID of the service".to_string(),
114            required: true,
115        },
116        Parameter {
117            name: "characteristic_uuid".to_string(),
118            type_hint: "string".to_string(),
119            description: "UUID of the characteristic".to_string(),
120            required: true,
121        },
122        Parameter {
123            name: "value".to_string(),
124            type_hint: "string".to_string(),
125            description: "Human-readable value (if applicable)".to_string(),
126            required: false,
127        },
128        Parameter {
129            name: "value_hex".to_string(),
130            type_hint: "string".to_string(),
131            description: "Hex-encoded raw bytes".to_string(),
132            required: true,
133        },
134    ])
135});
136
137/// Bluetooth client protocol action handler
138pub struct BluetoothClientProtocol;
139
140impl BluetoothClientProtocol {
141    pub fn new() -> Self {
142        Self
143    }
144}
145
146// Implement Protocol trait (common functionality)
147impl Protocol for BluetoothClientProtocol {
148    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
149        vec![
150            ActionDefinition {
151                name: "scan_devices".to_string(),
152                description: "Scan for nearby BLE devices".to_string(),
153                parameters: vec![Parameter {
154                    name: "duration_secs".to_string(),
155                    type_hint: "number".to_string(),
156                    description: "How long to scan in seconds (default: 5)".to_string(),
157                    required: false,
158                }],
159                example: json!({
160                    "type": "scan_devices",
161                    "duration_secs": 5
162                }),
163                log_template: None,
164            },
165            ActionDefinition {
166                name: "connect_device".to_string(),
167                description: "Connect to a specific BLE device by address or name".to_string(),
168                parameters: vec![
169                    Parameter {
170                        name: "device_address".to_string(),
171                        type_hint: "string".to_string(),
172                        description: "MAC address of device (e.g., 'AA:BB:CC:DD:EE:FF')"
173                            .to_string(),
174                        required: false,
175                    },
176                    Parameter {
177                        name: "device_name".to_string(),
178                        type_hint: "string".to_string(),
179                        description: "Name of device (if address not provided)".to_string(),
180                        required: false,
181                    },
182                ],
183                example: json!({
184                    "type": "connect_device",
185                    "device_address": "AA:BB:CC:DD:EE:FF"
186                }),
187                log_template: None,
188            },
189            ActionDefinition {
190                name: "discover_services".to_string(),
191                description: "Discover GATT services and characteristics on connected device"
192                    .to_string(),
193                parameters: vec![],
194                example: json!({
195                    "type": "discover_services"
196                }),
197                log_template: None,
198            },
199            ActionDefinition {
200                name: "disconnect".to_string(),
201                description: "Disconnect from the BLE device".to_string(),
202                parameters: vec![],
203                example: json!({
204                    "type": "disconnect"
205                }),
206                log_template: None,
207            },
208        ]
209    }
210
211    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
212        vec![
213            ActionDefinition {
214                name: "read_characteristic".to_string(),
215                description: "Read value from a BLE characteristic".to_string(),
216                parameters: vec![
217                    Parameter {
218                        name: "service_uuid".to_string(),
219                        type_hint: "string".to_string(),
220                        description:
221                            "UUID of the service (e.g., '0000180f-0000-1000-8000-00805f9b34fb')"
222                                .to_string(),
223                        required: true,
224                    },
225                    Parameter {
226                        name: "characteristic_uuid".to_string(),
227                        type_hint: "string".to_string(),
228                        description: "UUID of the characteristic".to_string(),
229                        required: true,
230                    },
231                ],
232                example: json!({
233                    "type": "read_characteristic",
234                    "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
235                    "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"
236                }),
237                log_template: None,
238            },
239            ActionDefinition {
240                name: "write_characteristic".to_string(),
241                description: "Write value to a BLE characteristic".to_string(),
242                parameters: vec![
243                    Parameter {
244                        name: "service_uuid".to_string(),
245                        type_hint: "string".to_string(),
246                        description: "UUID of the service".to_string(),
247                        required: true,
248                    },
249                    Parameter {
250                        name: "characteristic_uuid".to_string(),
251                        type_hint: "string".to_string(),
252                        description: "UUID of the characteristic".to_string(),
253                        required: true,
254                    },
255                    Parameter {
256                        name: "value_hex".to_string(),
257                        type_hint: "string".to_string(),
258                        description: "Hex-encoded bytes to write".to_string(),
259                        required: true,
260                    },
261                    Parameter {
262                        name: "with_response".to_string(),
263                        type_hint: "boolean".to_string(),
264                        description: "Whether to wait for write response (default: true)"
265                            .to_string(),
266                        required: false,
267                    },
268                ],
269                example: json!({
270                    "type": "write_characteristic",
271                    "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
272                    "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb",
273                    "value_hex": "01",
274                    "with_response": true
275                }),
276                log_template: None,
277            },
278            ActionDefinition {
279                name: "subscribe_notifications".to_string(),
280                description: "Subscribe to notifications from a BLE characteristic".to_string(),
281                parameters: vec![
282                    Parameter {
283                        name: "service_uuid".to_string(),
284                        type_hint: "string".to_string(),
285                        description: "UUID of the service".to_string(),
286                        required: true,
287                    },
288                    Parameter {
289                        name: "characteristic_uuid".to_string(),
290                        type_hint: "string".to_string(),
291                        description: "UUID of the characteristic".to_string(),
292                        required: true,
293                    },
294                ],
295                example: json!({
296                    "type": "subscribe_notifications",
297                    "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
298                    "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"
299                }),
300                log_template: None,
301            },
302            ActionDefinition {
303                name: "unsubscribe_notifications".to_string(),
304                description: "Unsubscribe from notifications from a BLE characteristic".to_string(),
305                parameters: vec![
306                    Parameter {
307                        name: "service_uuid".to_string(),
308                        type_hint: "string".to_string(),
309                        description: "UUID of the service".to_string(),
310                        required: true,
311                    },
312                    Parameter {
313                        name: "characteristic_uuid".to_string(),
314                        type_hint: "string".to_string(),
315                        description: "UUID of the characteristic".to_string(),
316                        required: true,
317                    },
318                ],
319                example: json!({
320                    "type": "unsubscribe_notifications",
321                    "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
322                    "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"
323                }),
324                log_template: None,
325            },
326        ]
327    }
328
329    fn protocol_name(&self) -> &'static str {
330        "Bluetooth (BLE)"
331    }
332
333    fn get_event_types(&self) -> Vec<EventType> {
334        vec![
335            EventType::new(
336                "bluetooth_scan_complete",
337                "Triggered when BLE scan completes",
338                json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
339            ),
340            EventType::new(
341                "bluetooth_connected",
342                "Triggered when connected to BLE device",
343                json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
344            ),
345            EventType::new(
346                "bluetooth_services_discovered",
347                "Triggered when GATT services are discovered",
348                json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
349            ),
350            EventType::new(
351                "bluetooth_data_read",
352                "Triggered when data is read from characteristic",
353                json!({"type": "write_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb", "value_hex": "01", "with_response": true}),
354            ),
355            EventType::new(
356                "bluetooth_notification_received",
357                "Triggered when notification is received",
358                json!({"type": "write_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb", "value_hex": "01", "with_response": true}),
359            ),
360            EventType::new(
361                "bluetooth_disconnected",
362                "Triggered when disconnected from device",
363                json!({"type": "read_characteristic", "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb", "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"}),
364            ),
365        ]
366    }
367
368    fn stack_name(&self) -> &'static str {
369        "BLE>GATT"
370    }
371
372    fn keywords(&self) -> Vec<&'static str> {
373        vec![
374            "bluetooth",
375            "ble",
376            "bluetooth low energy",
377            "gatt",
378            "connect to bluetooth",
379        ]
380    }
381
382    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
383        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
384
385        ProtocolMetadataV2::builder()
386            .state(DevelopmentState::Experimental)
387            .implementation("btleplug library for cross-platform BLE")
388            .llm_control("Full control over scanning, connecting, service discovery, read/write/subscribe operations")
389            .e2e_testing("Real BLE device or simulator required")
390            .build()
391    }
392
393    fn description(&self) -> &'static str {
394        "Bluetooth Low Energy (BLE) client for connecting to BLE devices (Classic Bluetooth not supported)"
395    }
396
397    fn example_prompt(&self) -> &'static str {
398        "Scan for BLE devices, connect to 'Heart Rate Monitor', and read battery level"
399    }
400
401    fn group_name(&self) -> &'static str {
402        "Wireless"
403    }
404    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
405        use crate::llm::actions::StartupExamples;
406        use serde_json::json;
407
408        StartupExamples::new(
409            // LLM mode: LLM handles Bluetooth BLE operations
410            json!({
411                "type": "open_client",
412                "remote_addr": "ble:scan",
413                "base_stack": "bluetooth",
414                "instruction": "Scan for BLE devices, connect to heart rate monitor, and read battery level"
415            }),
416            // Script mode: Code-based BLE handling
417            json!({
418                "type": "open_client",
419                "remote_addr": "ble:scan",
420                "base_stack": "bluetooth",
421                "event_handlers": [{
422                    "event_pattern": "bluetooth_connected",
423                    "handler": {
424                        "type": "script",
425                        "language": "python",
426                        "code": "<bluetooth_client_handler>"
427                    }
428                }]
429            }),
430            // Static mode: Fixed BLE action
431            json!({
432                "type": "open_client",
433                "remote_addr": "ble:scan",
434                "base_stack": "bluetooth",
435                "event_handlers": [
436                    {
437                        "event_pattern": "bluetooth_connected",
438                        "handler": {
439                            "type": "static",
440                            "actions": [{
441                                "type": "discover_services"
442                            }]
443                        }
444                    },
445                    {
446                        "event_pattern": "bluetooth_services_discovered",
447                        "handler": {
448                            "type": "static",
449                            "actions": [{
450                                "type": "read_characteristic",
451                                "service_uuid": "0000180f-0000-1000-8000-00805f9b34fb",
452                                "characteristic_uuid": "00002a19-0000-1000-8000-00805f9b34fb"
453                            }]
454                        }
455                    }
456                ]
457            }),
458        )
459    }
460}
461
462// Implement Client trait (client-specific functionality)
463impl Client for BluetoothClientProtocol {
464    fn connect(
465        &self,
466        ctx: crate::protocol::ConnectContext,
467    ) -> std::pin::Pin<
468        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
469    > {
470        Box::pin(async move {
471            use crate::client::bluetooth::BluetoothClient;
472            BluetoothClient::connect_with_llm_actions(
473                ctx.remote_addr,
474                ctx.llm_client,
475                ctx.state,
476                ctx.status_tx,
477                ctx.client_id,
478            )
479            .await
480        })
481    }
482
483    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
484        let action_type = action
485            .get("type")
486            .and_then(|v| v.as_str())
487            .context("Missing 'type' field in action")?;
488
489        match action_type {
490            "scan_devices" => {
491                let duration_secs = action
492                    .get("duration_secs")
493                    .and_then(|v| v.as_u64())
494                    .unwrap_or(5);
495
496                Ok(ClientActionResult::Custom {
497                    name: "scan_devices".to_string(),
498                    data: json!({
499                        "duration_secs": duration_secs,
500                    }),
501                })
502            }
503            "connect_device" => {
504                let device_address = action
505                    .get("device_address")
506                    .and_then(|v| v.as_str())
507                    .map(|s| s.to_string());
508
509                let device_name = action
510                    .get("device_name")
511                    .and_then(|v| v.as_str())
512                    .map(|s| s.to_string());
513
514                if device_address.is_none() && device_name.is_none() {
515                    return Err(anyhow::anyhow!(
516                        "Either device_address or device_name must be provided"
517                    ));
518                }
519
520                Ok(ClientActionResult::Custom {
521                    name: "connect_device".to_string(),
522                    data: json!({
523                        "device_address": device_address,
524                        "device_name": device_name,
525                    }),
526                })
527            }
528            "discover_services" => Ok(ClientActionResult::Custom {
529                name: "discover_services".to_string(),
530                data: json!({}),
531            }),
532            "read_characteristic" => {
533                let service_uuid = action
534                    .get("service_uuid")
535                    .and_then(|v| v.as_str())
536                    .context("Missing 'service_uuid' field")?
537                    .to_string();
538
539                let characteristic_uuid = action
540                    .get("characteristic_uuid")
541                    .and_then(|v| v.as_str())
542                    .context("Missing 'characteristic_uuid' field")?
543                    .to_string();
544
545                Ok(ClientActionResult::Custom {
546                    name: "read_characteristic".to_string(),
547                    data: json!({
548                        "service_uuid": service_uuid,
549                        "characteristic_uuid": characteristic_uuid,
550                    }),
551                })
552            }
553            "write_characteristic" => {
554                let service_uuid = action
555                    .get("service_uuid")
556                    .and_then(|v| v.as_str())
557                    .context("Missing 'service_uuid' field")?
558                    .to_string();
559
560                let characteristic_uuid = action
561                    .get("characteristic_uuid")
562                    .and_then(|v| v.as_str())
563                    .context("Missing 'characteristic_uuid' field")?
564                    .to_string();
565
566                let value_hex = action
567                    .get("value_hex")
568                    .and_then(|v| v.as_str())
569                    .context("Missing 'value_hex' field")?;
570
571                let value_bytes =
572                    hex::decode(value_hex).context("Invalid hex data in value_hex")?;
573
574                let with_response = action
575                    .get("with_response")
576                    .and_then(|v| v.as_bool())
577                    .unwrap_or(true);
578
579                Ok(ClientActionResult::Custom {
580                    name: "write_characteristic".to_string(),
581                    data: json!({
582                        "service_uuid": service_uuid,
583                        "characteristic_uuid": characteristic_uuid,
584                        "value_bytes": value_bytes,
585                        "with_response": with_response,
586                    }),
587                })
588            }
589            "subscribe_notifications" => {
590                let service_uuid = action
591                    .get("service_uuid")
592                    .and_then(|v| v.as_str())
593                    .context("Missing 'service_uuid' field")?
594                    .to_string();
595
596                let characteristic_uuid = action
597                    .get("characteristic_uuid")
598                    .and_then(|v| v.as_str())
599                    .context("Missing 'characteristic_uuid' field")?
600                    .to_string();
601
602                Ok(ClientActionResult::Custom {
603                    name: "subscribe_notifications".to_string(),
604                    data: json!({
605                        "service_uuid": service_uuid,
606                        "characteristic_uuid": characteristic_uuid,
607                    }),
608                })
609            }
610            "unsubscribe_notifications" => {
611                let service_uuid = action
612                    .get("service_uuid")
613                    .and_then(|v| v.as_str())
614                    .context("Missing 'service_uuid' field")?
615                    .to_string();
616
617                let characteristic_uuid = action
618                    .get("characteristic_uuid")
619                    .and_then(|v| v.as_str())
620                    .context("Missing 'characteristic_uuid' field")?
621                    .to_string();
622
623                Ok(ClientActionResult::Custom {
624                    name: "unsubscribe_notifications".to_string(),
625                    data: json!({
626                        "service_uuid": service_uuid,
627                        "characteristic_uuid": characteristic_uuid,
628                    }),
629                })
630            }
631            "disconnect" => Ok(ClientActionResult::Disconnect),
632            _ => Err(anyhow::anyhow!(
633                "Unknown Bluetooth client action: {}",
634                action_type
635            )),
636        }
637    }
638}