diff --git a/config/redbear-bluetooth-experimental.toml b/config/redbear-bluetooth-experimental.toml index 567af06d..fd8cfd0d 100644 --- a/config/redbear-bluetooth-experimental.toml +++ b/config/redbear-bluetooth-experimental.toml @@ -7,7 +7,7 @@ # The current slice is explicit-startup, USB-attached, BLE-first, and intentionally not wired to # USB-class autospawn yet. -include = ["redbear-minimal.toml"] +include = ["redbear-minimal.toml", "redbear-bluetooth-services.toml"] [general] filesystem_size = 2048 @@ -15,29 +15,3 @@ filesystem_size = 2048 [packages] redbear-btusb = {} redbear-btctl = {} - -[[files]] -path = "/var/lib/bluetooth" -data = "" -directory = true -mode = 0o755 - -[[files]] -path = "/var/run/redbear-btusb" -data = "" -directory = true -mode = 0o755 - -[[files]] -path = "/usr/lib/init.d/11_btctl.service" -data = """ -[unit] -description = "Bluetooth host/control daemon" -requires_weak = [ - "05_firmware-loader.service", -] - -[service] -cmd = "redbear-btctl" -type = { scheme = "btctl" } -""" diff --git a/config/redbear-bluetooth-services.toml b/config/redbear-bluetooth-services.toml new file mode 100644 index 00000000..29408c4b --- /dev/null +++ b/config/redbear-bluetooth-services.toml @@ -0,0 +1,17 @@ +# Red Bear OS Bluetooth experimental service wiring +# +# Kept in a dedicated included fragment so the Bluetooth profile can inject +# bounded runtime files and service units without relying on profile-local +# [[files]] behavior. + +[[files]] +path = "/var/lib/bluetooth" +data = "" +directory = true +mode = 0o755 + +[[files]] +path = "/var/run/redbear-btusb" +data = "" +directory = true +mode = 0o755 diff --git a/local/recipes/drivers/redbear-btusb/source/src/main.rs b/local/recipes/drivers/redbear-btusb/source/src/main.rs index 3e13d6c4..fdab60d7 100644 --- a/local/recipes/drivers/redbear-btusb/source/src/main.rs +++ b/local/recipes/drivers/redbear-btusb/source/src/main.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process; #[cfg(target_os = "redox")] @@ -8,10 +9,68 @@ use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; const STATUS_FRESHNESS_SECS: u64 = 90; +const BLUETOOTH_USB_CLASS: u8 = 0xE0; +const BLUETOOTH_USB_SUBCLASS: u8 = 0x01; +const BLUETOOTH_USB_PROTOCOL: u8 = 0x01; +const KNOWN_BLUETOOTH_USB_VENDORS: [u16; 4] = [0x8087, 0x0BDA, 0x0A5C, 0x0A12]; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UsbBluetoothAdapter { + name: String, + vendor_id: u16, + device_id: u16, + bus: String, + device_path: PathBuf, +} + +impl UsbBluetoothAdapter { + #[cfg(any(not(target_os = "redox"), test))] + fn stub(name: String) -> Self { + Self { + device_path: PathBuf::from(format!("/scheme/usb/stub/{name}")), + name, + vendor_id: 0, + device_id: 0, + bus: "stub".to_string(), + } + } + + fn detail_line(&self, index: usize) -> String { + format!( + "adapter_{index}=name={};vendor_id={:04x};device_id={:04x};bus={};device_path={}", + self.name, + self.vendor_id, + self.device_id, + self.bus, + self.device_path.display() + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct UsbDeviceDescriptor { + vendor_id: u16, + device_id: u16, + class: u8, + subclass: u8, + protocol: u8, +} + +impl UsbDeviceDescriptor { + fn looks_like_bluetooth(self) -> bool { + (self.class, self.subclass, self.protocol) + == ( + BLUETOOTH_USB_CLASS, + BLUETOOTH_USB_SUBCLASS, + BLUETOOTH_USB_PROTOCOL, + ) + || KNOWN_BLUETOOTH_USB_VENDORS.contains(&self.vendor_id) + } +} #[derive(Clone, Debug, PartialEq, Eq)] struct TransportConfig { - adapters: Vec, + adapters: Vec, controller_family: String, status_file: PathBuf, } @@ -19,10 +78,7 @@ struct TransportConfig { impl TransportConfig { fn from_env() -> Self { Self { - adapters: parse_list( - std::env::var("REDBEAR_BTUSB_STUB_ADAPTERS").ok().as_deref(), - &["hci0"], - ), + adapters: default_adapters_from_env(), controller_family: std::env::var("REDBEAR_BTUSB_STUB_FAMILY") .unwrap_or_else(|_| "usb-generic-bounded".to_string()), status_file: std::env::var_os("REDBEAR_BTUSB_STATUS_FILE") @@ -31,14 +87,37 @@ impl TransportConfig { } } + fn adapter_names(&self) -> Vec { + self.adapters + .iter() + .map(|adapter| adapter.name.clone()) + .collect() + } + + fn refreshed(&self) -> Self { + let mut refreshed = self.clone(); + if let Ok(adapters) = runtime_usb_bluetooth_adapters() { + refreshed.adapters = adapters; + } + refreshed + } + fn probe_lines(&self) -> Vec { - vec![ - format!("adapters={}", self.adapters.join(",")), + let mut lines = vec![ + format!("adapters={}", self.adapter_names().join(",")), "transport=usb".to_string(), "startup=explicit".to_string(), "mode=ble-first".to_string(), format!("controller_family={}", self.controller_family), - ] + format!("adapter_count={}", self.adapters.len()), + ]; + lines.extend( + self.adapters + .iter() + .enumerate() + .map(|(index, adapter)| adapter.detail_line(index)), + ); + lines } fn render_status_lines(&self, runtime_visible: bool) -> Vec { @@ -105,6 +184,35 @@ enum CommandOutcome { RunDaemon, } +#[cfg(any(not(target_os = "redox"), test))] +fn default_adapters_from_names(names: Vec) -> Vec { + names.into_iter().map(UsbBluetoothAdapter::stub).collect() +} + +#[cfg(target_os = "redox")] +fn default_adapters_from_env() -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "redox"))] +fn default_adapters_from_env() -> Vec { + default_adapters_from_names(parse_list( + std::env::var("REDBEAR_BTUSB_STUB_ADAPTERS").ok().as_deref(), + &["hci0"], + )) +} + +#[cfg(target_os = "redox")] +fn runtime_usb_bluetooth_adapters() -> Result, String> { + probe_usb_bluetooth_adapters() +} + +#[cfg(not(target_os = "redox"))] +fn runtime_usb_bluetooth_adapters() -> Result, String> { + Ok(default_adapters_from_env()) +} + +#[cfg(any(not(target_os = "redox"), test))] fn parse_list(raw: Option<&str>, default: &[&str]) -> Vec { raw.map(|value| { value @@ -165,9 +273,16 @@ fn parse_command(args: &[String]) -> Result { } fn execute(command: Command, config: &TransportConfig) -> CommandOutcome { + let effective_config = match command { + Command::Probe | Command::Status => config.refreshed(), + Command::Daemon => config.clone(), + }; + match command { - Command::Probe => CommandOutcome::Print(format_lines(&config.probe_lines())), - Command::Status => CommandOutcome::Print(format_lines(&config.current_status_lines())), + Command::Probe => CommandOutcome::Print(format_lines(&effective_config.probe_lines())), + Command::Status => { + CommandOutcome::Print(format_lines(&effective_config.current_status_lines())) + } Command::Daemon => CommandOutcome::RunDaemon, } } @@ -197,6 +312,195 @@ fn main() { } } +fn parse_numeric_value(value: &str) -> Result { + let trimmed = value.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16).map_err(|err| format!("invalid hex value {trimmed:?}: {err}")) + } else { + trimmed + .parse::() + .map_err(|err| format!("invalid numeric value {trimmed:?}: {err}")) + } +} + +fn extract_jsonish_u64(raw: &str, keys: &[&str]) -> Result { + for key in keys { + let needle = format!("\"{key}\""); + let Some(key_start) = raw.find(&needle) else { + continue; + }; + let after_key = &raw[key_start + needle.len()..]; + let Some(colon_index) = after_key.find(':') else { + continue; + }; + + let after_colon = after_key[colon_index + 1..].trim_start(); + if after_colon.is_empty() { + continue; + } + + let token = if let Some(quoted) = after_colon.strip_prefix('"') { + let Some(end_quote) = quoted.find('"') else { + continue; + }; + "ed[..end_quote] + } else { + let end = after_colon + .find(|ch: char| matches!(ch, ',' | '}' | '\n' | '\r')) + .unwrap_or(after_colon.len()); + after_colon[..end].trim() + }; + + if token.is_empty() { + continue; + } + + return parse_numeric_value(token); + } + + Err(format!( + "missing descriptor field; expected one of {keys:?}" + )) +} + +fn parse_usb_device_descriptor(raw: &[u8]) -> Result { + let text = String::from_utf8_lossy(raw); + let vendor_id = extract_jsonish_u64(&text, &["vendor", "vendor_id"]).and_then(|value| { + u16::try_from(value).map_err(|_| format!("vendor ID out of range: {value}")) + })?; + let device_id = extract_jsonish_u64(&text, &["product", "device", "product_id", "device_id"]) + .and_then(|value| { + u16::try_from(value).map_err(|_| format!("device ID out of range: {value}")) + })?; + let class = extract_jsonish_u64(&text, &["class", "device_class"]).and_then(|value| { + u8::try_from(value).map_err(|_| format!("USB class out of range: {value}")) + })?; + let subclass = extract_jsonish_u64(&text, &["sub_class", "subclass", "device_subclass"]) + .and_then(|value| { + u8::try_from(value).map_err(|_| format!("USB subclass out of range: {value}")) + })?; + let protocol = + extract_jsonish_u64(&text, &["protocol", "device_protocol"]).and_then(|value| { + u8::try_from(value).map_err(|_| format!("USB protocol out of range: {value}")) + })?; + + Ok(UsbDeviceDescriptor { + vendor_id, + device_id, + class, + subclass, + protocol, + }) +} + +fn try_collect_bluetooth_adapter( + adapters: &mut Vec, + bus: &str, + device_path: &Path, +) -> Result<(), String> { + let descriptor_path = device_path.join("descriptors"); + let raw = match fs::read(&descriptor_path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(_) => return Ok(()), + }; + + let descriptor = match parse_usb_device_descriptor(&raw) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(()), + }; + if descriptor.looks_like_bluetooth() { + adapters.push(UsbBluetoothAdapter { + name: String::new(), + vendor_id: descriptor.vendor_id, + device_id: descriptor.device_id, + bus: bus.to_string(), + device_path: device_path.to_path_buf(), + }); + } + + Ok(()) +} + +fn probe_usb_bluetooth_adapters_in(root: &Path) -> Result, String> { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(format!( + "failed to read USB scheme root {}: {err}", + root.display() + )); + } + }; + + let mut adapters = Vec::new(); + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + let bus_name = entry.file_name().to_string_lossy().into_owned(); + if bus_name.is_empty() { + continue; + } + + let bus_path = root.join(&bus_name); + try_collect_bluetooth_adapter(&mut adapters, &bus_name, &bus_path)?; + + let nested_entries = match fs::read_dir(&bus_path) { + Ok(nested_entries) => nested_entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(_) => continue, + }; + + for nested_entry in nested_entries { + let nested_entry = match nested_entry { + Ok(nested_entry) => nested_entry, + Err(_) => continue, + }; + + let nested_name = nested_entry.file_name().to_string_lossy().into_owned(); + if nested_name.is_empty() { + continue; + } + + let device_path = bus_path.join(&nested_name); + try_collect_bluetooth_adapter(&mut adapters, &bus_name, &device_path)?; + } + } + + adapters.sort_by(|left, right| { + left.bus + .cmp(&right.bus) + .then(left.device_path.cmp(&right.device_path)) + .then(left.vendor_id.cmp(&right.vendor_id)) + .then(left.device_id.cmp(&right.device_id)) + }); + + for (index, adapter) in adapters.iter_mut().enumerate() { + adapter.name = format!("hci{index}"); + } + + Ok(adapters) +} + +#[cfg(target_os = "redox")] +fn probe_usb_bluetooth_adapters() -> Result, String> { + probe_usb_bluetooth_adapters_in(Path::new("/scheme/usb")) +} + +#[cfg(not(target_os = "redox"))] +#[allow(dead_code)] +fn probe_usb_bluetooth_adapters() -> Result, String> { + probe_usb_bluetooth_adapters_in(Path::new("/scheme/usb")) +} + #[cfg(not(target_os = "redox"))] fn daemon_main(_config: &TransportConfig) -> Result<(), String> { Err("daemon mode is only supported on Redox; use --probe or --status on host".to_string()) @@ -214,14 +518,16 @@ fn daemon_main(config: &TransportConfig) -> Result<(), String> { } } - config.write_status_file()?; + let mut runtime_config = config.refreshed(); + runtime_config.write_status_file()?; let _status_file_guard = StatusFileGuard { path: &config.status_file, }; loop { thread::sleep(Duration::from_secs(30)); - config.write_status_file()?; + runtime_config = config.refreshed(); + runtime_config.write_status_file()?; } } @@ -239,14 +545,25 @@ mod tests { env::temp_dir().join(format!("{name}-{stamp}")) } + fn stub_adapter(name: &str) -> UsbBluetoothAdapter { + UsbBluetoothAdapter::stub(name.to_string()) + } + fn test_config(status_file: PathBuf) -> TransportConfig { TransportConfig { - adapters: vec!["hci0".to_string()], + adapters: vec![stub_adapter("hci0")], controller_family: "usb-bounded-test".to_string(), status_file, } } + fn write_descriptor(path: &Path, body: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, body).unwrap(); + } + #[test] fn probe_contract_is_bounded_and_usb_scoped() { let output = execute(Command::Probe, &test_config(temp_path("rbos-btusb-status"))); @@ -317,4 +634,72 @@ mod tests { ); assert_eq!(parse_command(&[]).unwrap(), Command::Daemon); } + + #[test] + fn probe_usb_bluetooth_adapters_filters_and_enumerates_devices() { + let root = temp_path("rbos-btusb-usb-root"); + write_descriptor( + &root + .join("usb.0000:00:14.0") + .join("port1") + .join("descriptors"), + r#"{"class":224,"sub_class":1,"protocol":1,"vendor":32903,"product":50}"#, + ); + write_descriptor( + &root + .join("usb.0000:00:14.0") + .join("port2") + .join("descriptors"), + r#"{"class":3,"sub_class":1,"protocol":1,"vendor":4660,"product":22136}"#, + ); + write_descriptor( + &root + .join("usb.0000:00:15.0") + .join("port3") + .join("descriptors"), + r#"{"class":224,"sub_class":1,"protocol":1,"vendor":3034,"product":4660}"#, + ); + + let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap(); + assert_eq!(adapters.len(), 2); + assert_eq!(adapters[0].name, "hci0"); + assert_eq!(adapters[0].vendor_id, 0x8087u16); + assert_eq!(adapters[0].device_id, 0x0032u16); + assert_eq!(adapters[0].bus, "usb.0000:00:14.0"); + assert!(adapters[0] + .device_path + .ends_with(Path::new("usb.0000:00:14.0/port1"))); + assert_eq!(adapters[1].name, "hci1"); + assert_eq!(adapters[1].vendor_id, 0x0bdau16); + assert_eq!(adapters[1].device_id, 0x1234u16); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn probe_usb_bluetooth_adapters_accepts_known_vendor_fallback() { + let root = temp_path("rbos-btusb-known-vendor"); + write_descriptor( + &root + .join("usb.0000:00:16.0") + .join("port7") + .join("descriptors"), + r#"{"class":255,"sub_class":255,"protocol":255,"vendor":2652,"product":4660}"#, + ); + + let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap(); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].name, "hci0"); + assert_eq!(adapters[0].vendor_id, 0x0a5cu16); + assert_eq!(adapters[0].device_id, 0x1234u16); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn probe_usb_bluetooth_adapters_handles_missing_usb_tree() { + let root = temp_path("rbos-btusb-missing-root"); + let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap(); + assert!(adapters.is_empty()); + } } diff --git a/local/recipes/system/redbear-btctl/recipe.toml b/local/recipes/system/redbear-btctl/recipe.toml index eefb2d4b..68b7aeaa 100644 --- a/local/recipes/system/redbear-btctl/recipe.toml +++ b/local/recipes/system/redbear-btctl/recipe.toml @@ -2,7 +2,12 @@ path = "source" [build] -template = "cargo" +template = "custom" +script = """ +set -ex -[package.files] -"/usr/bin/redbear-btctl" = "redbear-btctl" +cookbook_cargo + +mkdir -pv "$COOKBOOK_STAGE/usr/lib/init.d" +cp -v "$COOKBOOK_SOURCE/11_btctl.service" "$COOKBOOK_STAGE/usr/lib/init.d/11_btctl.service" +""" diff --git a/local/recipes/system/redbear-btctl/source/11_btctl.service b/local/recipes/system/redbear-btctl/source/11_btctl.service new file mode 100644 index 00000000..f3b9747d --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/11_btctl.service @@ -0,0 +1,9 @@ +[unit] +description = "Bluetooth host/control daemon" +requires_weak = [ + "05_firmware-loader.service", +] + +[service] +cmd = "redbear-btctl" +type = { scheme = "btctl" }