USB: comprehensive hotplug with Linux 7.1 port event state machine

Cross-referenced with Linux 7.1 drivers/usb/core/hub.c:
- hub_port_debounce() (line 4698): 25ms-step debounce algorithm
  with 100ms stable threshold, 2000ms timeout per USB 2.0 §7.1.7.3
- hub_port_connect_change() (line 5055): connect/disconnect dispatch
- hub_irq() / port_event(): interrupt-driven change detection

PortState enum (replaces ad-hoc bool tracking):
  Disconnected → DebouncingConnect → Connected → Active → Disconnected

Debounce: stable_start timestamp, 4x 25ms checks for 100ms
stable window.  Connection drop during debounce → reset.
Timeout after 2000ms → warn + remove.

Phase-based main loop:
  Phase 1: scan controllers/ports → detect new connections
  Phase 2: debounce + state transitions per port
    - DebouncingConnect: check stability against stable_start
    - Connected: read descriptors → find_driver → spawn
    - Active: check driver exit (try_wait) + disconnect detection
  Phase 3: cleanup (remove disconnected, time out stale debounces)

Driver re-enumeration on crash: Active → Connected transition
on driver exit re-triggers descriptor read + spawn.

read_port_connected() uses XhciClientHandle open success as
connectivity check (lighter than full descriptor read).
This commit is contained in:
2026-07-07 16:04:28 +03:00
parent 87b85ca083
commit f79954e2bc
@@ -1,25 +1,30 @@
//! USB device hotplug daemon — comprehensive.
//! USB device hotplug daemon — comprehensive port event state machine.
//!
//! Cross-referenced with Linux 7.1 `drivers/usb/core/hub.c` port event
//! handling and `drivers/usb/core/driver.c` device-driver binding.
//! Cross-referenced with Linux 7.1 `drivers/usb/core/hub.c`:
//! - hub_port_connect_change() (line 5055+): port connect/disconnect dispatch
//! - hub_port_debounce() (line 4698+): 25ms-step debounce algorithm
//! - hub_port_connect() (line 4939+): full device enumeration sequence
//! - hub_irq() / port_event(): interrupt-driven change detection
//!
//! Monitors all USB controllers for device connect/disconnect events.
//! When a device appears, reads its descriptors to determine class/subclass/
//! protocol, maps to the correct class driver binary, and spawns it.
//! Handles both root hub ports and child hub ports (recursive scanning).
//! When a device disconnects or its driver exits, cleans up the process.
//! Implements the USB 2.0 §7.1.7.3 connect debounce requirement:
//! 100ms stable connection before reporting connect, 2000ms timeout.
//! Follows Linux's HUB_DEBOUNCE_STEP=25ms, HUB_DEBOUNCE_STABLE=100ms,
//! HUB_DEBOUNCE_TIMEOUT=2000ms constants.
use std::collections::HashMap;
use std::fs;
use std::process::{Child, Command};
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use xhcid_interface::XhciClientHandle;
// USB class → driver binary mapping, extended with all Red Bear class drivers.
// Cross-referenced with Linux 7.1 include/uapi/linux/usb/ch9.h and
// usb-core spawn.rs. Subclass-aware for CDC (ACM vs ECM) and Audio.
// USB 2.0 §7.1.7.3 debounce timing.
// Linux 7.1 drivers/usb/core/hub.c:138-140.
const DEBOUNCE_STEP_MS: u64 = 25;
const DEBOUNCE_STABLE_MS: u64 = 100;
const DEBOUNCE_TIMEOUT_MS: u64 = 2000;
const DRIVER_MAP: &[(u8, u8, &str, &str)] = &[
(0x01, 0x01, "/usr/bin/redbear-usbaudiod", "0"),
(0x01, 0x02, "/usr/bin/redbear-usbaudiod", "0"),
@@ -34,10 +39,22 @@ const DRIVER_MAP: &[(u8, u8, &str, &str)] = &[
(0xFF, 0xFF, "/usr/bin/redbear-ftdi", "0"),
];
const POLL_INTERVAL_MS: u64 = 1000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PortState {
Disconnected,
DebouncingConnect { stable_start: Instant },
Connected,
Active,
}
struct TrackedDevice {
child: Child,
state: PortState,
child: Option<Child>,
class: u8,
subclass: u8,
protocol: u8,
scheme: String,
port_str: String,
}
fn find_driver(class: u8, subclass: u8, protocol: u8) -> Option<(&'static str, String)> {
@@ -98,36 +115,39 @@ fn extract_port_id(full_port_path: &str) -> Option<(String, String)> {
if parts.len() < 4 { return None; }
let controller = parts.get(3)?;
let port_start = full_port_path.find(controller)? + controller.len() + 1;
let port_str = &full_port_path[port_start..];
Some((controller.to_string(), port_str.to_string()))
Some((controller.to_string(), full_port_path[port_start..].to_string()))
}
fn try_read_descriptors(scheme: &str, _controller: &str, port_str: &str) -> Option<(u8, u8, u8)> {
fn read_port_connected(scheme: &str, port_str: &str) -> Option<bool> {
let port_id: u8 = if let Some(dot_pos) = port_str.find('.') {
port_str[4..dot_pos].parse().ok()?
} else {
port_str.strip_prefix("port")?.parse().ok()?
};
use xhcid_interface::PortId;
let pid = PortId { root_hub_port_num: port_id, route_string: 0 };
XhciClientHandle::new(scheme.to_string(), pid).ok().map(|_| true)
}
fn read_descriptors(scheme: &str, port_str: &str) -> Option<(u8, u8, u8)> {
let port_id: u8 = if let Some(dot_pos) = port_str.find('.') {
port_str[4..dot_pos].parse().ok()?
} else {
port_str.strip_prefix("port")?.parse().ok()?
};
use xhcid_interface::PortId;
let pid = PortId { root_hub_port_num: port_id, route_string: 0 };
match XhciClientHandle::new(scheme.to_string(), pid) {
Ok(handle) => match handle.get_standard_descs() {
Ok(desc) => {
for conf in &desc.config_descs {
for ifd in &conf.interface_descs {
if ifd.class != 0x09 && ifd.class != 0x0A {
return Some((ifd.class, ifd.sub_class, ifd.protocol));
}
}
XhciClientHandle::new(scheme.to_string(), pid).ok()?.get_standard_descs().ok().and_then(|desc| {
for conf in &desc.config_descs {
for ifd in &conf.interface_descs {
if ifd.class != 0x09 && ifd.class != 0x0A {
return Some((ifd.class, ifd.sub_class, ifd.protocol));
}
None
}
Err(_) => None,
},
Err(_) => None,
}
}
None
})
}
fn main() {
@@ -136,13 +156,16 @@ fn main() {
common::setup_logging("usb", "system", "usb-hotplugd",
common::output_level(), common::file_level());
log::info!("USB hotplug daemon started — polling every {}ms", POLL_INTERVAL_MS);
log::info!("USB hotplug daemon — debounce {}/{}/{}ms per Linux 7.1 hub_port_debounce()",
DEBOUNCE_STEP_MS, DEBOUNCE_STABLE_MS, DEBOUNCE_TIMEOUT_MS);
let mut tracked: HashMap<String, TrackedDevice> = HashMap::new();
loop {
let now = Instant::now();
let controllers = scan_controllers();
// Phase 1: discover new ports (Linux 7.1 hub_irq → port_event)
for ctrl_name in &controllers {
let scheme_name = format!("usb.{}", ctrl_name);
let port_paths = scan_ports(ctrl_name);
@@ -151,64 +174,104 @@ fn main() {
if tracked.contains_key(port_path) {
continue;
}
let (ref ctrl, ref port_str) = match extract_port_id(port_path) {
Some(x) => x,
None => continue,
};
if let Some((class, subclass, protocol)) = try_read_descriptors(&scheme_name, ctrl, port_str) {
log::info!("USB device on {}: class={:02X} subclass={:02X} protocol={:02X}",
port_path, class, subclass, protocol);
if let Some((driver_binary, extra_arg)) = find_driver(class, subclass, protocol) {
let short_port = port_str.strip_prefix("port").unwrap_or(port_str);
log::info!("Spawning {} for {} (scheme={} port={} arg={})",
driver_binary, port_path, scheme_name, short_port, extra_arg);
match Command::new(driver_binary)
.arg(&scheme_name)
.arg(short_port)
.arg(&extra_arg)
.stdin(std::process::Stdio::null())
.spawn()
{
Ok(child) => {
tracked.insert(port_path.clone(), TrackedDevice { child });
}
Err(e) => {
log::warn!("Failed to spawn {} for {}: {}", driver_binary, port_path, e);
}
}
}
let connected = read_port_connected(&scheme_name, port_str).unwrap_or(false);
if connected {
log::info!("port {}: new connection, entering debounce", port_path);
tracked.insert(port_path.clone(), TrackedDevice {
state: PortState::DebouncingConnect { stable_start: now },
child: None, class: 0, subclass: 0, protocol: 0,
scheme: scheme_name.clone(), port_str: port_str.clone(),
});
}
}
}
let mut removed: Vec<String> = Vec::new();
// Phase 2: debounce + state transitions (Linux 7.1 hub_port_debounce)
for (port_path, dev) in &mut tracked {
match dev.child.try_wait() {
Ok(Some(status)) => {
log::info!("Driver for {} exited ({:?})", port_path, status);
removed.push(port_path.clone());
let scheme = dev.scheme.clone();
let port_str = dev.port_str.clone();
let connected = read_port_connected(&scheme, &port_str).unwrap_or(false);
match dev.state {
PortState::Disconnected => {}
PortState::DebouncingConnect { stable_start } => {
if !connected {
log::info!("port {}: dropped during debounce", port_path);
dev.state = PortState::Disconnected;
} else if now.duration_since(stable_start) >= Duration::from_millis(DEBOUNCE_STABLE_MS) {
log::info!("port {}: debounce passed ({}ms)", port_path,
now.duration_since(stable_start).as_millis());
dev.state = PortState::Connected;
}
}
Ok(None) => {
if let Some((ref ctrl, ref port_str)) = extract_port_id(port_path) {
let scheme = format!("usb.{}", ctrl);
if try_read_descriptors(&scheme, ctrl, port_str).is_none() {
log::info!("Device disconnected from {}", port_path);
let _ = dev.child.kill();
removed.push(port_path.clone());
PortState::Connected => {
if !connected {
log::info!("port {}: disconnected before enumeration", port_path);
dev.state = PortState::Disconnected;
} else if let Some((class, subclass, protocol)) = read_descriptors(&scheme, &port_str) {
log::info!("port {}: class={:02X} subclass={:02X} protocol={:02X}",
port_path, class, subclass, protocol);
dev.class = class; dev.subclass = subclass; dev.protocol = protocol;
if let Some((driver_binary, extra_arg)) = find_driver(class, subclass, protocol) {
let short_port = port_str.strip_prefix("port").unwrap_or(&port_str);
log::info!("spawning {} (scheme={} port={} arg={})",
driver_binary, scheme, short_port, extra_arg);
match Command::new(driver_binary)
.arg(&scheme).arg(short_port).arg(&extra_arg)
.stdin(std::process::Stdio::null()).spawn()
{
Ok(child) => {
dev.child = Some(child);
dev.state = PortState::Active;
}
Err(e) => {
log::warn!("spawn {}: {}", driver_binary, e);
}
}
} else {
dev.state = PortState::Active;
}
}
}
Err(e) => log::warn!("Driver check error for {}: {}", port_path, e),
PortState::Active => {
if let Some(ref mut child) = dev.child {
if let Ok(Some(status)) = child.try_wait() {
log::info!("port {}: driver exited ({:?})", port_path, status);
dev.child = None;
dev.state = PortState::Connected;
}
}
if !connected {
log::info!("port {}: device disconnected", port_path);
if let Some(ref mut child) = dev.child { let _ = child.kill(); }
dev.child = None;
dev.state = PortState::Disconnected;
}
}
}
}
for key in &removed {
tracked.remove(key);
}
thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
// Phase 3: remove disconnected entries, time out stale debounces
let mut remove: Vec<String> = Vec::new();
for (key, dev) in &tracked {
if dev.state == PortState::Disconnected { remove.push(key.clone()); }
if let PortState::DebouncingConnect { stable_start } = dev.state {
if now.duration_since(stable_start) >= Duration::from_millis(DEBOUNCE_TIMEOUT_MS) {
log::warn!("port {}: debounce timed out", key);
remove.push(key.clone());
}
}
}
for key in &remove { tracked.remove(key); }
thread::sleep(Duration::from_millis(DEBOUNCE_STEP_MS));
}
}