netget/server/bluetooth_ble_remote/mod.rs
1//! Bluetooth Low Energy (BLE) Remote Control implementation
2//!
3//! Builds on bluetooth-ble to provide HID Consumer Control functionality.
4//! Acts as a media remote control for TVs, media players, etc.
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 Remote Control server
19pub struct BluetoothBleRemote;
20
21impl BluetoothBleRemote {
22 /// Spawn BLE remote control server
23 #[cfg(all(unix, feature = "bluetooth"))]
24 pub async fn spawn_with_llm_actions(
25 device_name: String,
26 llm_client: OllamaClient,
27 app_state: Arc<AppState>,
28 status_tx: mpsc::UnboundedSender<String>,
29 server_id: crate::state::ServerId,
30 instruction: String,
31 ) -> Result<std::net::SocketAddr> {
32 info!("Starting BLE Remote Control server: {}", device_name);
33
34 // Create the underlying BLE server with HID remote configuration
35 let remote_instruction = format!(
36 "Configure as a BLE HID remote control with HID Service (UUID: 0x1812). {} {}",
37 instruction,
38 "Add HID Report Map, HID Report Input, HID Information, and HID Control Point characteristics for consumer control."
39 );
40
41 // Use the base bluetooth-ble server.
42 //
43 // This profile decides nothing once the radio is up: the base stack owns the event
44 // loop, and every `decision=` a read, write or subscribe produces is emitted there
45 // against `BluetoothBleProtocol`. The one terminal outcome the profile does own is
46 // this — the shared radio would not come up, so no HID service was ever published.
47 let log_tx = status_tx.clone();
48 let log_name = device_name.clone();
49 match BluetoothBle::spawn_with_llm_actions(
50 device_name,
51 llm_client,
52 app_state,
53 status_tx,
54 server_id,
55 remote_instruction,
56 )
57 .await
58 {
59 Ok(addr) => Ok(addr),
60 Err(e) => {
61 console_error!(log_tx, "{}", radio_start_failure(&log_name, &e));
62 Err(e)
63 }
64 }
65 }
66}
67
68/// The single source of the text used when the shared BLE radio could not be brought up.
69///
70/// `pub` so a test can assert the `decision=` tag without claiming a Bluetooth adapter: this is
71/// the profile's only terminal outcome of its own, and the project forbids `#[cfg(test)]`
72/// modules in `src/`. No model is consulted on this path — the radio fails before any event
73/// exists — so the token is deliberately not one of the `fail_closed_llm_*` pair, which would
74/// send someone to restart Ollama over an adapter that is switched off.
75pub fn radio_start_failure(device_name: &str, err: &anyhow::Error) -> String {
76 format!(
77 "BLE HID remote control '{device_name}' could not start \
78 (decision=refused_adapter_unavailable): {err}. No HID service was published and \
79 nothing is advertising."
80 )
81}
82
83#[cfg(any(all(unix, not(feature = "bluetooth")), not(unix)))]
84impl BluetoothBleRemote {
85 pub async fn spawn_with_llm_actions(
86 _device_name: String,
87 _llm_client: OllamaClient,
88 _app_state: Arc<AppState>,
89 _status_tx: mpsc::UnboundedSender<String>,
90 _server_id: crate::state::ServerId,
91 _instruction: String,
92 ) -> Result<std::net::SocketAddr> {
93 anyhow::bail!(
94 "BLE remote support not enabled - compile with --features bluetooth-ble-remote"
95 )
96 }
97}
98
99/// HID Consumer Control usage codes
100pub mod consumer_control {
101 // Media control
102 pub const PLAY_PAUSE: u16 = 0xCD;
103 pub const NEXT_TRACK: u16 = 0xB5;
104 pub const PREVIOUS_TRACK: u16 = 0xB6;
105 pub const STOP: u16 = 0xB7;
106 pub const FAST_FORWARD: u16 = 0xB3;
107 pub const REWIND: u16 = 0xB4;
108
109 // Volume control
110 pub const VOLUME_UP: u16 = 0xE9;
111 pub const VOLUME_DOWN: u16 = 0xEA;
112 pub const MUTE: u16 = 0xE2;
113
114 // Other controls
115 pub const POWER: u16 = 0x30;
116 pub const MENU: u16 = 0x40;
117 pub const HOME: u16 = 0x223;
118}
119
120/// Length in bytes of the input report [`HID_REMOTE_REPORT_DESCRIPTOR`] describes.
121///
122/// 16 one-bit buttons, so exactly two bytes with no padding. `build_remote_report`
123/// returns this many bytes, and the startup examples in `actions.rs` size the input
124/// report characteristic's initial value from it.
125pub const HID_REMOTE_INPUT_REPORT_LEN: usize = 2;
126
127/// HID Report Descriptor for Consumer Control remote.
128///
129/// This is the single source of truth for the report map: `actions.rs` hex-encodes
130/// these bytes into its startup examples rather than carrying a second copy, and
131/// `tests/server/bluetooth_ble_remote/report_descriptor_test.rs` walks every item and
132/// asserts the total is `HID_REMOTE_INPUT_REPORT_LEN` bytes.
133///
134/// Bit order matches [`build_remote_report`] exactly: the *n*th Usage below is bit *n*
135/// of the report.
136pub const HID_REMOTE_REPORT_DESCRIPTOR: &[u8] = &[
137 0x05, 0x0C, // Usage Page (Consumer)
138 0x09, 0x01, // Usage (Consumer Control)
139 0xA1, 0x01, // Collection (Application)
140 0x15, 0x00, // Logical Minimum (0)
141 0x25, 0x01, // Logical Maximum (1)
142 0x75, 0x01, // Report Size (1)
143 0x95, 0x0C, // Report Count (12) - one bit per named control below
144 0x09, 0xCD, // Usage (Play/Pause) -> bit 0
145 0x09, 0xB5, // Usage (Scan Next Track) -> bit 1
146 0x09, 0xB6, // Usage (Scan Previous Track) -> bit 2
147 0x09, 0xB7, // Usage (Stop) -> bit 3
148 0x09, 0xB3, // Usage (Fast Forward) -> bit 4
149 0x09, 0xB4, // Usage (Rewind) -> bit 5
150 0x09, 0xE9, // Usage (Volume Increment) -> bit 6
151 0x09, 0xEA, // Usage (Volume Decrement) -> bit 7
152 0x09, 0xE2, // Usage (Mute) -> bit 8
153 0x09, 0x30, // Usage (Power) -> bit 9
154 0x09, 0x40, // Usage (Menu) -> bit 10
155 // AC Home is 0x0223, a two-byte usage, so it needs the bSize=2 form of the Usage
156 // item (0x0A) and not the one-byte form (0x09). Written as `0x09, 0x23, 0x02` the
157 // trailing 0x02 is not data at all: it parses as a *new* item with bTag=0000 and
158 // bType=Main, which is reserved, and it then swallows the two bytes after it. That
159 // is what this descriptor said until September 2026, and a host walking it would
160 // have rejected the report map outright.
161 0x0A, 0x23, 0x02, // Usage (AC Home) -> bit 11
162 0x81, 0x02, // Input (Data, Variable, Absolute)
163 // Pad bits 12-15 so the report is exactly two whole bytes. Constant padding is the
164 // spec's mechanism for this; the four `Usage (0x00)` items that used to stand here
165 // named the Undefined usage, which is not a padding declaration.
166 0x95, 0x04, // Report Count (4)
167 0x81, 0x03, // Input (Constant, Variable, Absolute) - padding
168 0xC0, // End Collection
169];
170
171/// Build a remote control report from a control name.
172///
173/// The report is [`HID_REMOTE_INPUT_REPORT_LEN`] bytes, little-endian bit order: bit *n*
174/// is the *n*th Usage declared in [`HID_REMOTE_REPORT_DESCRIPTOR`]. Bits 12-15 are the
175/// descriptor's constant padding and are always zero.
176///
177/// Example: Play/Pause pressed is bit 0, so `[0x01, 0x00]`.
178///
179/// Returns `None` for a name this profile does not define. That is deliberate and is the
180/// reason this is not `[u8; 2]`: a report full of zeroes is a *valid* report meaning "no
181/// control is pressed", so returning one for an unrecognised name would turn a caller's
182/// mistake into an affirmative statement on the wire that every button was released. The
183/// caller has to decide what an unknown name means; this function will not decide for it.
184pub fn build_remote_report(button: &str) -> Option<[u8; HID_REMOTE_INPUT_REPORT_LEN]> {
185 let bit_position: u32 = match button {
186 "play_pause" => 0,
187 "next_track" => 1,
188 "previous_track" => 2,
189 "stop" => 3,
190 "fast_forward" => 4,
191 "rewind" => 5,
192 "volume_up" => 6,
193 "volume_down" => 7,
194 "mute" => 8,
195 "power" => 9,
196 "menu" => 10,
197 "home" => 11,
198 _ => return None,
199 };
200
201 let mut report = [0u8; HID_REMOTE_INPUT_REPORT_LEN];
202 report[(bit_position / 8) as usize] = 1u8 << (bit_position % 8);
203 Some(report)
204}