netget/server/bluetooth_ble_keyboard/mod.rs
1//! Bluetooth Low Energy (BLE) HID Keyboard implementation
2//!
3//! Builds on bluetooth-ble to provide HID over GATT keyboard functionality.
4//! Supports connection tracking and targeted messages to specific devices.
5
6pub mod actions;
7
8use anyhow::Result;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::info;
12
13use crate::console_error;
14use crate::llm::ollama_client::OllamaClient;
15use crate::server::bluetooth_ble::BluetoothBle;
16use crate::state::app_state::AppState;
17
18/// BLE HID Keyboard server.
19///
20/// A unit struct: this profile owns no state. `ble-peripheral-rust` 0.2 gives the
21/// peripheral no per-central identity, and the base stack emits no connect or
22/// disconnect events, so there is nothing a connection table could be keyed on or
23/// populated from.
24pub struct BluetoothBleKeyboard;
25
26impl BluetoothBleKeyboard {
27 /// Spawn BLE HID keyboard server
28 #[cfg(all(unix, feature = "bluetooth"))]
29 pub async fn spawn_with_llm_actions(
30 device_name: String,
31 llm_client: OllamaClient,
32 app_state: Arc<AppState>,
33 status_tx: mpsc::UnboundedSender<String>,
34 server_id: crate::state::ServerId,
35 instruction: String,
36 ) -> Result<std::net::SocketAddr> {
37 info!("Starting BLE HID Keyboard server: {}", device_name);
38
39 // Create the underlying BLE server with HID keyboard configuration
40 let keyboard_instruction = format!(
41 "Configure as a BLE HID keyboard with HID Service (UUID: 0x1812). {} {}",
42 instruction,
43 "Add HID Report Map, HID Report Input, HID Information, and HID Control Point characteristics."
44 );
45
46 // Use the base bluetooth-ble server.
47 //
48 // This profile decides nothing once the radio is up: the base stack owns the event
49 // loop, and every `decision=` a read, write or subscribe produces is emitted there
50 // against `BluetoothBleProtocol`. The one terminal outcome the profile does own is
51 // this — the shared radio would not come up, so no HID service was ever published.
52 let log_tx = status_tx.clone();
53 let log_name = device_name.clone();
54 match BluetoothBle::spawn_with_llm_actions(
55 device_name,
56 llm_client,
57 app_state,
58 status_tx,
59 server_id,
60 keyboard_instruction,
61 )
62 .await
63 {
64 Ok(addr) => Ok(addr),
65 Err(e) => {
66 console_error!(log_tx, "{}", radio_start_failure(&log_name, &e));
67 Err(e)
68 }
69 }
70 }
71}
72
73/// The single source of the text used when the shared BLE radio could not be brought up.
74///
75/// `pub` so a test can assert the `decision=` tag without claiming a Bluetooth adapter: this is
76/// the profile's only terminal outcome of its own, and the project forbids `#[cfg(test)]`
77/// modules in `src/`. No model is consulted on this path — the radio fails before any event
78/// exists — so the token is deliberately not one of the `fail_closed_llm_*` pair, which would
79/// send someone to restart Ollama over an adapter that is switched off.
80pub fn radio_start_failure(device_name: &str, err: &anyhow::Error) -> String {
81 format!(
82 "BLE HID keyboard '{device_name}' could not start \
83 (decision=refused_adapter_unavailable): {err}. No HID service was published and \
84 nothing is advertising."
85 )
86}
87
88#[cfg(any(all(unix, not(feature = "bluetooth")), not(unix)))]
89impl BluetoothBleKeyboard {
90 pub async fn spawn_with_llm_actions(
91 _device_name: String,
92 _llm_client: OllamaClient,
93 _app_state: Arc<AppState>,
94 _status_tx: mpsc::UnboundedSender<String>,
95 _server_id: crate::state::ServerId,
96 _instruction: String,
97 ) -> Result<std::net::SocketAddr> {
98 anyhow::bail!(
99 "BLE keyboard support not enabled - compile with --features bluetooth-ble-keyboard"
100 )
101 }
102}
103
104/// HID keyboard scan codes (USB HID specification)
105pub mod hid_keycodes {
106 pub const KEY_A: u8 = 0x04;
107 pub const KEY_B: u8 = 0x05;
108 pub const KEY_C: u8 = 0x06;
109 // ... (full mapping in actual implementation)
110 pub const KEY_ENTER: u8 = 0x28;
111 pub const KEY_SPACE: u8 = 0x2C;
112
113 /// Modifier bits
114 pub const MOD_LEFT_CTRL: u8 = 0x01;
115 pub const MOD_LEFT_SHIFT: u8 = 0x02;
116 pub const MOD_LEFT_ALT: u8 = 0x04;
117 pub const MOD_LEFT_GUI: u8 = 0x08;
118
119 /// Convert ASCII character to HID keycode
120 pub fn char_to_keycode(c: char) -> Option<(u8, u8)> {
121 // Returns (modifiers, keycode)
122 match c {
123 'a'..='z' => Some((0, KEY_A + (c as u8 - b'a'))),
124 'A'..='Z' => Some((MOD_LEFT_SHIFT, KEY_A + (c as u8 - b'A'))),
125 ' ' => Some((0, KEY_SPACE)),
126 '\n' => Some((0, KEY_ENTER)),
127 _ => None,
128 }
129 }
130}
131
132/// Length in bytes of the input report [`HID_KEYBOARD_REPORT_DESCRIPTOR`] describes.
133///
134/// One modifier byte, one reserved byte, and a six-slot key array: the standard boot
135/// keyboard input report. The startup examples in `actions.rs` size the input report
136/// characteristic's initial value from it.
137pub const HID_KEYBOARD_INPUT_REPORT_LEN: usize = 8;
138
139/// HID Report Descriptor for keyboard.
140///
141/// This is the single source of truth for the report map: `actions.rs` hex-encodes these
142/// bytes into its startup examples rather than carrying a second copy, and
143/// `tests/server/bluetooth_ble_keyboard/report_descriptor_test.rs` walks every item and
144/// asserts the total is `HID_KEYBOARD_INPUT_REPORT_LEN` bytes.
145///
146/// Input-only: there is no LED Output block, so a host cannot drive Caps/Num Lock. That is
147/// a deliberate omission rather than a missing piece — nothing in this profile consumes an
148/// output report — and it is why the descriptor is shorter than the boot-protocol keyboard
149/// descriptor in the HID specification's Appendix B.
150pub const HID_KEYBOARD_REPORT_DESCRIPTOR: &[u8] = &[
151 0x05, 0x01, // Usage Page (Generic Desktop)
152 0x09, 0x06, // Usage (Keyboard)
153 0xA1, 0x01, // Collection (Application)
154 0x05, 0x07, // Usage Page (Key Codes)
155 0x19, 0xE0, // Usage Minimum (224)
156 0x29, 0xE7, // Usage Maximum (231)
157 0x15, 0x00, // Logical Minimum (0)
158 0x25, 0x01, // Logical Maximum (1)
159 0x75, 0x01, // Report Size (1)
160 0x95, 0x08, // Report Count (8)
161 0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
162 0x95, 0x01, // Report Count (1)
163 0x75, 0x08, // Report Size (8)
164 0x81, 0x01, // Input (Constant) - Reserved byte
165 0x95, 0x06, // Report Count (6)
166 0x75, 0x08, // Report Size (8)
167 0x15, 0x00, // Logical Minimum (0)
168 0x25, 0x65, // Logical Maximum (101)
169 0x05, 0x07, // Usage Page (Key Codes)
170 0x19, 0x00, // Usage Minimum (0)
171 0x29, 0x65, // Usage Maximum (101)
172 0x81, 0x00, // Input (Data, Array) - Key array
173 0xC0, // End Collection
174];