Skip to main content

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}