mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
feat(bluetooth-hid): 支持经典蓝牙 HID 后端
This commit is contained in:
@@ -70,6 +70,7 @@ desktop = [
|
|||||||
"dep:v4l2r",
|
"dep:v4l2r",
|
||||||
"dep:alsa",
|
"dep:alsa",
|
||||||
"dep:gpio-cdev",
|
"dep:gpio-cdev",
|
||||||
|
"dep:one-kvm-bluetooth-hid",
|
||||||
"dep:cpal",
|
"dep:cpal",
|
||||||
"dep:windows-sys",
|
"dep:windows-sys",
|
||||||
]
|
]
|
||||||
@@ -179,6 +180,8 @@ turbojpeg = { version = "1.3", optional = true }
|
|||||||
audiopus = { version = "0.2", optional = true }
|
audiopus = { version = "0.2", optional = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
one-kvm-bluetooth-hid = { path = "libs/bluetooth-hid", optional = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
|
nix = { version = "0.31", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
|
||||||
|
bluez \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ RUN sed -i 's/ main$/ main contrib non-free/' /etc/apt/sources.list && \
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
# Core runtime (all platforms) - no codec libs needed
|
# Core runtime (all platforms) - no codec libs needed
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
# Bluetooth HID uses the host BlueZ system bus; btmgmt manages a dedicated adapter.
|
||||||
|
bluez \
|
||||||
libudev1 \
|
libudev1 \
|
||||||
libasound2 \
|
libasound2 \
|
||||||
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
# OTG Ethernet bridge control (nmcli talks to the host NetworkManager over D-Bus)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Maintainer: SilentWind <admin@mofeng.run>
|
|||||||
Package: one-kvm
|
Package: one-kvm
|
||||||
Architecture: {arch}
|
Architecture: {arch}
|
||||||
Depends: ${{auto}}, ca-certificates{distsuffix}
|
Depends: ${{auto}}, ca-certificates{distsuffix}
|
||||||
|
Recommends: bluez
|
||||||
Description: A open and lightweight IP-KVM solution written in Rust
|
Description: A open and lightweight IP-KVM solution written in Rust
|
||||||
Enables BIOS-level remote management of servers and workstations.
|
Enables BIOS-level remote management of servers and workstations.
|
||||||
.
|
.
|
||||||
@@ -17,6 +18,7 @@ Description: A open and lightweight IP-KVM solution written in Rust
|
|||||||
* Hardware-accelerated video encoding (VAAPI, QSV, RKMPP)
|
* Hardware-accelerated video encoding (VAAPI, QSV, RKMPP)
|
||||||
* WebRTC and MJPEG streaming with low latency
|
* WebRTC and MJPEG streaming with low latency
|
||||||
* USB HID emulation via OTG gadget
|
* USB HID emulation via OTG gadget
|
||||||
|
* Classic Bluetooth keyboard and mouse via BlueZ
|
||||||
* Mass storage device for ISO/IMG mounting
|
* Mass storage device for ISO/IMG mounting
|
||||||
* ATX power control via GPIO or USB relay
|
* ATX power control via GPIO or USB relay
|
||||||
Homepage: https://github.com/mofeng-git/One-KVM
|
Homepage: https://github.com/mofeng-git/One-KVM
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=One-KVM IP-KVM Service
|
Description=One-KVM IP-KVM Service
|
||||||
Documentation=https://github.com/mofeng-git/One-KVM
|
Documentation=https://github.com/mofeng-git/One-KVM
|
||||||
After=network-online.target
|
After=network-online.target bluetooth.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ Section: admin
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: $DEB_ARCH
|
Architecture: $DEB_ARCH
|
||||||
Depends: $DEPS
|
Depends: $DEPS
|
||||||
|
Recommends: bluez
|
||||||
Maintainer: SilentWind <admin@mofeng.run>
|
Maintainer: SilentWind <admin@mofeng.run>
|
||||||
Description: A open and lightweight IP-KVM solution
|
Description: A open and lightweight IP-KVM solution
|
||||||
Enables BIOS-level remote management of servers and workstations.
|
Enables BIOS-level remote management of servers and workstations.
|
||||||
|
|||||||
16
libs/bluetooth-hid/Cargo.toml
Normal file
16
libs/bluetooth-hid/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "one-kvm-bluetooth-hid"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "GPL-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"] }
|
||||||
|
dbus = { version = "0.9", features = ["vendored"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
dbus-crossroads = "0.5"
|
||||||
|
dbus-tokio = "0.7"
|
||||||
|
libc = "0.2"
|
||||||
|
tracing = "0.1"
|
||||||
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal file
49
libs/bluetooth-hid/examples/pairing_probe.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! Explicit hardware regression: opens a 10-second pairing window without
|
||||||
|
//! removing existing bonds or sending input. Stop the production HID first.
|
||||||
|
use one_kvm_bluetooth_hid::{Action, Config, Peripheral};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), String> {
|
||||||
|
let peripheral = Peripheral::start(Config {
|
||||||
|
adapter: "hci0".into(),
|
||||||
|
name: "One-KVM HID".into(),
|
||||||
|
peer: None,
|
||||||
|
})?;
|
||||||
|
let result = async {
|
||||||
|
for _ in 0..50 {
|
||||||
|
if peripheral.status().initialized {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
let before = peripheral.status();
|
||||||
|
if !before.initialized {
|
||||||
|
return Err(format!("Initialization failed: {:?}", before.error));
|
||||||
|
}
|
||||||
|
if !before
|
||||||
|
.devices
|
||||||
|
.iter()
|
||||||
|
.any(|d| Some(&d.address) == before.peer.as_ref() && d.paired)
|
||||||
|
{
|
||||||
|
return Err("This regression needs an existing bonded computer".into());
|
||||||
|
}
|
||||||
|
peripheral.action(Action::Pair(10)).await?;
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
let open = peripheral.status();
|
||||||
|
println!("open: {}", serde_json::to_string(&open).unwrap());
|
||||||
|
if open.pairing_seconds == 0 {
|
||||||
|
return Err("Existing bond prematurely closed pairing".into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
|
let expired = peripheral.status();
|
||||||
|
println!("expired: {}", serde_json::to_string(&expired).unwrap());
|
||||||
|
if expired.pairing_seconds != 0 || expired.peer != before.peer {
|
||||||
|
return Err("Window did not expire while preserving the bonded peer".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let cleanup = peripheral.shutdown().await;
|
||||||
|
result.and(cleanup)
|
||||||
|
}
|
||||||
24
libs/bluetooth-hid/examples/probe.rs
Normal file
24
libs/bluetooth-hid/examples/probe.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! Native hardware smoke check. Registers for 12 seconds, sends no input,
|
||||||
|
//! opens no pairing window, then restores the adapter configuration.
|
||||||
|
use one_kvm_bluetooth_hid::{Config, Peripheral};
|
||||||
|
use std::time::Duration;
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), String> {
|
||||||
|
let peripheral = Peripheral::start(Config {
|
||||||
|
adapter: "hci0".into(),
|
||||||
|
name: "One-KVM HID".into(),
|
||||||
|
peer: None,
|
||||||
|
})?;
|
||||||
|
let mut initialized = false;
|
||||||
|
for _ in 0..12 {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
let status = peripheral.status();
|
||||||
|
initialized |= status.initialized;
|
||||||
|
println!("{}", serde_json::to_string(&status).unwrap());
|
||||||
|
}
|
||||||
|
peripheral.shutdown().await?;
|
||||||
|
if !initialized {
|
||||||
|
return Err("Native BlueZ peripheral never initialized".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
236
libs/bluetooth-hid/src/agent.rs
Normal file
236
libs/bluetooth-hid/src/agent.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use crate::Shared;
|
||||||
|
use dbus::{channel::MatchingReceiver, message::MatchRule, nonblock::Proxy, Path};
|
||||||
|
use dbus_crossroads::Crossroads;
|
||||||
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PATH: &str = "/org/onekvm/bluetooth/agent";
|
||||||
|
pub struct Agent {
|
||||||
|
connection: Arc<dbus::nonblock::SyncConnection>,
|
||||||
|
task: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
impl Agent {
|
||||||
|
pub async fn register(shared: Arc<Shared>, adapter: String) -> Result<Self, String> {
|
||||||
|
let (resource, connection) =
|
||||||
|
dbus_tokio::connection::new_system_sync().map_err(|e| e.to_string())?;
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let _ = resource.await;
|
||||||
|
});
|
||||||
|
let mut cr = Crossroads::new();
|
||||||
|
let iface = cr.register("org.bluez.Agent1", |b| {
|
||||||
|
b.method("Release", (), (), |_, _: &mut (), ()| Ok(()));
|
||||||
|
b.method("Cancel", (), (), |_, _: &mut (), ()| Ok(()));
|
||||||
|
let state = shared.clone();
|
||||||
|
let name = adapter.clone();
|
||||||
|
b.method(
|
||||||
|
"RequestAuthorization",
|
||||||
|
("device",),
|
||||||
|
(),
|
||||||
|
move |_, _: &mut (), (path,): (Path<'static>,)| {
|
||||||
|
authorize(&state, &name, &path, true)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let state = shared.clone();
|
||||||
|
let name = adapter.clone();
|
||||||
|
b.method(
|
||||||
|
"RequestConfirmation",
|
||||||
|
("device", "passkey"),
|
||||||
|
(),
|
||||||
|
move |_, _: &mut (), (path, _): (Path<'static>, u32)| {
|
||||||
|
authorize(&state, &name, &path, true)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
b.method(
|
||||||
|
"AuthorizeService",
|
||||||
|
("device", "uuid"),
|
||||||
|
(),
|
||||||
|
move |_, _: &mut (), (path, uuid): (Path<'static>, String)| {
|
||||||
|
if !matches!(uuid.as_str(), "00001124-0000-1000-8000-00805f9b34fb") {
|
||||||
|
return Err(dbus::MethodErr::from((
|
||||||
|
"org.bluez.Error.Rejected",
|
||||||
|
"Not an HID service",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
authorize(&shared, &adapter, &path, false)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cr.insert(PATH, &[iface], ());
|
||||||
|
let profile = cr.register("org.bluez.Profile1", |b| {
|
||||||
|
b.method("Release", (), (), |_, _: &mut (), ()| Ok(()));
|
||||||
|
b.method(
|
||||||
|
"RequestDisconnection",
|
||||||
|
("device",),
|
||||||
|
(),
|
||||||
|
|_, _: &mut (), (_device,): (Path<'static>,)| Ok(()),
|
||||||
|
);
|
||||||
|
b.method(
|
||||||
|
"NewConnection",
|
||||||
|
("device", "fd", "properties"),
|
||||||
|
(),
|
||||||
|
|_,
|
||||||
|
_: &mut (),
|
||||||
|
(_device, _fd, _props): (
|
||||||
|
Path<'static>,
|
||||||
|
dbus::arg::OwnedFd,
|
||||||
|
dbus::arg::PropMap,
|
||||||
|
)| {
|
||||||
|
// This profile only publishes SDP. Our L2CAP sockets own both HID channels.
|
||||||
|
Err::<(), _>(dbus::MethodErr::from((
|
||||||
|
"org.bluez.Error.Rejected",
|
||||||
|
"Unexpected profile connection",
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cr.insert("/org/onekvm/bluetooth/profile", &[profile], ());
|
||||||
|
connection.start_receive(
|
||||||
|
MatchRule::new_method_call(),
|
||||||
|
Box::new(move |msg, conn| {
|
||||||
|
let _ = cr.handle_message(msg, conn);
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let agent = Self { connection, task };
|
||||||
|
let proxy = Proxy::new(
|
||||||
|
"org.bluez",
|
||||||
|
"/org/bluez",
|
||||||
|
Duration::from_secs(5),
|
||||||
|
agent.connection.clone(),
|
||||||
|
);
|
||||||
|
let result: Result<(), dbus::Error> = proxy
|
||||||
|
.method_call(
|
||||||
|
"org.bluez.AgentManager1",
|
||||||
|
"RegisterAgent",
|
||||||
|
(Path::from(PATH), "NoInputNoOutput"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
return Err(error.to_string());
|
||||||
|
}
|
||||||
|
let result: Result<(), dbus::Error> = proxy
|
||||||
|
.method_call(
|
||||||
|
"org.bluez.AgentManager1",
|
||||||
|
"RequestDefaultAgent",
|
||||||
|
(Path::from(PATH),),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
result.map_err(|e| e.to_string())?;
|
||||||
|
let mut options: dbus::arg::PropMap = std::collections::HashMap::new();
|
||||||
|
options.insert(
|
||||||
|
"ServiceRecord".into(),
|
||||||
|
dbus::arg::Variant(Box::new(crate::protocol::sdp())),
|
||||||
|
);
|
||||||
|
options.insert(
|
||||||
|
"Role".into(),
|
||||||
|
dbus::arg::Variant(Box::new("server".to_string())),
|
||||||
|
);
|
||||||
|
options.insert(
|
||||||
|
"RequireAuthentication".into(),
|
||||||
|
dbus::arg::Variant(Box::new(true)),
|
||||||
|
);
|
||||||
|
options.insert(
|
||||||
|
"RequireAuthorization".into(),
|
||||||
|
dbus::arg::Variant(Box::new(false)),
|
||||||
|
);
|
||||||
|
let result: Result<(), dbus::Error> = proxy
|
||||||
|
.method_call(
|
||||||
|
"org.bluez.ProfileManager1",
|
||||||
|
"RegisterProfile",
|
||||||
|
(
|
||||||
|
Path::from("/org/onekvm/bluetooth/profile"),
|
||||||
|
crate::protocol::HID_UUID,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
result.map_err(|e| format!("Register HID SDP: {e}"))?;
|
||||||
|
Ok(agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Agent {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn authorize(
|
||||||
|
shared: &Shared,
|
||||||
|
adapter: &str,
|
||||||
|
path: &str,
|
||||||
|
pairing: bool,
|
||||||
|
) -> Result<(), dbus::MethodErr> {
|
||||||
|
let rejected = || {
|
||||||
|
dbus::MethodErr::from((
|
||||||
|
"org.bluez.Error.Rejected",
|
||||||
|
"Open pairing in One-KVM or select the paired computer",
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let prefix = format!("/org/bluez/{adapter}/dev_");
|
||||||
|
let address = path
|
||||||
|
.strip_prefix(&prefix)
|
||||||
|
.ok_or_else(rejected)?
|
||||||
|
.replace('_', ":");
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
if pairing
|
||||||
|
&& !state
|
||||||
|
.pairing_until
|
||||||
|
.is_some_and(|until| until > Instant::now())
|
||||||
|
{
|
||||||
|
return Err(rejected());
|
||||||
|
}
|
||||||
|
if state.peer.as_ref().is_some_and(|peer| peer != &address) {
|
||||||
|
return Err(rejected());
|
||||||
|
}
|
||||||
|
if state.peer.is_none() {
|
||||||
|
if !pairing || state.bonded_before_pairing.contains(&address) {
|
||||||
|
return Err(rejected());
|
||||||
|
}
|
||||||
|
state.peer = Some(address);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn existing_unrelated_bond_cannot_be_adopted_by_agent_callback() {
|
||||||
|
let shared = Shared::new(None);
|
||||||
|
{
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
|
||||||
|
state
|
||||||
|
.bonded_before_pairing
|
||||||
|
.insert("10:6F:D9:66:97:88".into());
|
||||||
|
}
|
||||||
|
assert!(authorize(
|
||||||
|
&shared,
|
||||||
|
"hci0",
|
||||||
|
"/org/bluez/hci0/dev_10_6F_D9_66_97_88",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(shared.state.lock().unwrap().peer.is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn only_pair_during_window_and_pin_first_peer() {
|
||||||
|
let state = Shared::new(None);
|
||||||
|
let first = "/org/bluez/hci0/dev_10_6F_D9_66_97_88";
|
||||||
|
assert!(authorize(&state, "hci0", first, true).is_err());
|
||||||
|
state.state.lock().unwrap().pairing_until = Some(Instant::now() + Duration::from_secs(60));
|
||||||
|
assert!(authorize(&state, "hci0", first, true).is_ok());
|
||||||
|
assert!(authorize(
|
||||||
|
&state,
|
||||||
|
"hci0",
|
||||||
|
"/org/bluez/hci0/dev_10_6F_D9_66_97_89",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(authorize(&state, "hci1", first, true).is_err());
|
||||||
|
state.state.lock().unwrap().pairing_until = None;
|
||||||
|
assert!(authorize(&state, "hci0", first, false).is_ok());
|
||||||
|
assert!(authorize(&state, "hci0", first, true).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
145
libs/bluetooth-hid/src/bonds.rs
Normal file
145
libs/bluetooth-hid/src/bonds.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
//! Only bonds explicitly owned by this HID peripheral may be removed.
|
||||||
|
use bluer::{Adapter, Session};
|
||||||
|
use std::{future::Future, pin::Pin};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Bond {
|
||||||
|
pub adapter: String,
|
||||||
|
pub peer: String,
|
||||||
|
pub pending: bool,
|
||||||
|
}
|
||||||
|
pub type Operation<'a, T> = Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'a>>;
|
||||||
|
pub trait BondStore: Send + Sync {
|
||||||
|
fn list(&self) -> Operation<'_, Vec<Bond>>;
|
||||||
|
fn save(&self, bond: Bond) -> Operation<'_, ()>;
|
||||||
|
fn remove(&self, bond: Bond) -> Operation<'_, ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clean_adapter(store: &dyn BondStore, adapter: &Adapter) -> Result<(), String> {
|
||||||
|
let address = adapter
|
||||||
|
.address()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string();
|
||||||
|
clean_owned(store, &address, |peer| async move {
|
||||||
|
let peer = peer.parse().map_err(|_| "Invalid stored HID peer")?;
|
||||||
|
if adapter
|
||||||
|
.device_addresses()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.contains(&peer)
|
||||||
|
{
|
||||||
|
adapter
|
||||||
|
.remove_device(peer)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clean_owned<F, Fut>(
|
||||||
|
store: &dyn BondStore,
|
||||||
|
address: &str,
|
||||||
|
mut remove: F,
|
||||||
|
) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: FnMut(String) -> Fut,
|
||||||
|
Fut: Future<Output = Result<(), String>>,
|
||||||
|
{
|
||||||
|
for bond in store
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|b| b.pending && b.adapter == address)
|
||||||
|
{
|
||||||
|
remove(bond.peer.clone()).await?;
|
||||||
|
// Keep the tombstone until BlueZ confirms removal; retry safely after interruption.
|
||||||
|
store.remove(bond).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset(store: &dyn BondStore, explicit: Vec<Bond>) -> Result<(), String> {
|
||||||
|
let session = Session::new().await.map_err(|e| e.to_string())?;
|
||||||
|
let mut owned = store.list().await?;
|
||||||
|
owned.extend(explicit);
|
||||||
|
for mut bond in owned {
|
||||||
|
bond.pending = true;
|
||||||
|
store.save(bond).await?;
|
||||||
|
}
|
||||||
|
for name in session.adapter_names().await.map_err(|e| e.to_string())? {
|
||||||
|
clean_adapter(store, &session.adapter(&name).map_err(|e| e.to_string())?).await?;
|
||||||
|
}
|
||||||
|
// Records for absent adapters remain pending and are cleaned before their next use.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Memory(Mutex<Vec<Bond>>);
|
||||||
|
impl BondStore for Memory {
|
||||||
|
fn list(&self) -> Operation<'_, Vec<Bond>> {
|
||||||
|
Box::pin(async { Ok(self.0.lock().unwrap().clone()) })
|
||||||
|
}
|
||||||
|
fn save(&self, bond: Bond) -> Operation<'_, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let mut rows = self.0.lock().unwrap();
|
||||||
|
rows.retain(|b| b.adapter != bond.adapter || b.peer != bond.peer);
|
||||||
|
rows.push(bond);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn remove(&self, bond: Bond) -> Operation<'_, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.0.lock().unwrap().retain(|b| b != &bond);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bond(adapter: &str, peer: &str, pending: bool) -> Bond {
|
||||||
|
Bond {
|
||||||
|
adapter: adapter.into(),
|
||||||
|
peer: peer.into(),
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cleanup_only_removes_owned_pending_bonds_on_the_selected_hardware() {
|
||||||
|
let current = bond("adapter-A", "host-1", true);
|
||||||
|
let absent = bond("adapter-B", "host-2", true);
|
||||||
|
let retained = bond("adapter-A", "host-3", false);
|
||||||
|
let store = Memory(Mutex::new(vec![current, absent.clone(), retained.clone()]));
|
||||||
|
let removed = Mutex::new(Vec::new());
|
||||||
|
clean_owned(&store, "adapter-A", |peer| {
|
||||||
|
removed.lock().unwrap().push(peer);
|
||||||
|
async { Ok(()) }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(*removed.lock().unwrap(), vec!["host-1"]);
|
||||||
|
assert_eq!(store.list().await.unwrap(), vec![absent, retained]);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cleanup_failure_keeps_tombstone_and_is_reported() {
|
||||||
|
let record = bond("adapter-A", "host-1", true);
|
||||||
|
let store = Memory(Mutex::new(vec![record.clone()]));
|
||||||
|
assert_eq!(
|
||||||
|
clean_owned(&store, "adapter-A", |_| async {
|
||||||
|
Err("BlueZ denied removal".into())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
"BlueZ denied removal"
|
||||||
|
);
|
||||||
|
assert_eq!(store.list().await.unwrap(), vec![record]);
|
||||||
|
clean_owned(&store, "adapter-A", |_| async { Ok(()) })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.list().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
135
libs/bluetooth-hid/src/controller.rs
Normal file
135
libs/bluetooth-hid/src/controller.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Exclusive controller ownership. No BLE switching or power cycling.
|
||||||
|
use std::{
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
|
||||||
|
process::Stdio,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::process::Command;
|
||||||
|
pub struct Controller {
|
||||||
|
_lock: File,
|
||||||
|
adapter: String,
|
||||||
|
old_class: (u8, u8),
|
||||||
|
old_connectable: bool,
|
||||||
|
}
|
||||||
|
async fn run_once(adapter: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let mut cmd = Command::new("btmgmt");
|
||||||
|
cmd.arg("--index")
|
||||||
|
.arg(adapter.trim_start_matches("hci"))
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Install bluez/btmgmt: {e}"))?;
|
||||||
|
// BlueZ 5.55's btmgmt exits on stdin EOF, even with a command argument.
|
||||||
|
// Keep a pipe open until it finishes; systemd normally supplies /dev/null.
|
||||||
|
let stdin = child.stdin.take();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "btmgmt timeout")?
|
||||||
|
.map_err(|e| format!("Install bluez/btmgmt: {e}"))?;
|
||||||
|
drop(stdin);
|
||||||
|
let text = String::from_utf8_lossy(&result.stdout).into_owned();
|
||||||
|
if !result.status.success()
|
||||||
|
|| !result.stderr.is_empty()
|
||||||
|
|| text.to_lowercase().contains("failed")
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"btmgmt {}: {text} {}",
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&result.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
async fn run(adapter: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
// BlueZ updates the EIR/class asynchronously after registering SDP.
|
||||||
|
// Kernel management rejects a simultaneous class change with Busy.
|
||||||
|
for attempt in 0..5 {
|
||||||
|
match run_once(adapter, args).await {
|
||||||
|
Err(error) if error.contains("(Busy)") && attempt < 4 => {
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
result => return result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
impl Controller {
|
||||||
|
pub async fn acquire(adapter: &str) -> Result<Self, String> {
|
||||||
|
let lock = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.mode(0o600)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW)
|
||||||
|
.open(format!("/run/one-kvm-bluetooth-{adapter}.lock"))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
|
||||||
|
return Err("Bluetooth adapter already owned by One-KVM".into());
|
||||||
|
}
|
||||||
|
let info = run(adapter, &["info"]).await?;
|
||||||
|
let settings = info
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.trim().strip_prefix("current settings:"))
|
||||||
|
.ok_or("Cannot read Bluetooth settings")?;
|
||||||
|
if !settings.split_whitespace().any(|s| s == "br/edr") {
|
||||||
|
return Err(
|
||||||
|
"Classic Bluetooth disabled; enable BR/EDR with btmgmt before using HID".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let class = info
|
||||||
|
.split_whitespace()
|
||||||
|
.skip_while(|s| *s != "class")
|
||||||
|
.nth(1)
|
||||||
|
.ok_or("Missing Bluetooth class")?;
|
||||||
|
let class =
|
||||||
|
u32::from_str_radix(class.trim_start_matches("0x"), 16).map_err(|e| e.to_string())?;
|
||||||
|
Ok(Self {
|
||||||
|
_lock: lock,
|
||||||
|
adapter: adapter.into(),
|
||||||
|
old_class: (((class >> 8) & 0x1f) as u8, (class & 0xfc) as u8),
|
||||||
|
old_connectable: settings.split_whitespace().any(|s| s == "connectable"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub async fn configure(&self) -> Result<(), String> {
|
||||||
|
run(&self.adapter, &["class", "5", "192"]).await?;
|
||||||
|
run(&self.adapter, &["connectable", "on"]).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub async fn restore(&self) -> Result<(), String> {
|
||||||
|
let mut errors = vec![];
|
||||||
|
if let Err(e) = run(
|
||||||
|
&self.adapter,
|
||||||
|
&[
|
||||||
|
"class",
|
||||||
|
&self.old_class.0.to_string(),
|
||||||
|
&self.old_class.1.to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
if let Err(e) = run(
|
||||||
|
&self.adapter,
|
||||||
|
&[
|
||||||
|
"connectable",
|
||||||
|
if self.old_connectable { "on" } else { "off" },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors.join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
964
libs/bluetooth-hid/src/lib.rs
Normal file
964
libs/bluetooth-hid/src/lib.rs
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
//! Classic Bluetooth HID peripheral: SDP + L2CAP. Linux/BlueZ only.
|
||||||
|
mod agent;
|
||||||
|
pub mod bonds;
|
||||||
|
mod controller;
|
||||||
|
mod protocol;
|
||||||
|
|
||||||
|
use bluer::{
|
||||||
|
l2cap::{Security, SecurityLevel, SeqPacket, SeqPacketListener, Socket, SocketAddr},
|
||||||
|
Adapter, Address, AddressType, Session,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tokio::sync::{mpsc, oneshot, watch, Mutex as AsyncMutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub adapter: String,
|
||||||
|
pub name: String,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
}
|
||||||
|
impl Config {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if !self
|
||||||
|
.adapter
|
||||||
|
.strip_prefix("hci")
|
||||||
|
.is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit()))
|
||||||
|
{
|
||||||
|
return Err("Bluetooth adapter must be hci followed by an index".into());
|
||||||
|
}
|
||||||
|
if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) {
|
||||||
|
return Err(
|
||||||
|
"Bluetooth name must contain 1–64 UTF-8 bytes without control characters".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(peer) = &self.peer {
|
||||||
|
peer.parse::<Address>()
|
||||||
|
.map_err(|_| "Invalid Bluetooth peer address")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub initialized: bool,
|
||||||
|
pub connected: bool,
|
||||||
|
pub ready: bool,
|
||||||
|
pub adapter: String,
|
||||||
|
pub adapter_address: String,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
pub pairing_seconds: u32,
|
||||||
|
pub control_connected: bool,
|
||||||
|
pub interrupt_connected: bool,
|
||||||
|
pub leds: u8,
|
||||||
|
pub generation: u64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub devices: Vec<Device>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Device {
|
||||||
|
pub address: String,
|
||||||
|
pub name: String,
|
||||||
|
pub paired: bool,
|
||||||
|
pub connected: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Report {
|
||||||
|
Keyboard,
|
||||||
|
Mouse,
|
||||||
|
Consumer,
|
||||||
|
}
|
||||||
|
impl Report {
|
||||||
|
fn len(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Keyboard => 8,
|
||||||
|
Self::Mouse => 4,
|
||||||
|
Self::Consumer => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn id(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Keyboard => 1,
|
||||||
|
Self::Mouse => 2,
|
||||||
|
Self::Consumer => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct State {
|
||||||
|
peer: Option<String>,
|
||||||
|
pairing_until: Option<Instant>,
|
||||||
|
bonded_before_pairing: HashSet<String>,
|
||||||
|
}
|
||||||
|
impl State {
|
||||||
|
fn observe_bond(&mut self, address: &str, paired: bool) {
|
||||||
|
// Just Works may complete without an Agent1 authorization callback.
|
||||||
|
// Adopt only a new bond from our explicit window, respecting a pinned peer.
|
||||||
|
if paired
|
||||||
|
&& self.peer.is_none()
|
||||||
|
&& self
|
||||||
|
.pairing_until
|
||||||
|
.is_some_and(|until| until > Instant::now())
|
||||||
|
&& !self.bonded_before_pairing.contains(address)
|
||||||
|
{
|
||||||
|
self.peer = Some(address.to_owned());
|
||||||
|
tracing::info!(
|
||||||
|
peer = address,
|
||||||
|
"Bluetooth HID selected newly bonded computer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn close_pairing(&mut self, paired: bool, fallback: Option<String>) {
|
||||||
|
self.pairing_until = None;
|
||||||
|
if !paired {
|
||||||
|
self.peer = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn pairing_completed(&self, selected_paired: bool) -> bool {
|
||||||
|
selected_paired
|
||||||
|
&& self
|
||||||
|
.peer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|peer| !self.bonded_before_pairing.contains(peer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Shared {
|
||||||
|
state: Mutex<State>,
|
||||||
|
}
|
||||||
|
impl Shared {
|
||||||
|
fn new(peer: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Mutex::new(State {
|
||||||
|
peer,
|
||||||
|
pairing_until: None,
|
||||||
|
bonded_before_pairing: HashSet::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn allowed(&self, address: Address) -> bool {
|
||||||
|
self.state.lock().unwrap().peer.as_deref() == Some(address.to_string().as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub enum Action {
|
||||||
|
Pair(u32),
|
||||||
|
ClosePairing,
|
||||||
|
Forget,
|
||||||
|
Disconnect,
|
||||||
|
Reset,
|
||||||
|
}
|
||||||
|
enum Command {
|
||||||
|
Report(Report, Vec<u8>),
|
||||||
|
Action(Action),
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
struct Request {
|
||||||
|
command: Command,
|
||||||
|
result: oneshot::Sender<Result<(), String>>,
|
||||||
|
expires: Instant,
|
||||||
|
}
|
||||||
|
pub struct Peripheral {
|
||||||
|
tx: mpsc::Sender<Request>,
|
||||||
|
status: watch::Receiver<Status>,
|
||||||
|
task: AsyncMutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
impl Peripheral {
|
||||||
|
pub fn start(config: Config) -> Result<Self, String> {
|
||||||
|
Self::start_with_store(config, None)
|
||||||
|
}
|
||||||
|
pub fn start_with_store(
|
||||||
|
config: Config,
|
||||||
|
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
config.validate()?;
|
||||||
|
let (tx, rx) = mpsc::channel(64);
|
||||||
|
let (state_tx, status) = watch::channel(Status {
|
||||||
|
adapter: config.adapter.clone(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(supervise(config, bonds, rx, state_tx));
|
||||||
|
Ok(Self {
|
||||||
|
tx,
|
||||||
|
status,
|
||||||
|
task: AsyncMutex::new(Some(task)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn status(&self) -> Status {
|
||||||
|
self.status.borrow().clone()
|
||||||
|
}
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<Status> {
|
||||||
|
self.status.clone()
|
||||||
|
}
|
||||||
|
async fn request(&self, command: Command, duration: Duration) -> Result<(), String> {
|
||||||
|
let (result, rx) = oneshot::channel();
|
||||||
|
let request = Request {
|
||||||
|
command,
|
||||||
|
result,
|
||||||
|
expires: Instant::now() + duration,
|
||||||
|
};
|
||||||
|
self.tx
|
||||||
|
.try_send(request)
|
||||||
|
.map_err(|_| "Bluetooth input queue unavailable or full".to_string())?;
|
||||||
|
tokio::time::timeout(duration, rx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Bluetooth operation timed out".to_string())?
|
||||||
|
.map_err(|_| "Bluetooth worker stopped".to_string())?
|
||||||
|
}
|
||||||
|
pub async fn send(&self, report: Report, value: Vec<u8>) -> Result<(), String> {
|
||||||
|
if value.len() != report.len() {
|
||||||
|
return Err("Invalid HID report size".into());
|
||||||
|
}
|
||||||
|
self.request(Command::Report(report, value), Duration::from_millis(500))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
pub async fn action(&self, action: Action) -> Result<(), String> {
|
||||||
|
self.request(Command::Action(action), Duration::from_secs(15))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
pub async fn shutdown(&self) -> Result<(), String> {
|
||||||
|
if let Some(task) = self.task.lock().await.take() {
|
||||||
|
let result = self.request(Command::Stop, Duration::from_secs(45)).await;
|
||||||
|
// Do not abort: the worker must restore the radio before a backend switch.
|
||||||
|
task.await.map_err(|e| e.to_string())?;
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen(address: Address, psm: u16) -> Result<SeqPacketListener, String> {
|
||||||
|
let socket = Socket::<SeqPacket>::new_seq_packet().map_err(|e| e.to_string())?;
|
||||||
|
socket
|
||||||
|
.set_security(Security {
|
||||||
|
level: SecurityLevel::Medium,
|
||||||
|
key_size: 0,
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
socket.bind(SocketAddr::new(address,AddressType::BrEdr,psm))
|
||||||
|
.map_err(|e|format!("Cannot bind classic HID PSM 0x{psm:02x}: {e}. Disable BlueZ input plugin; see Bluetooth HID setup."))?;
|
||||||
|
socket.listen(2).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
struct Runtime {
|
||||||
|
adapter: Adapter,
|
||||||
|
_session: Session,
|
||||||
|
shared: Arc<Shared>,
|
||||||
|
agent: Option<agent::Agent>,
|
||||||
|
controller: controller::Controller,
|
||||||
|
listeners: [SeqPacketListener; 2],
|
||||||
|
channels: [Option<Arc<SeqPacket>>; 2],
|
||||||
|
channel_peer: Option<Address>,
|
||||||
|
partial_since: Option<Instant>,
|
||||||
|
hid: protocol::HidProtocol,
|
||||||
|
generation: u64,
|
||||||
|
old_pairable: bool,
|
||||||
|
old_powered: bool,
|
||||||
|
old_alias: String,
|
||||||
|
old_discoverable: bool,
|
||||||
|
old_discoverable_timeout: u32,
|
||||||
|
old_pairable_timeout: u32,
|
||||||
|
config: Config,
|
||||||
|
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||||
|
recorded_peer: Option<String>,
|
||||||
|
}
|
||||||
|
impl Runtime {
|
||||||
|
async fn open(
|
||||||
|
config: &Config,
|
||||||
|
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let controller = controller::Controller::acquire(&config.adapter).await?;
|
||||||
|
let session = Session::new().await.map_err(|e| e.to_string())?;
|
||||||
|
let adapter = session
|
||||||
|
.adapter(&config.adapter)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let address = adapter.address().await.map_err(|e| e.to_string())?;
|
||||||
|
let mut config = config.clone();
|
||||||
|
if let Some(store) = &bonds {
|
||||||
|
bonds::clean_adapter(store.as_ref(), &adapter).await?;
|
||||||
|
if config.peer.is_none() {
|
||||||
|
config.peer = store
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|bond| bond.adapter == address.to_string() && !bond.pending)
|
||||||
|
.map(|bond| bond.peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Acquire BOTH channels before changing adapter state. Conflict is a startup error.
|
||||||
|
let listeners = [listen(address, 0x11)?, listen(address, 0x13)?];
|
||||||
|
let old_powered = adapter.is_powered().await.map_err(|e| e.to_string())?;
|
||||||
|
let old_pairable = adapter.is_pairable().await.map_err(|e| e.to_string())?;
|
||||||
|
let old_alias = adapter.alias().await.map_err(|e| e.to_string())?;
|
||||||
|
let old_discoverable = adapter.is_discoverable().await.map_err(|e| e.to_string())?;
|
||||||
|
let old_discoverable_timeout = adapter
|
||||||
|
.discoverable_timeout()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let old_pairable_timeout = adapter
|
||||||
|
.pairable_timeout()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let shared = Arc::new(Shared::new(
|
||||||
|
config.peer.as_ref().map(|p| p.to_ascii_uppercase()),
|
||||||
|
));
|
||||||
|
let mut runtime = Self {
|
||||||
|
adapter,
|
||||||
|
_session: session,
|
||||||
|
shared,
|
||||||
|
agent: None,
|
||||||
|
controller,
|
||||||
|
listeners,
|
||||||
|
channels: [None, None],
|
||||||
|
channel_peer: None,
|
||||||
|
partial_since: None,
|
||||||
|
hid: Default::default(),
|
||||||
|
generation: 0,
|
||||||
|
old_pairable,
|
||||||
|
old_powered,
|
||||||
|
old_alias,
|
||||||
|
old_discoverable,
|
||||||
|
old_discoverable_timeout,
|
||||||
|
old_pairable_timeout,
|
||||||
|
config: config.clone(),
|
||||||
|
bonds,
|
||||||
|
recorded_peer: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = runtime.setup().await {
|
||||||
|
let restore = runtime.close().await;
|
||||||
|
return Err(format!("{e}; cleanup: {restore:?}"));
|
||||||
|
}
|
||||||
|
Ok(runtime)
|
||||||
|
}
|
||||||
|
async fn setup(&mut self) -> Result<(), String> {
|
||||||
|
self.adapter
|
||||||
|
.set_powered(true)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_discoverable(false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_pairable(false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_alias(self.config.name.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.agent =
|
||||||
|
Some(agent::Agent::register(self.shared.clone(), self.config.adapter.clone()).await?);
|
||||||
|
self.controller.configure().await?;
|
||||||
|
self.public(None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn public(&self, seconds: Option<u32>) -> Result<(), String> {
|
||||||
|
if let Some(seconds) = seconds {
|
||||||
|
self.adapter
|
||||||
|
.set_pairable_timeout(seconds)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_discoverable_timeout(seconds)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_pairable(true)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Err(e) = self.adapter.set_discoverable(true).await {
|
||||||
|
let _ = self.adapter.set_pairable(false).await;
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.adapter
|
||||||
|
.set_discoverable(false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.adapter
|
||||||
|
.set_pairable(false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn drop_channels(&mut self) {
|
||||||
|
for socket in self.channels.iter_mut().filter_map(Option::take) {
|
||||||
|
let _ = socket.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
self.channel_peer = None;
|
||||||
|
self.partial_since = None;
|
||||||
|
self.hid = Default::default();
|
||||||
|
self.generation = self.generation.wrapping_add(1);
|
||||||
|
}
|
||||||
|
async fn packet(&self, index: usize, data: &[u8]) -> Result<(), String> {
|
||||||
|
let socket = self.channels[index]
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Classic HID channel not connected")?;
|
||||||
|
let sent = tokio::time::timeout(Duration::from_millis(250), socket.send(data))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "HID send timeout")?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if sent != data.len() {
|
||||||
|
return Err("Short HID packet write".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn release(&mut self) {
|
||||||
|
for packet in self.hid.release() {
|
||||||
|
if self.channels[1].is_some() && self.packet(1, &packet).await.is_err() {
|
||||||
|
self.drop_channels();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.generation = self.generation.wrapping_add(1);
|
||||||
|
}
|
||||||
|
async fn accept(
|
||||||
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
socket: SeqPacket,
|
||||||
|
peer: SocketAddr,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if peer.addr_type != AddressType::BrEdr {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// A host can open HID channels before the next status poll.
|
||||||
|
let paired = self
|
||||||
|
.adapter
|
||||||
|
.device(peer.addr)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_paired()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.observe_bond(&peer.addr.to_string(), paired);
|
||||||
|
if !self.shared.allowed(peer.addr) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.channel_peer.is_some_and(|p| p != peer.addr) || self.channels[index].is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Kernel BT_SECURITY_MEDIUM negotiates encryption before accept completes.
|
||||||
|
if socket
|
||||||
|
.as_ref()
|
||||||
|
.security()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.key_size
|
||||||
|
< 7
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.channel_peer = Some(peer.addr);
|
||||||
|
self.channels[index] = Some(Arc::new(socket));
|
||||||
|
if self.channels.iter().all(Option::is_some) {
|
||||||
|
self.partial_since = None;
|
||||||
|
self.release().await;
|
||||||
|
} else {
|
||||||
|
self.partial_since = Some(Instant::now());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn status(&mut self) -> Result<Status, String> {
|
||||||
|
if !self.adapter.is_powered().await.map_err(|e| e.to_string())? {
|
||||||
|
return Err("Bluetooth adapter powered off".into());
|
||||||
|
}
|
||||||
|
// A daemon restart loses the SDP registration even if the adapter comes back powered.
|
||||||
|
if !self
|
||||||
|
.adapter
|
||||||
|
.uuids()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|id| id.to_string() == protocol::HID_UUID)
|
||||||
|
{
|
||||||
|
return Err("Classic HID SDP registration lost; restarting Bluetooth backend".into());
|
||||||
|
}
|
||||||
|
let mut status = Status {
|
||||||
|
initialized: true,
|
||||||
|
adapter: self.config.adapter.clone(),
|
||||||
|
adapter_address: self
|
||||||
|
.adapter
|
||||||
|
.address()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for address in self
|
||||||
|
.adapter
|
||||||
|
.device_addresses()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
let device = self.adapter.device(address).map_err(|e| e.to_string())?;
|
||||||
|
let connected = device.is_connected().await.unwrap_or(false);
|
||||||
|
let paired = device.is_paired().await.unwrap_or(false);
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.observe_bond(&address.to_string(), paired);
|
||||||
|
if paired || connected {
|
||||||
|
status.devices.push(Device {
|
||||||
|
address: address.to_string(),
|
||||||
|
name: device.alias().await.unwrap_or_default(),
|
||||||
|
paired,
|
||||||
|
connected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.devices.sort_by(|a, b| a.address.cmp(&b.address));
|
||||||
|
let (peer, until) = {
|
||||||
|
let state = self.shared.state.lock().unwrap();
|
||||||
|
(state.peer.clone(), state.pairing_until)
|
||||||
|
};
|
||||||
|
status.peer = peer.clone();
|
||||||
|
let selected = status
|
||||||
|
.devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| Some(&device.address) == peer.as_ref());
|
||||||
|
let selected_paired = selected.is_some_and(|device| device.paired);
|
||||||
|
if selected_paired && self.recorded_peer != peer {
|
||||||
|
if let (Some(store), Some(peer)) = (&self.bonds, &peer) {
|
||||||
|
store
|
||||||
|
.save(bonds::Bond {
|
||||||
|
adapter: status.adapter_address.clone(),
|
||||||
|
peer: peer.clone(),
|
||||||
|
pending: false,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
self.recorded_peer = Some(peer.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.connected = selected.is_some_and(|device| device.paired && device.connected);
|
||||||
|
if self.channels.iter().any(Option::is_some)
|
||||||
|
&& (!status.connected
|
||||||
|
|| self
|
||||||
|
.partial_since
|
||||||
|
.is_some_and(|time| time.elapsed() > Duration::from_secs(10)))
|
||||||
|
{
|
||||||
|
self.drop_channels();
|
||||||
|
}
|
||||||
|
if let Some(until) = until {
|
||||||
|
let completed = self
|
||||||
|
.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pairing_completed(selected_paired);
|
||||||
|
if until <= Instant::now() || completed {
|
||||||
|
{
|
||||||
|
let mut state = self.shared.state.lock().unwrap();
|
||||||
|
state.close_pairing(
|
||||||
|
selected_paired,
|
||||||
|
self.config.peer.as_ref().map(|p| p.to_ascii_uppercase()),
|
||||||
|
);
|
||||||
|
status.peer = state.peer.clone();
|
||||||
|
}
|
||||||
|
self.public(None).await?;
|
||||||
|
tracing::info!(reason = if completed { "bonded" } else { "expired" }, peer = ?status.peer, "Bluetooth HID pairing window closed");
|
||||||
|
} else {
|
||||||
|
status.pairing_seconds =
|
||||||
|
until.saturating_duration_since(Instant::now()).as_secs() as u32 + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.control_connected = self.channels[0].is_some();
|
||||||
|
status.interrupt_connected = self.channels[1].is_some();
|
||||||
|
status.ready = status.connected
|
||||||
|
&& status.control_connected
|
||||||
|
&& status.interrupt_connected
|
||||||
|
&& !self.hid.suspended;
|
||||||
|
status.leds = self.hid.leds;
|
||||||
|
status.generation = self.generation;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
async fn forget(&mut self) -> Result<(), String> {
|
||||||
|
self.release().await;
|
||||||
|
self.drop_channels();
|
||||||
|
let peer = self.shared.state.lock().unwrap().peer.clone();
|
||||||
|
if let Some(peer) = &peer {
|
||||||
|
let address = peer.parse().map_err(|_| "Invalid peer")?;
|
||||||
|
if self
|
||||||
|
.adapter
|
||||||
|
.device_addresses()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.contains(&address)
|
||||||
|
{
|
||||||
|
self.adapter
|
||||||
|
.remove_device(address)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.bonded_before_pairing
|
||||||
|
.remove(peer);
|
||||||
|
tracing::info!(%peer, "Bluetooth HID forgot computer");
|
||||||
|
}
|
||||||
|
if let Some(store) = &self.bonds {
|
||||||
|
let address = self
|
||||||
|
.adapter
|
||||||
|
.address()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string();
|
||||||
|
for bond in store
|
||||||
|
.list()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|b| b.adapter == address && Some(&b.peer) == peer.as_ref())
|
||||||
|
{
|
||||||
|
store.remove(bond).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.recorded_peer = None;
|
||||||
|
self.config.peer = None;
|
||||||
|
self.shared.state.lock().unwrap().peer = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn incoming(&mut self, index: usize, data: &[u8]) -> Result<(), String> {
|
||||||
|
if index == 1 {
|
||||||
|
self.hid.output(data);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let result = self.hid.control(data);
|
||||||
|
if let Some(reply) = result.reply {
|
||||||
|
self.packet(0, &reply).await?;
|
||||||
|
}
|
||||||
|
if result.reset {
|
||||||
|
self.release().await;
|
||||||
|
}
|
||||||
|
if result.unplug {
|
||||||
|
self.forget().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn execute(&mut self, command: Command) -> Result<(), String> {
|
||||||
|
match command {
|
||||||
|
Command::Report(kind, value) => {
|
||||||
|
let peer = self.channel_peer.ok_or("Classic HID is not connected")?;
|
||||||
|
if !self.shared.allowed(peer) || self.channels.iter().any(Option::is_none) {
|
||||||
|
return Err("Classic HID channels are not ready".into());
|
||||||
|
}
|
||||||
|
let device = self.adapter.device(peer).map_err(|e| e.to_string())?;
|
||||||
|
if !device.is_paired().await.unwrap_or(false)
|
||||||
|
|| !device.is_connected().await.unwrap_or(false)
|
||||||
|
{
|
||||||
|
self.drop_channels();
|
||||||
|
return Err("Selected Bluetooth computer disconnected".into());
|
||||||
|
}
|
||||||
|
let packet = self.hid.input(kind, &value)?;
|
||||||
|
if let Err(e) = self.packet(1, &packet).await {
|
||||||
|
self.drop_channels();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Action(Action::Pair(seconds)) => {
|
||||||
|
if !(10..=300).contains(&seconds) {
|
||||||
|
return Err("Pairing window must be 10–300 seconds".into());
|
||||||
|
}
|
||||||
|
let mut bonded = HashSet::new();
|
||||||
|
for address in self
|
||||||
|
.adapter
|
||||||
|
.device_addresses()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
if self
|
||||||
|
.adapter
|
||||||
|
.device(address)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_paired()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
{
|
||||||
|
bonded.insert(address.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut state = self.shared.state.lock().unwrap();
|
||||||
|
// Preserve the original baseline when extending an open window.
|
||||||
|
if !state
|
||||||
|
.pairing_until
|
||||||
|
.is_some_and(|until| until > Instant::now())
|
||||||
|
{
|
||||||
|
state.bonded_before_pairing = bonded;
|
||||||
|
}
|
||||||
|
state.pairing_until =
|
||||||
|
Some(Instant::now() + Duration::from_secs(seconds.into()));
|
||||||
|
}
|
||||||
|
if let Err(e) = self.public(Some(seconds)).await {
|
||||||
|
self.shared.state.lock().unwrap().pairing_until = None;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
tracing::info!(seconds, "Bluetooth HID pairing window opened");
|
||||||
|
}
|
||||||
|
Command::Action(Action::ClosePairing) => {
|
||||||
|
self.public(None).await?;
|
||||||
|
let peer = self.shared.state.lock().unwrap().peer.clone();
|
||||||
|
let paired = if let Some(peer) = peer {
|
||||||
|
self.adapter
|
||||||
|
.device(peer.parse().map_err(|_| "Invalid HID peer")?)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_paired()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
self.shared
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.close_pairing(paired, self.config.peer.clone());
|
||||||
|
tracing::info!(reason = "manual", "Bluetooth HID pairing window closed");
|
||||||
|
}
|
||||||
|
Command::Action(Action::Forget) => self.forget().await?,
|
||||||
|
Command::Action(Action::Disconnect) => {
|
||||||
|
self.release().await;
|
||||||
|
self.drop_channels();
|
||||||
|
let peer = self.shared.state.lock().unwrap().peer.clone();
|
||||||
|
if let Some(peer) = peer {
|
||||||
|
self.adapter
|
||||||
|
.device(peer.parse().map_err(|_| "Invalid peer")?)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.disconnect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Action(Action::Reset) => self.release().await,
|
||||||
|
Command::Stop => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn close(&mut self) -> Result<(), String> {
|
||||||
|
self.release().await;
|
||||||
|
self.drop_channels();
|
||||||
|
self.agent.take();
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
let mut errors = vec![];
|
||||||
|
if let Err(e) = self.adapter.set_discoverable(false).await {
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self.controller.restore().await {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
if let Err(e) = self.adapter.set_alias(self.old_alias.clone()).await {
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self
|
||||||
|
.adapter
|
||||||
|
.set_discoverable_timeout(self.old_discoverable_timeout)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self
|
||||||
|
.adapter
|
||||||
|
.set_pairable_timeout(self.old_pairable_timeout)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self.adapter.set_pairable(self.old_pairable).await {
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self.adapter.set_discoverable(self.old_discoverable).await {
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if let Err(e) = self.adapter.set_powered(self.old_powered).await {
|
||||||
|
errors.push(e.to_string());
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors.join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn receive(socket: Option<Arc<SeqPacket>>, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
match socket {
|
||||||
|
Some(socket) => socket.recv(buffer).await,
|
||||||
|
None => std::future::pending().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn supervise(
|
||||||
|
config: Config,
|
||||||
|
bonds: Option<Arc<dyn bonds::BondStore>>,
|
||||||
|
mut rx: mpsc::Receiver<Request>,
|
||||||
|
tx: watch::Sender<Status>,
|
||||||
|
) {
|
||||||
|
let mut generation = 0;
|
||||||
|
loop {
|
||||||
|
let mut runtime = match Runtime::open(&config, bonds.clone()).await {
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(error) => {
|
||||||
|
tx.send_replace(Status {
|
||||||
|
adapter: config.adapter.clone(),
|
||||||
|
error: Some(error.clone()),
|
||||||
|
generation,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let delay = tokio::time::sleep(Duration::from_secs(5));
|
||||||
|
tokio::pin!(delay);
|
||||||
|
loop {
|
||||||
|
tokio::select! {_=&mut delay=>break,request=rx.recv()=>{
|
||||||
|
let Some(request)=request else{return;};
|
||||||
|
if matches!(request.command,Command::Stop){let _=request.result.send(Ok(()));return;}
|
||||||
|
let _=request.result.send(Err(error.clone()));
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runtime.generation = generation;
|
||||||
|
let mut timer = tokio::time::interval(Duration::from_millis(500));
|
||||||
|
let failure = loop {
|
||||||
|
let ctl = runtime.channels[0].clone();
|
||||||
|
let intr = runtime.channels[1].clone();
|
||||||
|
let mut ctl_buffer = [0; 1024];
|
||||||
|
let mut intr_buffer = [0; 1024];
|
||||||
|
tokio::select! {
|
||||||
|
accepted=runtime.listeners[0].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(0,socket,peer).await{break e;}},Err(e)=>break e.to_string()}}
|
||||||
|
accepted=runtime.listeners[1].accept()=>{match accepted{Ok((socket,peer))=>{if let Err(e)=runtime.accept(1,socket,peer).await{break e;}},Err(e)=>break e.to_string()}}
|
||||||
|
read=receive(ctl,&mut ctl_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(0,&ctl_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}}
|
||||||
|
read=receive(intr,&mut intr_buffer)=>{match read{Ok(n)if n>0=>{if runtime.incoming(1,&intr_buffer[..n]).await.is_err(){runtime.drop_channels();}},_=>runtime.drop_channels()}}
|
||||||
|
_=timer.tick()=>{match runtime.status().await{Ok(status)=>{tx.send_if_modified(|old|{if *old!=status{*old=status;true}else{false}});},Err(e)=>break e}}
|
||||||
|
request=rx.recv()=>{
|
||||||
|
let Some(request)=request else{let _=runtime.close().await;return;};
|
||||||
|
if matches!(request.command,Command::Stop){let result=runtime.close().await;tx.send_replace(Status{adapter:config.adapter.clone(),..Default::default()});let _=request.result.send(result);return;}
|
||||||
|
if request.expires<=Instant::now()||request.result.is_closed(){runtime.release().await;continue;}
|
||||||
|
let action = matches!(&request.command, Command::Action(_));
|
||||||
|
let result=runtime.execute(request.command).await;
|
||||||
|
if result.is_err(){runtime.release().await;}
|
||||||
|
if action {
|
||||||
|
if let Ok(status) = runtime.status().await { tx.send_replace(status); }
|
||||||
|
}
|
||||||
|
let _=request.result.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let restore = runtime.close().await;
|
||||||
|
generation = runtime.generation.wrapping_add(1);
|
||||||
|
tx.send_replace(Status {
|
||||||
|
adapter: config.adapter.clone(),
|
||||||
|
error: Some(format!("{failure}; cleanup: {restore:?}")),
|
||||||
|
generation,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
drop(runtime);
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn closing_pairing_preserves_completed_bonds_but_releases_unpaired_targets() {
|
||||||
|
let shared = Shared::new(Some("10:6F:D9:66:97:88".into()));
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
|
||||||
|
state.close_pairing(true, None);
|
||||||
|
assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:88"));
|
||||||
|
assert!(state.pairing_until.is_none());
|
||||||
|
state.close_pairing(false, None);
|
||||||
|
assert!(state.peer.is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn configuration_validation() {
|
||||||
|
let mut c = Config {
|
||||||
|
adapter: "hci0".into(),
|
||||||
|
name: "One-KVM".into(),
|
||||||
|
peer: None,
|
||||||
|
};
|
||||||
|
assert!(c.validate().is_ok());
|
||||||
|
c.adapter = "hci0;reboot".into();
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
c.adapter = "hci1".into();
|
||||||
|
c.peer = Some("bogus".into());
|
||||||
|
assert!(c.validate().is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn peer_isolation() {
|
||||||
|
let state = Shared::new(Some("10:6F:D9:66:97:88".into()));
|
||||||
|
assert!(state.allowed("10:6F:D9:66:97:88".parse().unwrap()));
|
||||||
|
assert!(!state.allowed("10:6F:D9:66:97:89".parse().unwrap()));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn new_bond_without_agent_callback_completes_pairing() {
|
||||||
|
let shared = Shared::new(None);
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
|
||||||
|
let peer = "10:6F:D9:66:97:88";
|
||||||
|
state.observe_bond(peer, false);
|
||||||
|
assert_eq!(state.peer, None);
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert_eq!(state.peer.as_deref(), Some(peer));
|
||||||
|
assert!(state.pairing_completed(true));
|
||||||
|
// A second computer cannot replace the one selected in this window.
|
||||||
|
state.observe_bond("10:6F:D9:66:97:89", true);
|
||||||
|
assert_eq!(state.peer.as_deref(), Some(peer));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn old_bond_does_not_finish_a_new_window() {
|
||||||
|
let peer = "10:6F:D9:66:97:88";
|
||||||
|
let shared = Shared::new(Some(peer.into()));
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
|
||||||
|
state.bonded_before_pairing.insert(peer.into());
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert!(!state.pairing_completed(true));
|
||||||
|
state.peer = None;
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert_eq!(state.peer, None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn bond_observation_respects_closed_window_and_pinned_peer() {
|
||||||
|
let peer = "10:6F:D9:66:97:88";
|
||||||
|
let shared = Shared::new(None);
|
||||||
|
let mut state = shared.state.lock().unwrap();
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert_eq!(state.peer, None);
|
||||||
|
state.pairing_until = Some(Instant::now() - Duration::from_secs(1));
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert_eq!(state.peer, None);
|
||||||
|
state.pairing_until = Some(Instant::now() + Duration::from_secs(120));
|
||||||
|
state.peer = Some("10:6F:D9:66:97:89".into());
|
||||||
|
state.observe_bond(peer, true);
|
||||||
|
assert_eq!(state.peer.as_deref(), Some("10:6F:D9:66:97:89"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AdapterInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub address: String,
|
||||||
|
pub powered: bool,
|
||||||
|
}
|
||||||
|
pub async fn adapters() -> Result<Vec<AdapterInfo>, String> {
|
||||||
|
let session = Session::new().await.map_err(|e| e.to_string())?;
|
||||||
|
let mut output = vec![];
|
||||||
|
for name in session.adapter_names().await.map_err(|e| e.to_string())? {
|
||||||
|
let adapter = session.adapter(&name).map_err(|e| e.to_string())?;
|
||||||
|
output.push(AdapterInfo {
|
||||||
|
name,
|
||||||
|
address: adapter
|
||||||
|
.address()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string(),
|
||||||
|
powered: adapter.is_powered().await.map_err(|e| e.to_string())?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
267
libs/bluetooth-hid/src/protocol.rs
Normal file
267
libs/bluetooth-hid/src/protocol.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
//! Classic HIDP framing and report descriptor.
|
||||||
|
use crate::Report;
|
||||||
|
pub const MAP: &[u8] = &[
|
||||||
|
0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x85, 0x01, 0x05, 0x07, 0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00,
|
||||||
|
0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x05,
|
||||||
|
0x75, 0x01, 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02, 0x95, 0x01, 0x75, 0x03, 0x91, 0x01,
|
||||||
|
0x95, 0x06, 0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x81, 0x00,
|
||||||
|
0xc0, 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, 0x85, 0x02, 0x09, 0x01, 0xa1, 0x00, 0x05, 0x09, 0x19,
|
||||||
|
0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, 0x95, 0x05, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75,
|
||||||
|
0x03, 0x81, 0x01, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, 0x81, 0x25, 0x7f, 0x75,
|
||||||
|
0x08, 0x95, 0x03, 0x81, 0x06, 0xc0, 0xc0, 0x05, 0x0c, 0x09, 0x01, 0xa1, 0x01, 0x85, 0x03, 0x15,
|
||||||
|
0x00, 0x26, 0xff, 0x03, 0x19, 0x00, 0x2a, 0xff, 0x03, 0x75, 0x10, 0x95, 0x01, 0x81, 0x00, 0xc0,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const HID_UUID: &str = "00001124-0000-1000-8000-00805f9b34fb";
|
||||||
|
pub fn sdp() -> String {
|
||||||
|
let hex: String = MAP.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0"?>
|
||||||
|
<record>
|
||||||
|
<attribute id="0x0001"><sequence><uuid value="0x1124"/></sequence></attribute>
|
||||||
|
<attribute id="0x0004"><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0011"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></attribute>
|
||||||
|
<attribute id="0x0005"><sequence><uuid value="0x1002"/></sequence></attribute>
|
||||||
|
<attribute id="0x0006"><sequence><uint16 value="0x656e"/><uint16 value="0x006a"/><uint16 value="0x0100"/></sequence></attribute>
|
||||||
|
<attribute id="0x0009"><sequence><sequence><uuid value="0x1124"/><uint16 value="0x0100"/></sequence></sequence></attribute>
|
||||||
|
<attribute id="0x000d"><sequence><sequence><sequence><uuid value="0x0100"/><uint16 value="0x0013"/></sequence><sequence><uuid value="0x0011"/></sequence></sequence></sequence></attribute>
|
||||||
|
<attribute id="0x0100"><text value="One-KVM Keyboard and Mouse"/></attribute>
|
||||||
|
<attribute id="0x0101"><text value="Classic Bluetooth HID"/></attribute>
|
||||||
|
<attribute id="0x0102"><text value="One-KVM"/></attribute>
|
||||||
|
<attribute id="0x0200"><uint16 value="0x0100"/></attribute>
|
||||||
|
<attribute id="0x0201"><uint16 value="0x0111"/></attribute>
|
||||||
|
<attribute id="0x0202"><uint8 value="0xc0"/></attribute>
|
||||||
|
<attribute id="0x0203"><uint8 value="0x00"/></attribute>
|
||||||
|
<attribute id="0x0204"><boolean value="false"/></attribute>
|
||||||
|
<attribute id="0x0205"><boolean value="false"/></attribute>
|
||||||
|
<attribute id="0x0206"><sequence><sequence><uint8 value="0x22"/><text encoding="hex" value="{hex}"/></sequence></sequence></attribute>
|
||||||
|
<attribute id="0x0207"><sequence><sequence><uint16 value="0x0409"/><uint16 value="0x0100"/></sequence></sequence></attribute>
|
||||||
|
<attribute id="0x0209"><boolean value="false"/></attribute>
|
||||||
|
<attribute id="0x020a"><boolean value="false"/></attribute>
|
||||||
|
<attribute id="0x020b"><uint16 value="0x0100"/></attribute>
|
||||||
|
<attribute id="0x020c"><uint16 value="0x0c80"/></attribute>
|
||||||
|
<attribute id="0x020d"><boolean value="true"/></attribute>
|
||||||
|
<attribute id="0x020e"><boolean value="true"/></attribute>
|
||||||
|
</record>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct HidProtocol {
|
||||||
|
pub boot: bool,
|
||||||
|
pub suspended: bool,
|
||||||
|
pub leds: u8,
|
||||||
|
pub keyboard: [u8; 8],
|
||||||
|
pub buttons: u8,
|
||||||
|
pub consumer: [u8; 2],
|
||||||
|
}
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ControlResult {
|
||||||
|
pub reply: Option<Vec<u8>>,
|
||||||
|
pub unplug: bool,
|
||||||
|
pub reset: bool,
|
||||||
|
}
|
||||||
|
impl HidProtocol {
|
||||||
|
pub fn input(&mut self, kind: Report, value: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
if value.len() != kind.len() {
|
||||||
|
return Err("Invalid HID report length".into());
|
||||||
|
}
|
||||||
|
if self.suspended {
|
||||||
|
return Err("Bluetooth HID is suspended".into());
|
||||||
|
}
|
||||||
|
let mut packet = vec![0xa1, kind.id()];
|
||||||
|
match kind {
|
||||||
|
Report::Keyboard => {
|
||||||
|
self.keyboard.copy_from_slice(value);
|
||||||
|
packet.extend_from_slice(value);
|
||||||
|
}
|
||||||
|
Report::Mouse => {
|
||||||
|
self.buttons = value[0];
|
||||||
|
packet.extend_from_slice(&value[..if self.boot { 3 } else { 4 }]);
|
||||||
|
if self.boot {
|
||||||
|
packet[2] &= 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Report::Consumer if !self.boot => {
|
||||||
|
self.consumer.copy_from_slice(value);
|
||||||
|
packet.extend_from_slice(value);
|
||||||
|
}
|
||||||
|
Report::Consumer => return Err("Consumer keys unavailable in boot protocol".into()),
|
||||||
|
}
|
||||||
|
Ok(packet)
|
||||||
|
}
|
||||||
|
pub fn release(&mut self) -> Vec<Vec<u8>> {
|
||||||
|
self.keyboard = [0; 8];
|
||||||
|
self.buttons = 0;
|
||||||
|
self.consumer = [0; 2];
|
||||||
|
let mut packets = vec![
|
||||||
|
vec![0xa1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
if self.boot {
|
||||||
|
vec![0xa1, 2, 0, 0, 0]
|
||||||
|
} else {
|
||||||
|
vec![0xa1, 2, 0, 0, 0, 0]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if !self.boot {
|
||||||
|
packets.push(vec![0xa1, 3, 0, 0]);
|
||||||
|
}
|
||||||
|
packets
|
||||||
|
}
|
||||||
|
pub fn output(&mut self, data: &[u8]) -> bool {
|
||||||
|
if data.len() == 3 && data[0] == 0xa2 && data[1] == 1 {
|
||||||
|
self.leds = data[2] & 0x1f;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn control(&mut self, data: &[u8]) -> ControlResult {
|
||||||
|
let mut result = ControlResult::default();
|
||||||
|
let handshake = |code| ControlResult {
|
||||||
|
reply: Some(vec![code]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let Some(&header) = data.first() else {
|
||||||
|
return handshake(4);
|
||||||
|
};
|
||||||
|
match header {
|
||||||
|
0x13 if data.len() == 1 => {
|
||||||
|
self.suspended = true;
|
||||||
|
result.reset = true;
|
||||||
|
}
|
||||||
|
0x14 if data.len() == 1 => {
|
||||||
|
self.suspended = false;
|
||||||
|
result.reset = true;
|
||||||
|
}
|
||||||
|
0x15 if data.len() == 1 => {
|
||||||
|
result.unplug = true;
|
||||||
|
}
|
||||||
|
0x11 | 0x12 if data.len() == 1 => {
|
||||||
|
self.suspended = false;
|
||||||
|
result.reset = true;
|
||||||
|
}
|
||||||
|
0x60 if data.len() == 1 => {
|
||||||
|
result.reply = Some(vec![0xa0, if self.boot { 0 } else { 1 }]);
|
||||||
|
}
|
||||||
|
0x70 | 0x71 if data.len() == 1 => {
|
||||||
|
self.boot = header == 0x70;
|
||||||
|
result.reply = Some(vec![0]);
|
||||||
|
result.reset = true;
|
||||||
|
}
|
||||||
|
0x52 if data.len() == 3 && data[1] == 1 => {
|
||||||
|
self.leds = data[2] & 0x1f;
|
||||||
|
return handshake(0);
|
||||||
|
}
|
||||||
|
0x41 | 0x49 | 0x42 | 0x4a => {
|
||||||
|
let sized = header & 8 != 0;
|
||||||
|
if data.len() != if sized { 4 } else { 2 } {
|
||||||
|
return handshake(4);
|
||||||
|
}
|
||||||
|
let mut report = vec![0xa0 | (header & 3), data[1]];
|
||||||
|
match (header & 3, data[1]) {
|
||||||
|
(1, 1) => report.extend(self.keyboard),
|
||||||
|
(1, 2) => report.extend(if self.boot {
|
||||||
|
vec![self.buttons & 7, 0, 0]
|
||||||
|
} else {
|
||||||
|
vec![self.buttons, 0, 0, 0]
|
||||||
|
}),
|
||||||
|
(1, 3) if !self.boot => report.extend(self.consumer),
|
||||||
|
(2, 1) => report.push(self.leds),
|
||||||
|
_ => return handshake(2),
|
||||||
|
}
|
||||||
|
if sized {
|
||||||
|
report.truncate(1 + u16::from_le_bytes([data[2], data[3]]) as usize);
|
||||||
|
}
|
||||||
|
result.reply = Some(report);
|
||||||
|
}
|
||||||
|
_ => return handshake(3), // ERR_UNSUPPORTED_REQUEST
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn classic_frames_and_boot_protocol() {
|
||||||
|
let mut hid = HidProtocol::default();
|
||||||
|
assert_eq!(
|
||||||
|
hid.input(Report::Keyboard, &[2, 0, 4, 0, 0, 0, 0, 0])
|
||||||
|
.unwrap(),
|
||||||
|
[0xa1, 1, 2, 0, 4, 0, 0, 0, 0, 0]
|
||||||
|
);
|
||||||
|
assert_eq!(hid.control(&[0x60]).reply.unwrap(), [0xa0, 1]);
|
||||||
|
assert_eq!(hid.control(&[0x70]).reply.unwrap(), [0]);
|
||||||
|
assert_eq!(
|
||||||
|
hid.input(Report::Mouse, &[0x1f, 127, 128, 1]).unwrap(),
|
||||||
|
[0xa1, 2, 7, 127, 128]
|
||||||
|
);
|
||||||
|
assert!(hid.input(Report::Consumer, &[0, 0]).is_err());
|
||||||
|
assert!(hid.input(Report::Keyboard, &[0]).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn led_read_write_and_errors() {
|
||||||
|
let mut hid = HidProtocol::default();
|
||||||
|
assert!(hid.output(&[0xa2, 1, 3]));
|
||||||
|
assert_eq!(hid.leds, 3);
|
||||||
|
assert_eq!(hid.control(&[0x42, 1]).reply.unwrap(), [0xa2, 1, 3]);
|
||||||
|
assert_eq!(hid.control(&[0x52, 1, 2]).reply.unwrap(), [0]);
|
||||||
|
assert_eq!(hid.leds, 2);
|
||||||
|
assert_eq!(hid.control(&[0x41, 99]).reply.unwrap(), [2]);
|
||||||
|
assert_eq!(hid.control(&[0x41]).reply.unwrap(), [4]);
|
||||||
|
assert_eq!(hid.control(&[0x90, 0]).reply.unwrap(), [3]);
|
||||||
|
assert_eq!(
|
||||||
|
hid.control(&[0x49, 1, 3, 0]).reply.unwrap(),
|
||||||
|
[0xa1, 1, 0, 0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn suspend_reset_and_virtual_unplug() {
|
||||||
|
let mut hid = HidProtocol::default();
|
||||||
|
assert!(hid.control(&[0x13]).reset);
|
||||||
|
assert!(hid.input(Report::Mouse, &[0; 4]).is_err());
|
||||||
|
assert!(hid.control(&[0x14]).reset);
|
||||||
|
assert!(hid.input(Report::Mouse, &[0; 4]).is_ok());
|
||||||
|
assert!(hid.control(&[0x15]).unplug);
|
||||||
|
hid.keyboard[2] = 4;
|
||||||
|
hid.buttons = 1;
|
||||||
|
assert_eq!(hid.release().len(), 3);
|
||||||
|
assert_eq!(hid.keyboard, [0; 8]);
|
||||||
|
assert_eq!(hid.buttons, 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn sdp_describes_both_classic_channels() {
|
||||||
|
let record = sdp();
|
||||||
|
assert!(record.contains("uuid value=\"0x1124\""));
|
||||||
|
assert!(record.contains("uint16 value=\"0x0011\""));
|
||||||
|
assert!(record.contains("uint16 value=\"0x0013\""));
|
||||||
|
assert!(!record.contains("0x1812"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn descriptor_report_sizes() {
|
||||||
|
let (mut pos, mut size, mut count, mut id, mut depth) = (0, 0, 0, 0, 0);
|
||||||
|
let mut input = [0; 4];
|
||||||
|
let mut output = [0; 4];
|
||||||
|
while pos < MAP.len() {
|
||||||
|
let prefix = MAP[pos];
|
||||||
|
pos += 1;
|
||||||
|
let length = [0, 1, 2, 4][(prefix & 3) as usize];
|
||||||
|
let mut value = 0;
|
||||||
|
for i in 0..length {
|
||||||
|
value |= (MAP[pos + i] as usize) << (8 * i);
|
||||||
|
}
|
||||||
|
pos += length;
|
||||||
|
match ((prefix >> 2) & 3, prefix >> 4) {
|
||||||
|
(1, 7) => size = value,
|
||||||
|
(1, 8) => id = value,
|
||||||
|
(1, 9) => count = value,
|
||||||
|
(0, 8) => input[id] += size * count,
|
||||||
|
(0, 9) => output[id] += size * count,
|
||||||
|
(0, 10) => depth += 1,
|
||||||
|
(0, 12) => depth -= 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(depth, 0);
|
||||||
|
assert_eq!(input, [0, 64, 32, 16]);
|
||||||
|
assert_eq!(output, [0, 8, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ fn is_setup_public_endpoint(path: &str) -> bool {
|
|||||||
"/setup"
|
"/setup"
|
||||||
| "/setup/init"
|
| "/setup/init"
|
||||||
| "/devices"
|
| "/devices"
|
||||||
|
| "/hid/bluetooth/adapters"
|
||||||
| "/video/input-status"
|
| "/video/input-status"
|
||||||
| "/stream/codecs"
|
| "/stream/codecs"
|
||||||
| "/video/codecs"
|
| "/video/codecs"
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct BluetoothHidConfig {
|
||||||
|
pub adapter: String,
|
||||||
|
pub name: String,
|
||||||
|
pub peer: Option<String>,
|
||||||
|
}
|
||||||
|
impl Default for BluetoothHidConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
adapter: "hci0".into(),
|
||||||
|
name: "One-KVM HID".into(),
|
||||||
|
peer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl BluetoothHidConfig {
|
||||||
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
|
let invalid = |reason: &str| crate::error::AppError::BadRequest(reason.into());
|
||||||
|
if !self
|
||||||
|
.adapter
|
||||||
|
.strip_prefix("hci")
|
||||||
|
.is_some_and(|s| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()))
|
||||||
|
{
|
||||||
|
return Err(invalid(
|
||||||
|
"Bluetooth adapter must be hci followed by an index",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) {
|
||||||
|
return Err(invalid(
|
||||||
|
"Bluetooth name must contain 1–64 UTF-8 bytes without control characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(peer) = &self.peer {
|
||||||
|
let parts: Vec<_> = peer.split(':').collect();
|
||||||
|
if parts.len() != 6
|
||||||
|
|| parts
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||||
|
{
|
||||||
|
return Err(invalid("Invalid Bluetooth peer address"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -8,6 +56,7 @@ use typeshare::typeshare;
|
|||||||
pub enum HidBackend {
|
pub enum HidBackend {
|
||||||
Otg,
|
Otg,
|
||||||
Ch9329,
|
Ch9329,
|
||||||
|
Bluetooth,
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
@@ -166,6 +215,7 @@ impl OtgHidProfile {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct HidConfig {
|
pub struct HidConfig {
|
||||||
|
pub bluetooth: BluetoothHidConfig,
|
||||||
pub backend: HidBackend,
|
pub backend: HidBackend,
|
||||||
pub otg_udc: Option<String>,
|
pub otg_udc: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -189,6 +239,7 @@ impl Default for HidConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backend: HidBackend::None,
|
backend: HidBackend::None,
|
||||||
|
bluetooth: BluetoothHidConfig::default(),
|
||||||
otg_udc: None,
|
otg_udc: None,
|
||||||
otg_descriptor: OtgDescriptorConfig::default(),
|
otg_descriptor: OtgDescriptorConfig::default(),
|
||||||
otg_profile: OtgHidProfile::default(),
|
otg_profile: OtgHidProfile::default(),
|
||||||
@@ -252,3 +303,49 @@ impl HidConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bluetooth_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn old_configs_keep_bluetooth_disabled_and_get_defaults() {
|
||||||
|
let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
|
||||||
|
assert_eq!(config.backend, HidBackend::Otg);
|
||||||
|
assert_eq!(config.bluetooth, BluetoothHidConfig::default());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn obsolete_ble_flag_is_ignored_and_not_saved() {
|
||||||
|
let config: BluetoothHidConfig =
|
||||||
|
serde_json::from_str(r#"{"adapter":"hci0","name":"My keyboard","le_only":true}"#)
|
||||||
|
.unwrap();
|
||||||
|
config.validate().unwrap();
|
||||||
|
assert!(serde_json::to_value(config)
|
||||||
|
.unwrap()
|
||||||
|
.get("le_only")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn bluetooth_uses_relative_mouse_and_existing_usb_constraints() {
|
||||||
|
let mut config = crate::config::AppConfig::default();
|
||||||
|
config.hid.backend = HidBackend::Bluetooth;
|
||||||
|
config.hid.mouse_absolute = true;
|
||||||
|
config.msd.enabled = true;
|
||||||
|
config.uac.enabled = true;
|
||||||
|
config.otg_network.enabled = true;
|
||||||
|
config.enforce_invariants();
|
||||||
|
assert!(!config.hid.mouse_absolute);
|
||||||
|
assert!(!config.msd.enabled && !config.uac.enabled && !config.otg_network.enabled);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reject_invalid_adapter_address_and_oversize_advertisement_name() {
|
||||||
|
let mut config = BluetoothHidConfig::default();
|
||||||
|
config.adapter = "/dev/hci0".into();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
config.adapter = "hci0".into();
|
||||||
|
config.peer = Some("not-a-mac".into());
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
config.peer = None;
|
||||||
|
config.name = "蓝".repeat(24);
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ impl AppConfig {
|
|||||||
self.otg_network.enabled = false;
|
self.otg_network.enabled = false;
|
||||||
self.uac.enabled = false;
|
self.uac.enabled = false;
|
||||||
}
|
}
|
||||||
|
if self.hid.backend == HidBackend::Bluetooth {
|
||||||
|
self.hid.mouse_absolute = false;
|
||||||
|
}
|
||||||
self.atx.normalize();
|
self.atx.normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ impl ConfigStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn hid_bonds(&self) -> crate::db::hid_bonds::HidBondStore {
|
||||||
|
crate::db::hid_bonds::HidBondStore(self.pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self) -> Arc<AppConfig> {
|
pub fn get(&self) -> Arc<AppConfig> {
|
||||||
self.cache.load_full()
|
self.cache.load_full()
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/db/hid_bonds.rs
Normal file
80
src/db/hid_bonds.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use one_kvm_bluetooth_hid::bonds::{Bond, BondStore, Operation};
|
||||||
|
use sqlx::{Pool, Sqlite};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HidBondStore(pub Pool<Sqlite>);
|
||||||
|
impl BondStore for HidBondStore {
|
||||||
|
fn list(&self) -> Operation<'_, Vec<Bond>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let rows: Vec<(String, String, bool)> = sqlx::query_as(
|
||||||
|
"SELECT adapter, peer, pending FROM hid_bonds ORDER BY adapter, peer",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(adapter, peer, pending)| Bond {
|
||||||
|
adapter,
|
||||||
|
peer,
|
||||||
|
pending,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn save(&self, bond: Bond) -> Operation<'_, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query("INSERT INTO hid_bonds(adapter, peer, pending) VALUES (?, ?, ?) ON CONFLICT(adapter, peer) DO UPDATE SET pending = MAX(pending, excluded.pending)")
|
||||||
|
.bind(bond.adapter.to_ascii_uppercase()).bind(bond.peer.to_ascii_uppercase()).bind(bond.pending)
|
||||||
|
.execute(&self.0).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn remove(&self, bond: Bond) -> Operation<'_, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query("DELETE FROM hid_bonds WHERE adapter = ? AND peer = ?")
|
||||||
|
.bind(bond.adapter)
|
||||||
|
.bind(bond.peer)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ownership_and_pending_cleanup_survive_reopen() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db = super::super::open_database_pool(dir.path()).await.unwrap();
|
||||||
|
let store = HidBondStore(db.clone_pool());
|
||||||
|
let bond = Bond {
|
||||||
|
adapter: "AA:BB:CC:DD:EE:FF".into(),
|
||||||
|
peer: "11:22:33:44:55:66".into(),
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
store.save(bond.clone()).await.unwrap();
|
||||||
|
store
|
||||||
|
.save(Bond {
|
||||||
|
pending: true,
|
||||||
|
..bond.clone()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store.save(bond.clone()).await.unwrap(); // Late status cannot undo a pending reset.
|
||||||
|
let reopened = super::super::open_database_pool(dir.path()).await.unwrap();
|
||||||
|
let records = HidBondStore(reopened.clone_pool()).list().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
records,
|
||||||
|
vec![Bond {
|
||||||
|
pending: true,
|
||||||
|
..bond.clone()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
store.remove(bond).await.unwrap();
|
||||||
|
assert!(store.list().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod hid_bonds;
|
||||||
mod pool;
|
mod pool;
|
||||||
mod wol_history;
|
mod wol_history;
|
||||||
|
|
||||||
|
|||||||
@@ -144,4 +144,5 @@ const SCHEMA_MIGRATIONS: &[&[&str]] = &[
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton
|
||||||
ON users ((1))
|
ON users ((1))
|
||||||
"#],
|
"#],
|
||||||
|
&["CREATE TABLE hid_bonds (adapter TEXT NOT NULL, peer TEXT NOT NULL, pending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(adapter, peer))"],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ fn default_ch9329_baud_rate() -> u32 {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub enum HidBackendType {
|
pub enum HidBackendType {
|
||||||
Otg,
|
Otg,
|
||||||
|
Bluetooth {
|
||||||
|
config: crate::config::BluetoothHidConfig,
|
||||||
|
},
|
||||||
Ch9329 {
|
Ch9329 {
|
||||||
port: String,
|
port: String,
|
||||||
#[serde(default = "default_ch9329_baud_rate")]
|
#[serde(default = "default_ch9329_baud_rate")]
|
||||||
@@ -33,6 +36,7 @@ impl HidBackendType {
|
|||||||
pub fn name_str(&self) -> &str {
|
pub fn name_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Otg => "otg",
|
Self::Otg => "otg",
|
||||||
|
Self::Bluetooth { .. } => "bluetooth",
|
||||||
Self::Ch9329 { .. } => "ch9329",
|
Self::Ch9329 { .. } => "ch9329",
|
||||||
Self::None => "none",
|
Self::None => "none",
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,17 @@ pub trait HidBackend: Send + Sync {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||||
|
Err(crate::error::AppError::BadRequest(
|
||||||
|
"Bluetooth HID is not active".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn bluetooth_action(&self, _action: &str, _seconds: u32) -> Result<()> {
|
||||||
|
Err(crate::error::AppError::BadRequest(
|
||||||
|
"Bluetooth HID is not active".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn reset(&self) -> Result<()>;
|
async fn reset(&self) -> Result<()>;
|
||||||
|
|
||||||
async fn prepare_rebuild(&self) -> Result<()> {
|
async fn prepare_rebuild(&self) -> Result<()> {
|
||||||
|
|||||||
283
src/hid/bluetooth.rs
Normal file
283
src/hid/bluetooth.rs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
//! Translate canonical One-KVM input into the native BlueZ peripheral.
|
||||||
|
use super::{
|
||||||
|
backend::{HidBackend, HidBackendRuntimeSnapshot},
|
||||||
|
types::{
|
||||||
|
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
config::BluetoothHidConfig,
|
||||||
|
error::{AppError, Result},
|
||||||
|
events::LedState,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use one_kvm_bluetooth_hid::{Action, Config, Peripheral, Report};
|
||||||
|
use tokio::sync::{watch, Mutex};
|
||||||
|
|
||||||
|
fn error(message: String) -> AppError {
|
||||||
|
AppError::HidError {
|
||||||
|
backend: "bluetooth".into(),
|
||||||
|
error_code: "bluetooth_error".into(),
|
||||||
|
reason: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InputState {
|
||||||
|
keyboard: KeyboardReport,
|
||||||
|
buttons: u8,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
pub struct BluetoothBackend {
|
||||||
|
peripheral: Peripheral,
|
||||||
|
input: Mutex<InputState>,
|
||||||
|
runtime: watch::Sender<()>,
|
||||||
|
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
impl BluetoothBackend {
|
||||||
|
pub fn new(
|
||||||
|
config: BluetoothHidConfig,
|
||||||
|
bonds: Option<std::sync::Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let peripheral = Peripheral::start_with_store(
|
||||||
|
Config {
|
||||||
|
adapter: config.adapter,
|
||||||
|
name: config.name,
|
||||||
|
peer: config.peer,
|
||||||
|
},
|
||||||
|
bonds,
|
||||||
|
)
|
||||||
|
.map_err(error)?;
|
||||||
|
let (runtime, _) = watch::channel(());
|
||||||
|
Ok(Self {
|
||||||
|
peripheral,
|
||||||
|
input: Mutex::new(InputState::default()),
|
||||||
|
runtime,
|
||||||
|
worker: Mutex::new(None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn check(&self, input: &mut InputState) -> Result<()> {
|
||||||
|
let status = self.peripheral.status();
|
||||||
|
if input.generation != status.generation || !status.ready {
|
||||||
|
*input = InputState {
|
||||||
|
generation: status.generation,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !status.ready {
|
||||||
|
return Err(error(status.error.unwrap_or_else(|| {
|
||||||
|
"Pair and connect a computer; HID reports are not ready".into()
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl HidBackend for BluetoothBackend {
|
||||||
|
async fn init(&self) -> Result<()> {
|
||||||
|
let mut status = self.peripheral.subscribe();
|
||||||
|
let runtime = self.runtime.clone();
|
||||||
|
*self.worker.lock().await = Some(tokio::spawn(async move {
|
||||||
|
let mut last_error = None;
|
||||||
|
while status.changed().await.is_ok() {
|
||||||
|
let error = status.borrow_and_update().error.clone();
|
||||||
|
if error != last_error {
|
||||||
|
if let Some(reason) = &error {
|
||||||
|
tracing::warn!(%reason, "Bluetooth HID unavailable");
|
||||||
|
}
|
||||||
|
last_error = error;
|
||||||
|
}
|
||||||
|
runtime.send_replace(());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||||
|
let mut input = self.input.lock().await;
|
||||||
|
self.check(&mut input)?;
|
||||||
|
apply_key(&mut input.keyboard, &event);
|
||||||
|
let result = self
|
||||||
|
.peripheral
|
||||||
|
.send(Report::Keyboard, input.keyboard.to_bytes().to_vec())
|
||||||
|
.await
|
||||||
|
.map_err(error);
|
||||||
|
if result.is_err() {
|
||||||
|
*input = InputState::default();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||||
|
let mut input = self.input.lock().await;
|
||||||
|
self.check(&mut input)?;
|
||||||
|
let (mut x, mut y, wheel) = match event.event_type {
|
||||||
|
MouseEventType::Move => (event.x, event.y, 0),
|
||||||
|
MouseEventType::MoveAbs => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Bluetooth HID supports relative mouse only".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
MouseEventType::Down | MouseEventType::Up => {
|
||||||
|
if let Some(button) = event.button {
|
||||||
|
let bit = button.to_hid_bit();
|
||||||
|
if event.event_type == MouseEventType::Down {
|
||||||
|
input.buttons |= bit;
|
||||||
|
} else {
|
||||||
|
input.buttons &= !bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0, 0, 0)
|
||||||
|
}
|
||||||
|
MouseEventType::Scroll => (0, 0, event.scroll),
|
||||||
|
};
|
||||||
|
// Bound malformed remote input without silently clipping normal relative movements.
|
||||||
|
if x.unsigned_abs() > 32767 || y.unsigned_abs() > 32767 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Relative mouse displacement too large".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let dx = x.clamp(-127, 127);
|
||||||
|
let dy = y.clamp(-127, 127);
|
||||||
|
self.peripheral
|
||||||
|
.send(
|
||||||
|
Report::Mouse,
|
||||||
|
vec![input.buttons, dx as i8 as u8, dy as i8 as u8, wheel as u8],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(error)?;
|
||||||
|
x -= dx;
|
||||||
|
y -= dy;
|
||||||
|
if x == 0 && y == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||||
|
let mut input = self.input.lock().await;
|
||||||
|
self.check(&mut input)?;
|
||||||
|
if event.usage > 0x3ff {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Consumer usage exceeds Bluetooth report range".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.peripheral
|
||||||
|
.send(Report::Consumer, event.usage.to_le_bytes().to_vec())
|
||||||
|
.await
|
||||||
|
.map_err(error)
|
||||||
|
}
|
||||||
|
async fn reset(&self) -> Result<()> {
|
||||||
|
*self.input.lock().await = InputState::default();
|
||||||
|
self.peripheral.action(Action::Reset).await.map_err(error)
|
||||||
|
}
|
||||||
|
async fn shutdown(&self) -> Result<()> {
|
||||||
|
let result = self.peripheral.shutdown().await.map_err(error);
|
||||||
|
if let Some(worker) = self.worker.lock().await.take() {
|
||||||
|
worker.abort();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||||
|
let state = self.peripheral.status();
|
||||||
|
HidBackendRuntimeSnapshot {
|
||||||
|
initialized: state.initialized,
|
||||||
|
online: state.ready,
|
||||||
|
supports_absolute_mouse: false,
|
||||||
|
keyboard_leds_enabled: true,
|
||||||
|
led_state: LedState {
|
||||||
|
num_lock: state.leds & 1 != 0,
|
||||||
|
caps_lock: state.leds & 2 != 0,
|
||||||
|
scroll_lock: state.leds & 4 != 0,
|
||||||
|
compose: state.leds & 8 != 0,
|
||||||
|
kana: state.leds & 16 != 0,
|
||||||
|
},
|
||||||
|
device: Some(state.peer.unwrap_or(state.adapter)),
|
||||||
|
screen_resolution: None,
|
||||||
|
error_code: state.error.as_ref().map(|_| "bluetooth_error".into()),
|
||||||
|
error: state.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn subscribe_runtime(&self) -> watch::Receiver<()> {
|
||||||
|
self.runtime.subscribe()
|
||||||
|
}
|
||||||
|
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||||
|
serde_json::to_value(self.peripheral.status()).map_err(|e| error(e.to_string()))
|
||||||
|
}
|
||||||
|
async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
|
||||||
|
if action == "pair" && !(10..=300).contains(&seconds) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Pairing window must be 10–300 seconds".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let action = match action {
|
||||||
|
"pair" => Action::Pair(seconds),
|
||||||
|
"close" => Action::ClosePairing,
|
||||||
|
"forget" => Action::Forget,
|
||||||
|
"disconnect" => Action::Disconnect,
|
||||||
|
_ => return Err(AppError::BadRequest("Unknown Bluetooth action".into())),
|
||||||
|
};
|
||||||
|
self.peripheral.action(action).await.map_err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn apply_key(report: &mut KeyboardReport, event: &KeyboardEvent) {
|
||||||
|
if let Some(bit) = event.key.modifier_bit() {
|
||||||
|
match event.event_type {
|
||||||
|
KeyEventType::Down => report.modifiers |= bit,
|
||||||
|
KeyEventType::Up => report.modifiers &= !bit,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
report.modifiers = event.modifiers.to_hid_byte();
|
||||||
|
let usage = event.key.to_hid_usage();
|
||||||
|
match event.event_type {
|
||||||
|
KeyEventType::Down if !report.keys.contains(&usage) => {
|
||||||
|
report.add_key(usage);
|
||||||
|
}
|
||||||
|
KeyEventType::Up => report.remove_key(usage),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::hid::{CanonicalKey, KeyboardModifiers};
|
||||||
|
#[test]
|
||||||
|
fn repeated_keydown_does_not_leave_stuck_keys() {
|
||||||
|
let mut report = KeyboardReport::default();
|
||||||
|
let event = KeyboardEvent {
|
||||||
|
key: CanonicalKey::KeyA,
|
||||||
|
event_type: KeyEventType::Down,
|
||||||
|
modifiers: KeyboardModifiers::default(),
|
||||||
|
};
|
||||||
|
apply_key(&mut report, &event);
|
||||||
|
apply_key(&mut report, &event);
|
||||||
|
assert_eq!(report.keys.iter().filter(|&&key| key == 4).count(), 1);
|
||||||
|
apply_key(
|
||||||
|
&mut report,
|
||||||
|
&KeyboardEvent {
|
||||||
|
event_type: KeyEventType::Up,
|
||||||
|
..event
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(report.to_bytes(), [0; 8]);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn modifier_press_and_release() {
|
||||||
|
let mut report = KeyboardReport::default();
|
||||||
|
let event = KeyboardEvent {
|
||||||
|
key: CanonicalKey::ShiftRight,
|
||||||
|
event_type: KeyEventType::Down,
|
||||||
|
modifiers: KeyboardModifiers::default(),
|
||||||
|
};
|
||||||
|
apply_key(&mut report, &event);
|
||||||
|
assert_eq!(report.modifiers, 0x20);
|
||||||
|
apply_key(
|
||||||
|
&mut report,
|
||||||
|
&KeyboardEvent {
|
||||||
|
event_type: KeyEventType::Up,
|
||||||
|
..event
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(report.modifiers, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use crate::error::{AppError, Result};
|
|||||||
use crate::otg::OtgService;
|
use crate::otg::OtgService;
|
||||||
|
|
||||||
pub struct HidBackendFactory {
|
pub struct HidBackendFactory {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub bonds: std::sync::OnceLock<Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
otg_service: Option<Arc<OtgService>>,
|
otg_service: Option<Arc<OtgService>>,
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,11 @@ pub struct HidBackendFactory {
|
|||||||
impl HidBackendFactory {
|
impl HidBackendFactory {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
|
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
|
||||||
Self { otg_service }
|
Self {
|
||||||
|
otg_service,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
bonds: Default::default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -54,6 +60,22 @@ impl HidBackendFactory {
|
|||||||
*hybrid_mouse,
|
*hybrid_mouse,
|
||||||
)?)))
|
)?)))
|
||||||
}
|
}
|
||||||
|
HidBackendType::Bluetooth { config } => {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Ok(Some(Arc::new(super::bluetooth::BluetoothBackend::new(
|
||||||
|
config.clone(),
|
||||||
|
self.bonds.get().cloned(),
|
||||||
|
)?)))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = config;
|
||||||
|
Err(AppError::Config(
|
||||||
|
"Bluetooth HID requires Linux and BlueZ".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
HidBackendType::None => {
|
HidBackendType::None => {
|
||||||
warn!("HID backend disabled");
|
warn!("HID backend disabled");
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|||||||
161
src/hid/mod.rs
161
src/hid/mod.rs
@@ -1,6 +1,8 @@
|
|||||||
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod bluetooth;
|
||||||
pub mod ch9329;
|
pub mod ch9329;
|
||||||
mod ch9329_proto;
|
mod ch9329_proto;
|
||||||
pub mod consumer;
|
pub mod consumer;
|
||||||
@@ -132,6 +134,7 @@ pub struct HidController {
|
|||||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||||
backend_available: Arc<AtomicBool>,
|
backend_available: Arc<AtomicBool>,
|
||||||
|
reset_requested: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HidController {
|
impl HidController {
|
||||||
@@ -153,6 +156,7 @@ impl HidController {
|
|||||||
hid_worker: Mutex::new(None),
|
hid_worker: Mutex::new(None),
|
||||||
runtime_worker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
backend_available: Arc::new(AtomicBool::new(false)),
|
||||||
|
reset_requested: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +178,15 @@ impl HidController {
|
|||||||
hid_worker: Mutex::new(None),
|
hid_worker: Mutex::new(None),
|
||||||
runtime_worker: Mutex::new(None),
|
runtime_worker: Mutex::new(None),
|
||||||
backend_available: Arc::new(AtomicBool::new(false)),
|
backend_available: Arc::new(AtomicBool::new(false)),
|
||||||
|
reset_requested: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn set_bond_store(&self, store: crate::db::hid_bonds::HidBondStore) {
|
||||||
|
let _ = self.backend_factory.bonds.set(Arc::new(store));
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||||
*self.events.write().await = Some(events);
|
*self.events.write().await = Some(events);
|
||||||
}
|
}
|
||||||
@@ -293,6 +303,25 @@ impl HidController {
|
|||||||
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
|
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let backend = self
|
||||||
|
.backend
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
|
||||||
|
backend.bluetooth_status().await
|
||||||
|
}
|
||||||
|
pub async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
|
||||||
|
let backend = self
|
||||||
|
.backend
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
|
||||||
|
backend.bluetooth_action(action, seconds).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn reset(&self) -> Result<()> {
|
pub async fn reset(&self) -> Result<()> {
|
||||||
if !self.backend_available.load(Ordering::Acquire) {
|
if !self.backend_available.load(Ordering::Acquire) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -349,6 +378,14 @@ impl HidController {
|
|||||||
|
|
||||||
if let Some(backend) = self.backend.write().await.take() {
|
if let Some(backend) = self.backend.write().await.take() {
|
||||||
if let Err(e) = backend.shutdown().await {
|
if let Err(e) = backend.shutdown().await {
|
||||||
|
// A Bluetooth shutdown may fail to restore adapter settings. Surface
|
||||||
|
// that failure so the config transaction can roll back.
|
||||||
|
if matches!(
|
||||||
|
*self.backend_type.read().await,
|
||||||
|
HidBackendType::Bluetooth { .. }
|
||||||
|
) {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
warn!("Error shutting down old HID backend: {}", e);
|
warn!("Error shutting down old HID backend: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +474,7 @@ impl HidController {
|
|||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let pending_move = self.pending_move.clone();
|
let pending_move = self.pending_move.clone();
|
||||||
let pending_move_flag = self.pending_move_flag.clone();
|
let pending_move_flag = self.pending_move_flag.clone();
|
||||||
|
let reset_requested = self.reset_requested.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut rx = rx;
|
let mut rx = rx;
|
||||||
@@ -446,6 +484,15 @@ impl HidController {
|
|||||||
None => break,
|
None => break,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if reset_requested.swap(false, Ordering::AcqRel) {
|
||||||
|
// A full input queue must not lose a key/button release and leave
|
||||||
|
// the host stuck. Discard the obsolete batch and send all-up.
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
*pending_move.lock() = None;
|
||||||
|
pending_move_flag.store(false, Ordering::Release);
|
||||||
|
process_hid_event(QueuedHidEvent::Reset, &backend).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
process_hid_event(event, &backend).await;
|
process_hid_event(event, &backend).await;
|
||||||
|
|
||||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||||
@@ -504,7 +551,7 @@ impl HidController {
|
|||||||
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
|
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
*self.pending_move.lock() = Some(event);
|
merge_pending_move(&mut self.pending_move.lock(), event);
|
||||||
self.pending_move_flag.store(true, Ordering::Release);
|
self.pending_move_flag.store(true, Ordering::Release);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -524,11 +571,16 @@ impl HidController {
|
|||||||
tx.send(ev),
|
tx.send(ev),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if send_result.is_ok() {
|
match send_result {
|
||||||
Ok(())
|
Ok(Ok(())) => Ok(()),
|
||||||
} else {
|
Ok(Err(_)) => Err(AppError::BadRequest("HID event queue closed".into())),
|
||||||
warn!("HID event queue full, dropping event");
|
Err(_) => {
|
||||||
Ok(())
|
self.reset_requested.store(true, Ordering::Release);
|
||||||
|
warn!("HID event queue full; scheduling all-input release");
|
||||||
|
Err(AppError::ServiceUnavailable(
|
||||||
|
"HID input queue full; input state will be reset".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
@@ -620,3 +672,100 @@ async fn apply_runtime_state(
|
|||||||
events.mark_device_info_dirty();
|
events.mark_device_info_dirty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
|
||||||
|
if let Some(previous) = pending {
|
||||||
|
if previous.event_type == MouseEventType::Move && event.event_type == MouseEventType::Move {
|
||||||
|
previous.x = previous.x.saturating_add(event.x).clamp(-32767, 32767);
|
||||||
|
previous.y = previous.y.saturating_add(event.y).clamp(-32767, 32767);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*pending = Some(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod queue_tests {
|
||||||
|
use super::*;
|
||||||
|
struct TestBackend {
|
||||||
|
pressed: Arc<AtomicBool>,
|
||||||
|
reset_done: Arc<tokio::sync::Notify>,
|
||||||
|
runtime: tokio::sync::watch::Sender<()>,
|
||||||
|
}
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl HidBackend for TestBackend {
|
||||||
|
async fn init(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_keyboard(&self, _: KeyboardEvent) -> Result<()> {
|
||||||
|
self.pressed.store(true, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_mouse(&self, _: MouseEvent) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn reset(&self) -> Result<()> {
|
||||||
|
self.pressed.store(false, Ordering::Release);
|
||||||
|
self.reset_done.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn shutdown(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||||
|
HidBackendRuntimeSnapshot::default()
|
||||||
|
}
|
||||||
|
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
|
||||||
|
self.runtime.subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn congested_queue_releases_host_instead_of_replaying_keydowns() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let controller = HidController::new(HidBackendType::None, None);
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let controller = HidController::new(HidBackendType::None);
|
||||||
|
let pressed = Arc::new(AtomicBool::new(true));
|
||||||
|
let done = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let (runtime, _) = tokio::sync::watch::channel(());
|
||||||
|
*controller.backend.write().await = Some(Arc::new(TestBackend {
|
||||||
|
pressed: pressed.clone(),
|
||||||
|
reset_done: done.clone(),
|
||||||
|
runtime,
|
||||||
|
}));
|
||||||
|
for _ in 0..HID_EVENT_QUEUE_CAPACITY {
|
||||||
|
controller
|
||||||
|
.enqueue_event(QueuedHidEvent::Keyboard(KeyboardEvent::key_down(
|
||||||
|
CanonicalKey::KeyA,
|
||||||
|
KeyboardModifiers::default(),
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(controller
|
||||||
|
.enqueue_event(QueuedHidEvent::Reset)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
controller.start_event_worker().await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), done.notified())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!pressed.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_motion_is_accumulated_but_absolute_is_replaced() {
|
||||||
|
let mut pending = Some(MouseEvent::move_rel(100, -20));
|
||||||
|
merge_pending_move(&mut pending, MouseEvent::move_rel(80, 30));
|
||||||
|
assert_eq!(
|
||||||
|
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
|
||||||
|
(180, 10)
|
||||||
|
);
|
||||||
|
merge_pending_move(&mut pending, MouseEvent::move_abs(10, 20));
|
||||||
|
merge_pending_move(&mut pending, MouseEvent::move_abs(30, 40));
|
||||||
|
assert_eq!(
|
||||||
|
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
|
||||||
|
(30, 40)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ impl RuntimeBuilder {
|
|||||||
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
|
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
let hid = Arc::new(HidController::new(hid_backend));
|
let hid = Arc::new(HidController::new(hid_backend));
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
hid.set_bond_store(config_store.hid_bonds());
|
||||||
hid.set_event_bus(events.clone()).await;
|
hid.set_event_bus(events.clone()).await;
|
||||||
if let Err(error) = hid.init().await {
|
if let Err(error) = hid.init().await {
|
||||||
tracing::warn!("Failed to initialize HID backend: {}", error);
|
tracing::warn!("Failed to initialize HID backend: {}", error);
|
||||||
@@ -412,6 +414,9 @@ fn hid_backend_type(config: &AppConfig) -> HidBackendType {
|
|||||||
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
||||||
},
|
},
|
||||||
config::HidBackend::None => HidBackendType::None,
|
config::HidBackend::None => HidBackendType::None,
|
||||||
|
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||||
|
config: config.hid.bluetooth.clone(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ impl UsbCoordinator {
|
|||||||
options: ConfigApplyOptions,
|
options: ConfigApplyOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
new_config.validate_otg_functions()?;
|
new_config.validate_otg_functions()?;
|
||||||
|
new_config.bluetooth.validate()?;
|
||||||
|
|
||||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||||
let hid_functions_changed =
|
let hid_functions_changed =
|
||||||
@@ -180,6 +181,7 @@ impl UsbCoordinator {
|
|||||||
if old_config.backend == new_config.backend
|
if old_config.backend == new_config.backend
|
||||||
&& old_config.ch9329_port == new_config.ch9329_port
|
&& old_config.ch9329_port == new_config.ch9329_port
|
||||||
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
|
||||||
|
&& old_config.bluetooth == new_config.bluetooth
|
||||||
&& !ch9329_runtime_changed
|
&& !ch9329_runtime_changed
|
||||||
&& old_config.otg_udc == new_config.otg_udc
|
&& old_config.otg_udc == new_config.otg_udc
|
||||||
&& !descriptor_changed
|
&& !descriptor_changed
|
||||||
@@ -314,6 +316,9 @@ fn hid_backend_type(config: &HidConfig) -> HidBackendType {
|
|||||||
hybrid_mouse: config.ch9329_hybrid_mouse,
|
hybrid_mouse: config.ch9329_hybrid_mouse,
|
||||||
},
|
},
|
||||||
HidBackend::None => HidBackendType::None,
|
HidBackend::None => HidBackendType::None,
|
||||||
|
HidBackend::Bluetooth => HidBackendType::Bluetooth {
|
||||||
|
config: config.bluetooth.clone(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::error::Result;
|
|||||||
use crate::web::state::UsbApiState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::types::HidConfigUpdate;
|
use super::types::HidConfigUpdate;
|
||||||
use super::usb_update::{stage_hid_config_update, update_usb_config};
|
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
|
||||||
|
|
||||||
pub async fn get_hid_config(State(state): State<UsbApiState>) -> Json<HidConfig> {
|
pub async fn get_hid_config(State(state): State<UsbApiState>) -> Json<HidConfig> {
|
||||||
Json(state.config.get().hid.clone())
|
Json(state.config.get().hid.clone())
|
||||||
@@ -15,7 +15,8 @@ pub async fn update_hid_config(
|
|||||||
State(state): State<UsbApiState>,
|
State(state): State<UsbApiState>,
|
||||||
Json(req): Json<HidConfigUpdate>,
|
Json(req): Json<HidConfigUpdate>,
|
||||||
) -> Result<Json<HidConfig>> {
|
) -> Result<Json<HidConfig>> {
|
||||||
let config = update_usb_config(&state, move |staged| {
|
let reset = req.bluetooth_reset_pairing.unwrap_or(false);
|
||||||
|
let config = update_usb_config_with_reset(&state, reset, move |staged| {
|
||||||
stage_hid_config_update(&mut staged.hid, &req)
|
stage_hid_config_update(&mut staged.hid, &req)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::otg::OtgNetworkStatus;
|
|||||||
use crate::web::state::UsbApiState;
|
use crate::web::state::UsbApiState;
|
||||||
|
|
||||||
use super::types::OtgConfigUpdate;
|
use super::types::OtgConfigUpdate;
|
||||||
use super::usb_update::{stage_hid_config_update, update_usb_config};
|
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -30,7 +30,12 @@ pub(super) async fn update_otg_config_inner(
|
|||||||
state: &UsbApiState,
|
state: &UsbApiState,
|
||||||
request: OtgConfigUpdate,
|
request: OtgConfigUpdate,
|
||||||
) -> Result<OtgConfigResponse> {
|
) -> Result<OtgConfigResponse> {
|
||||||
let staged_config = update_usb_config(state, move |staged| {
|
let reset = request
|
||||||
|
.hid
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|h| h.bluetooth_reset_pairing)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let staged_config = update_usb_config_with_reset(state, reset, move |staged| {
|
||||||
let requested_ch9329_descriptor = match request.hid.as_ref() {
|
let requested_ch9329_descriptor = match request.hid.as_ref() {
|
||||||
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
|
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
@@ -402,6 +402,9 @@ impl Ch9329DescriptorConfigUpdate {
|
|||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct HidConfigUpdate {
|
pub struct HidConfigUpdate {
|
||||||
|
/// Request-only; never persisted or replayed during startup.
|
||||||
|
pub bluetooth_reset_pairing: Option<bool>,
|
||||||
|
pub bluetooth: Option<crate::config::BluetoothHidConfig>,
|
||||||
pub backend: Option<HidBackend>,
|
pub backend: Option<HidBackend>,
|
||||||
pub ch9329_port: Option<String>,
|
pub ch9329_port: Option<String>,
|
||||||
pub ch9329_baudrate: Option<u32>,
|
pub ch9329_baudrate: Option<u32>,
|
||||||
@@ -426,6 +429,9 @@ pub struct OtgConfigUpdate {
|
|||||||
|
|
||||||
impl HidConfigUpdate {
|
impl HidConfigUpdate {
|
||||||
pub fn validate(&self) -> crate::error::Result<()> {
|
pub fn validate(&self) -> crate::error::Result<()> {
|
||||||
|
if let Some(config) = &self.bluetooth {
|
||||||
|
config.validate()?;
|
||||||
|
}
|
||||||
if let Some(baudrate) = self.ch9329_baudrate {
|
if let Some(baudrate) = self.ch9329_baudrate {
|
||||||
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
let valid_rates = [9600, 19200, 38400, 57600, 115200];
|
||||||
if !valid_rates.contains(&baudrate) {
|
if !valid_rates.contains(&baudrate) {
|
||||||
@@ -444,6 +450,9 @@ impl HidConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_to(&self, config: &mut HidConfig) {
|
pub fn apply_to(&self, config: &mut HidConfig) {
|
||||||
|
if let Some(bluetooth) = &self.bluetooth {
|
||||||
|
config.bluetooth = bluetooth.clone();
|
||||||
|
}
|
||||||
if let Some(backend) = self.backend.clone() {
|
if let Some(backend) = self.backend.clone() {
|
||||||
config.backend = backend;
|
config.backend = backend;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ pub(super) fn stage_hid_config_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn update_usb_config<F>(state: &UsbApiState, stage_update: F) -> Result<AppConfig>
|
pub(super) async fn update_usb_config<F>(state: &UsbApiState, stage_update: F) -> Result<AppConfig>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut AppConfig) -> Result<Option<Ch9329DescriptorConfig>>,
|
||||||
|
{
|
||||||
|
update_usb_config_with_reset(state, false, stage_update).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn update_usb_config_with_reset<F>(
|
||||||
|
state: &UsbApiState,
|
||||||
|
reset: bool,
|
||||||
|
stage_update: F,
|
||||||
|
) -> Result<AppConfig>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut AppConfig) -> Result<Option<Ch9329DescriptorConfig>>,
|
F: FnOnce(&mut AppConfig) -> Result<Option<Ch9329DescriptorConfig>>,
|
||||||
{
|
{
|
||||||
@@ -53,12 +64,118 @@ where
|
|||||||
staged_config.uac.validate()?;
|
staged_config.uac.validate()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staged_config.hid.bluetooth.validate()?;
|
||||||
|
if reset && staged_config.hid.backend != HidBackend::Bluetooth {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Pairing reset requires Bluetooth HID".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
if reset {
|
||||||
|
return Err(AppError::BadRequest("Bluetooth HID requires Linux".into()));
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if reset {
|
||||||
|
use one_kvm_bluetooth_hid::{
|
||||||
|
adapters,
|
||||||
|
bonds::{self, Bond, BondStore},
|
||||||
|
};
|
||||||
|
let adapters = adapters().await.map_err(AppError::Config)?;
|
||||||
|
if !adapters
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.name == staged_config.hid.bluetooth.adapter)
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Selected Bluetooth adapter is missing".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut explicit = Vec::new();
|
||||||
|
let mut runtime = state.hid.bluetooth_status().await.ok();
|
||||||
|
if runtime
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|s| s["initialized"].as_bool() == Some(true))
|
||||||
|
{
|
||||||
|
// Publish the latest bond before shutdown, including pairing completed
|
||||||
|
// since the last 500 ms status update. No new peers can enter afterward.
|
||||||
|
state.hid.bluetooth_action("close", 120).await?;
|
||||||
|
runtime = state.hid.bluetooth_status().await.ok();
|
||||||
|
}
|
||||||
|
if let Some(status) = runtime {
|
||||||
|
if let (Some(adapter), Some(peer)) =
|
||||||
|
(status["adapter_address"].as_str(), status["peer"].as_str())
|
||||||
|
{
|
||||||
|
if !adapter.is_empty() {
|
||||||
|
explicit.push(Bond {
|
||||||
|
adapter: adapter.into(),
|
||||||
|
peer: peer.into(),
|
||||||
|
pending: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let owned = state
|
||||||
|
.config
|
||||||
|
.hid_bonds()
|
||||||
|
.list()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Config)?;
|
||||||
|
for config in [&old_config.hid.bluetooth, &staged_config.hid.bluetooth] {
|
||||||
|
// A recorded hardware address takes precedence over a potentially
|
||||||
|
// renumbered hci index. Runtime targets above also carry real addresses.
|
||||||
|
if config.peer.as_ref().is_some_and(|peer| {
|
||||||
|
owned
|
||||||
|
.iter()
|
||||||
|
.chain(explicit.iter())
|
||||||
|
.any(|bond| bond.peer.eq_ignore_ascii_case(peer))
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Some(peer), Some(adapter)) = (
|
||||||
|
&config.peer,
|
||||||
|
adapters.iter().find(|a| a.name == config.adapter),
|
||||||
|
) {
|
||||||
|
explicit.push(Bond {
|
||||||
|
adapter: adapter.address.clone(),
|
||||||
|
peer: peer.clone(),
|
||||||
|
pending: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop observation before writing tombstones or removing bonds.
|
||||||
|
if old_config.hid.backend == HidBackend::Bluetooth {
|
||||||
|
state.hid.reload(crate::hid::HidBackendType::None).await?;
|
||||||
|
}
|
||||||
|
if let Err(error) = bonds::reset(&state.config.hid_bonds(), explicit).await {
|
||||||
|
return Err(AppError::Config(format!("Bluetooth binding cleanup failed; some old bindings may already be cleared and require pairing again: {error}")));
|
||||||
|
}
|
||||||
|
staged_config.hid.bluetooth.peer = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = async {
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
.coordinator
|
.coordinator
|
||||||
.apply_config(&old_config, &staged_config)
|
.apply_config(&old_config, &staged_config)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await);
|
return Err(
|
||||||
|
rollback_after_failure(state, &staged_config, &old_config, error, false).await,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if reset
|
||||||
|
&& !matches!(
|
||||||
|
state.hid.backend_type().await,
|
||||||
|
crate::hid::HidBackendType::Bluetooth { .. }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Even identical device selections must rebuild with no pinned peer.
|
||||||
|
state
|
||||||
|
.hid
|
||||||
|
.reload(crate::hid::HidBackendType::Bluetooth {
|
||||||
|
config: staged_config.hid.bluetooth.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
||||||
@@ -111,6 +228,11 @@ where
|
|||||||
|
|
||||||
Ok(staged_config)
|
Ok(staged_config)
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
result.map_err(|error| if reset {
|
||||||
|
AppError::Config(format!("Old One-KVM HID bindings were cleared; pair again. Configuration apply failed: {error}"))
|
||||||
|
} else { error })
|
||||||
|
}
|
||||||
|
|
||||||
async fn rollback_after_failure(
|
async fn rollback_after_failure(
|
||||||
state: &UsbApiState,
|
state: &UsbApiState,
|
||||||
@@ -156,6 +278,8 @@ mod tests {
|
|||||||
|
|
||||||
fn hid_update() -> HidConfigUpdate {
|
fn hid_update() -> HidConfigUpdate {
|
||||||
HidConfigUpdate {
|
HidConfigUpdate {
|
||||||
|
bluetooth_reset_pairing: None,
|
||||||
|
bluetooth: None,
|
||||||
backend: None,
|
backend: None,
|
||||||
ch9329_port: None,
|
ch9329_port: None,
|
||||||
ch9329_baudrate: None,
|
ch9329_baudrate: None,
|
||||||
@@ -170,6 +294,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_is_request_only_and_feature_updates_preserve_device_selection() {
|
||||||
|
let mut hid = HidConfig::default();
|
||||||
|
hid.backend = HidBackend::Ch9329;
|
||||||
|
hid.ch9329_port = "COM7".into();
|
||||||
|
let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({
|
||||||
|
"ch9329_hybrid_mouse": true, "bluetooth_reset_pairing": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
stage_hid_config_update(&mut hid, &update).unwrap();
|
||||||
|
assert_eq!(hid.backend, HidBackend::Ch9329);
|
||||||
|
assert_eq!(hid.ch9329_port, "COM7");
|
||||||
|
assert!(serde_json::to_value(&hid)
|
||||||
|
.unwrap()
|
||||||
|
.get("bluetooth_reset_pairing")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_target_does_not_mutate_staged_config() {
|
||||||
|
let mut hid = HidConfig::default();
|
||||||
|
let before = serde_json::to_value(&hid).unwrap();
|
||||||
|
let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({
|
||||||
|
"backend": "bluetooth", "bluetooth_reset_pairing": true,
|
||||||
|
"bluetooth": { "adapter": "hci0", "name": "" }
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(stage_hid_config_update(&mut hid, &update).is_err());
|
||||||
|
assert_eq!(serde_json::to_value(hid).unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stages_regular_hid_fields_immediately() {
|
fn stages_regular_hid_fields_immediately() {
|
||||||
let mut hid = HidConfig::default();
|
let mut hid = HidConfig::default();
|
||||||
|
|||||||
@@ -112,3 +112,43 @@ fn cached_ch9329_descriptor(
|
|||||||
descriptor,
|
descriptor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BluetoothAction {
|
||||||
|
pub action: String,
|
||||||
|
pub seconds: Option<u32>,
|
||||||
|
}
|
||||||
|
pub async fn hid_bluetooth_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
Ok(Json(state.hid.bluetooth_status().await?))
|
||||||
|
}
|
||||||
|
pub async fn hid_bluetooth_action(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<BluetoothAction>,
|
||||||
|
) -> Result<Json<serde_json::Value>> {
|
||||||
|
let _guard = crate::runtime::try_apply_lock(&state.config_apply_locks.otg, "bluetooth")?;
|
||||||
|
state
|
||||||
|
.hid
|
||||||
|
.bluetooth_action(&req.action, req.seconds.unwrap_or(120))
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"success": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn hid_bluetooth_adapters() -> Result<Json<serde_json::Value>> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::to_value(
|
||||||
|
one_kvm_bluetooth_hid::adapters()
|
||||||
|
.await
|
||||||
|
.map_err(AppError::ServiceUnavailable)?,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
Err(AppError::BadRequest("Bluetooth HID requires Linux".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/webrtc/close", post(handlers::webrtc_close_session))
|
.route("/webrtc/close", post(handlers::webrtc_close_session))
|
||||||
// HID endpoints
|
// HID endpoints
|
||||||
.route("/hid/status", get(handlers::hid_status))
|
.route("/hid/status", get(handlers::hid_status))
|
||||||
|
.route(
|
||||||
|
"/hid/bluetooth/adapters",
|
||||||
|
get(handlers::hid_bluetooth_adapters),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/hid/bluetooth",
|
||||||
|
get(handlers::hid_bluetooth_status).post(handlers::hid_bluetooth_action),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/hid/ch9329/descriptor",
|
"/hid/ch9329/descriptor",
|
||||||
get(handlers::hid_ch9329_descriptor),
|
get(handlers::hid_ch9329_descriptor),
|
||||||
|
|||||||
Reference in New Issue
Block a user