diff --git a/config/redbear-bluetooth-experimental.toml b/config/redbear-bluetooth-experimental.toml new file mode 100644 index 00000000..567af06d --- /dev/null +++ b/config/redbear-bluetooth-experimental.toml @@ -0,0 +1,43 @@ +# Red Bear OS Bluetooth Experimental Profile +# +# Standalone build target for the first bounded Bluetooth slice. +# +# This profile extends the existing minimal Red Bear baseline but keeps Bluetooth wiring isolated to +# this profile instead of leaking it into the shared device-service fragments used by all images. +# The current slice is explicit-startup, USB-attached, BLE-first, and intentionally not wired to +# USB-class autospawn yet. + +include = ["redbear-minimal.toml"] + +[general] +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/local/recipes/drivers/redbear-btusb/recipe.toml b/local/recipes/drivers/redbear-btusb/recipe.toml new file mode 100644 index 00000000..7fe1ab5a --- /dev/null +++ b/local/recipes/drivers/redbear-btusb/recipe.toml @@ -0,0 +1,8 @@ +[source] +path = "source" + +[build] +template = "cargo" + +[package.files] +"/usr/bin/redbear-btusb" = "redbear-btusb" diff --git a/local/recipes/drivers/redbear-btusb/source/Cargo.toml b/local/recipes/drivers/redbear-btusb/source/Cargo.toml new file mode 100644 index 00000000..83ac67e1 --- /dev/null +++ b/local/recipes/drivers/redbear-btusb/source/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "redbear-btusb" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-btusb" +path = "src/main.rs" diff --git a/local/recipes/drivers/redbear-btusb/source/src/main.rs b/local/recipes/drivers/redbear-btusb/source/src/main.rs new file mode 100644 index 00000000..3e13d6c4 --- /dev/null +++ b/local/recipes/drivers/redbear-btusb/source/src/main.rs @@ -0,0 +1,320 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process; +#[cfg(target_os = "redox")] +use std::thread; +#[cfg(target_os = "redox")] +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +const STATUS_FRESHNESS_SECS: u64 = 90; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TransportConfig { + adapters: Vec, + controller_family: String, + status_file: PathBuf, +} + +impl TransportConfig { + fn from_env() -> Self { + Self { + adapters: parse_list( + std::env::var("REDBEAR_BTUSB_STUB_ADAPTERS").ok().as_deref(), + &["hci0"], + ), + 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") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/run/redbear-btusb/status")), + } + } + + fn probe_lines(&self) -> Vec { + vec![ + format!("adapters={}", self.adapters.join(",")), + "transport=usb".to_string(), + "startup=explicit".to_string(), + "mode=ble-first".to_string(), + format!("controller_family={}", self.controller_family), + ] + } + + fn render_status_lines(&self, runtime_visible: bool) -> Vec { + let mut lines = self.probe_lines(); + lines.push(format!("updated_at_epoch={}", current_epoch_seconds())); + lines.push(format!( + "runtime_visibility={}", + if runtime_visible { + "runtime-visible" + } else { + "installed-only" + } + )); + lines.push(format!( + "daemon_status={}", + if runtime_visible { + "running" + } else { + "inactive" + } + )); + lines.push(format!("status_file={}", self.status_file.display())); + lines + } + + fn current_status_lines(&self) -> Vec { + read_status_lines(&self.status_file) + .filter(|lines| status_lines_are_fresh(lines)) + .unwrap_or_else(|| self.render_status_lines(false)) + } + + fn write_status_file(&self) -> Result<(), String> { + if let Some(parent) = self.status_file.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create transport status directory {}: {err}", + parent.display() + ) + })?; + } + fs::write( + &self.status_file, + format_lines(&self.render_status_lines(true)), + ) + .map_err(|err| { + format!( + "failed to write transport status file {}: {err}", + self.status_file.display() + ) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Command { + Probe, + Status, + Daemon, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CommandOutcome { + Print(String), + RunDaemon, +} + +fn parse_list(raw: Option<&str>, default: &[&str]) -> Vec { + raw.map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| default.iter().map(|value| (*value).to_string()).collect()) +} + +fn format_lines(lines: &[String]) -> String { + if lines.is_empty() { + "\n".to_string() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn current_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn read_status_lines(path: &Path) -> Option> { + let content = fs::read_to_string(path).ok()?; + let lines = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + Some(lines) +} + +fn status_lines_are_fresh(lines: &[String]) -> bool { + let updated_at = lines.iter().find_map(|line| { + line.strip_prefix("updated_at_epoch=") + .and_then(|value| value.parse::().ok()) + }); + + updated_at + .map(|timestamp| current_epoch_seconds().saturating_sub(timestamp) <= STATUS_FRESHNESS_SECS) + .unwrap_or(false) +} + +fn parse_command(args: &[String]) -> Result { + match args.first().map(String::as_str) { + Some("--probe") => Ok(Command::Probe), + Some("--status") => Ok(Command::Status), + Some("--daemon") | None => Ok(Command::Daemon), + Some(other) => Err(format!("unknown argument: {other}")), + } +} + +fn execute(command: Command, config: &TransportConfig) -> CommandOutcome { + match command { + Command::Probe => CommandOutcome::Print(format_lines(&config.probe_lines())), + Command::Status => CommandOutcome::Print(format_lines(&config.current_status_lines())), + Command::Daemon => CommandOutcome::RunDaemon, + } +} + +fn main() { + let args = std::env::args().skip(1).collect::>(); + let config = TransportConfig::from_env(); + + let command = match parse_command(&args) { + Ok(command) => command, + Err(err) => { + eprintln!("redbear-btusb: {err}"); + process::exit(1); + } + }; + + match execute(command, &config) { + CommandOutcome::Print(output) => { + print!("{output}"); + } + CommandOutcome::RunDaemon => { + if let Err(err) = daemon_main(&config) { + eprintln!("redbear-btusb: {err}"); + process::exit(1); + } + } + } +} + +#[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()) +} + +#[cfg(target_os = "redox")] +fn daemon_main(config: &TransportConfig) -> Result<(), String> { + struct StatusFileGuard<'a> { + path: &'a Path, + } + + impl Drop for StatusFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.path); + } + } + + config.write_status_file()?; + let _status_file_guard = StatusFileGuard { + path: &config.status_file, + }; + + loop { + thread::sleep(Duration::from_secs(30)); + config.write_status_file()?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{name}-{stamp}")) + } + + fn test_config(status_file: PathBuf) -> TransportConfig { + TransportConfig { + adapters: vec!["hci0".to_string()], + controller_family: "usb-bounded-test".to_string(), + status_file, + } + } + + #[test] + fn probe_contract_is_bounded_and_usb_scoped() { + let output = execute(Command::Probe, &test_config(temp_path("rbos-btusb-status"))); + let CommandOutcome::Print(output) = output else { + panic!("expected printable output"); + }; + assert!(output.contains("adapters=hci0")); + assert!(output.contains("transport=usb")); + assert!(output.contains("startup=explicit")); + assert!(output.contains("mode=ble-first")); + } + + #[test] + fn status_defaults_to_installed_only_without_runtime_file() { + let status_file = temp_path("rbos-btusb-status-missing"); + let output = execute(Command::Status, &test_config(status_file)); + let CommandOutcome::Print(output) = output else { + panic!("expected printable output"); + }; + assert!(output.contains("runtime_visibility=installed-only")); + assert!(output.contains("daemon_status=inactive")); + } + + #[test] + fn status_uses_runtime_file_when_present() { + let status_file = temp_path("rbos-btusb-status-present"); + let config = test_config(status_file.clone()); + config.write_status_file().unwrap(); + + let output = execute(Command::Status, &config); + let CommandOutcome::Print(output) = output else { + panic!("expected printable output"); + }; + assert!(output.contains("runtime_visibility=runtime-visible")); + assert!(output.contains("daemon_status=running")); + + fs::remove_file(status_file).unwrap(); + } + + #[test] + fn stale_status_file_is_treated_as_installed_only() { + let status_file = temp_path("rbos-btusb-status-stale"); + fs::write( + &status_file, + "adapters=hci0\ntransport=usb\nstartup=explicit\nmode=ble-first\nupdated_at_epoch=1\nruntime_visibility=runtime-visible\ndaemon_status=running\n", + ) + .unwrap(); + + let output = execute(Command::Status, &test_config(status_file.clone())); + let CommandOutcome::Print(output) = output else { + panic!("expected printable output"); + }; + assert!(output.contains("runtime_visibility=installed-only")); + assert!(output.contains("daemon_status=inactive")); + + fs::remove_file(status_file).unwrap(); + } + + #[test] + fn parse_command_accepts_probe_status_and_daemon() { + assert_eq!( + parse_command(&["--probe".to_string()]).unwrap(), + Command::Probe + ); + assert_eq!( + parse_command(&["--status".to_string()]).unwrap(), + Command::Status + ); + assert_eq!(parse_command(&[]).unwrap(), Command::Daemon); + } +} diff --git a/local/recipes/system/redbear-btctl/recipe.toml b/local/recipes/system/redbear-btctl/recipe.toml new file mode 100644 index 00000000..eefb2d4b --- /dev/null +++ b/local/recipes/system/redbear-btctl/recipe.toml @@ -0,0 +1,8 @@ +[source] +path = "source" + +[build] +template = "cargo" + +[package.files] +"/usr/bin/redbear-btctl" = "redbear-btctl" diff --git a/local/recipes/system/redbear-btctl/source/Cargo.toml b/local/recipes/system/redbear-btctl/source/Cargo.toml new file mode 100644 index 00000000..f00e73d9 --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "redbear-btctl" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "redbear-btctl" +path = "src/main.rs" + +[dependencies] +libc = "0.2" +libredox = { version = "0.1", features = ["call", "std"] } +log = { version = "0.4", features = ["std"] } +redox-scheme = "0.11" +syscall = { package = "redox_syscall", version = "0.7", features = ["std"] } diff --git a/local/recipes/system/redbear-btctl/source/src/backend.rs b/local/recipes/system/redbear-btctl/source/src/backend.rs new file mode 100644 index 00000000..73836613 --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/src/backend.rs @@ -0,0 +1,912 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::bond_store::{validate_adapter_name, BondRecord, BondStore}; + +const STATUS_FRESHNESS_SECS: u64 = 90; +const EXPERIMENTAL_WORKLOAD: &str = "battery-sensor-battery-level-read"; +const EXPERIMENTAL_PERIPHERAL_CLASS: &str = "ble-battery-sensor"; +const EXPERIMENTAL_CHARACTERISTIC: &str = "battery-level"; +const EXPERIMENTAL_SERVICE_UUID: &str = "0000180f-0000-1000-8000-00805f9b34fb"; +const EXPERIMENTAL_CHAR_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb"; +const EXPERIMENTAL_VALUE_HEX: &str = "57"; +const EXPERIMENTAL_VALUE_PERCENT: u8 = 87; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AdapterStatus { + ExplicitStartupRequired, + AdapterVisible, + Scanning, + Failed, +} + +impl AdapterStatus { + pub fn as_str(&self) -> &'static str { + match self { + AdapterStatus::ExplicitStartupRequired => "explicit-startup-required", + AdapterStatus::AdapterVisible => "adapter-visible", + AdapterStatus::Scanning => "scanning", + AdapterStatus::Failed => "failed", + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct AdapterState { + pub status: String, + pub transport_status: String, + pub last_error: String, + pub scan_results: Vec, + pub connected_bond_ids: Vec, + pub connect_result: String, + pub disconnect_result: String, + pub read_char_result: String, + pub bond_store_path: String, + pub bonds: Vec, +} + +#[derive(Clone, Debug, Default)] +struct AdapterRuntimeState { + connected_bond_ids: BTreeSet, + last_connect_result: String, + last_disconnect_result: String, + last_read_char_result: String, +} + +impl AdapterRuntimeState { + fn new() -> Self { + Self { + last_connect_result: "connect_result=not-run".to_string(), + last_disconnect_result: "disconnect_result=not-run".to_string(), + last_read_char_result: default_read_char_result(), + ..Self::default() + } + } +} + +pub fn connection_state_lines(connected_bond_ids: &[String]) -> Vec { + vec![ + format!( + "connection_state={}", + if connected_bond_ids.is_empty() { + "stub-disconnected" + } else { + "stub-connected" + } + ), + format!("connected_bond_count={}", connected_bond_ids.len()), + format!("connected_bond_ids={}", connected_bond_ids.join(",")), + format!( + "note=stub-control-only-no-real-link-layer-beyond-experimental-{}", + EXPERIMENTAL_WORKLOAD + ), + ] +} + +fn normalize_uuid(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn default_read_char_result() -> String { + format!( + "read_char_result=not-run workload={} peripheral_class={} characteristic={} service_uuid={} char_uuid={} access=read-only", + EXPERIMENTAL_WORKLOAD, + EXPERIMENTAL_PERIPHERAL_CLASS, + EXPERIMENTAL_CHARACTERISTIC, + EXPERIMENTAL_SERVICE_UUID, + EXPERIMENTAL_CHAR_UUID + ) +} + +fn rejected_read_char_result( + reason: &str, + bond_id: &str, + service_uuid: &str, + char_uuid: &str, +) -> String { + format!( + "read_char_result={} workload={} peripheral_class={} characteristic={} bond_id={} service_uuid={} char_uuid={} access=read-only supported_service_uuid={} supported_char_uuid={}", + reason, + EXPERIMENTAL_WORKLOAD, + EXPERIMENTAL_PERIPHERAL_CLASS, + EXPERIMENTAL_CHARACTERISTIC, + bond_id, + normalize_uuid(service_uuid), + normalize_uuid(char_uuid), + EXPERIMENTAL_SERVICE_UUID, + EXPERIMENTAL_CHAR_UUID + ) +} + +fn success_read_char_result(bond_id: &str) -> String { + format!( + "read_char_result=stub-value workload={} peripheral_class={} characteristic={} bond_id={} service_uuid={} char_uuid={} access=read-only value_hex={} value_percent={}", + EXPERIMENTAL_WORKLOAD, + EXPERIMENTAL_PERIPHERAL_CLASS, + EXPERIMENTAL_CHARACTERISTIC, + bond_id, + EXPERIMENTAL_SERVICE_UUID, + EXPERIMENTAL_CHAR_UUID, + EXPERIMENTAL_VALUE_HEX, + EXPERIMENTAL_VALUE_PERCENT + ) +} + +pub trait Backend { + fn adapters(&self) -> Vec; + fn capabilities(&self) -> Vec; + fn initial_status(&self, adapter: &str) -> AdapterStatus; + fn transport_status(&self, adapter: &str) -> String; + fn default_scan_results(&self, adapter: &str) -> Vec; + fn connected_bond_ids(&self, adapter: &str) -> Result, String>; + fn connect_result(&self, adapter: &str) -> Result; + fn disconnect_result(&self, adapter: &str) -> Result; + fn read_char_result(&self, adapter: &str) -> Result; + fn status(&self, adapter: &str) -> Result; + fn scan(&mut self, adapter: &str) -> Result, String>; + fn connect(&mut self, adapter: &str, bond_id: &str) -> Result<(), String>; + fn disconnect(&mut self, adapter: &str, bond_id: &str) -> Result<(), String>; + fn read_char( + &mut self, + adapter: &str, + bond_id: &str, + service_uuid: &str, + char_uuid: &str, + ) -> Result<(), String>; + fn bond_store_path(&self, adapter: &str) -> Result; + fn load_bonds(&self, adapter: &str) -> Result, String>; + fn add_stub_bond( + &mut self, + adapter: &str, + bond_id: &str, + alias: Option<&str>, + ) -> Result; + fn remove_bond(&mut self, adapter: &str, bond_id: &str) -> Result; +} + +pub struct StubBackend { + adapters: Vec, + scan_results: Vec, + transport_status_file: PathBuf, + bond_store: BondStore, + runtime_state: BTreeMap, +} + +impl StubBackend { + pub fn from_env() -> Self { + let adapters = parse_list( + env::var("REDBEAR_BTCTL_STUB_ADAPTERS").ok().as_deref(), + &["hci0"], + ); + let seeded_connected_bond_ids = parse_list( + env::var("REDBEAR_BTCTL_STUB_CONNECTED_BOND_IDS") + .ok() + .as_deref(), + &[], + ); + for adapter in &adapters { + if validate_adapter_name(adapter).is_err() { + panic!("invalid Bluetooth adapter name in REDBEAR_BTCTL_STUB_ADAPTERS: {adapter}"); + } + } + + Self { + runtime_state: adapters + .iter() + .cloned() + .map(|adapter| { + let mut state = AdapterRuntimeState::new(); + state.connected_bond_ids = seeded_connected_bond_ids.iter().cloned().collect(); + (adapter, state) + }) + .collect(), + adapters, + scan_results: parse_list( + env::var("REDBEAR_BTCTL_STUB_SCAN_RESULTS").ok().as_deref(), + &["demo-beacon", "demo-sensor"], + ), + transport_status_file: env::var_os("REDBEAR_BTCTL_TRANSPORT_STATUS_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/run/redbear-btusb/status")), + bond_store: BondStore::from_env(), + } + } + + #[cfg(test)] + pub fn new_for_test( + adapters: Vec, + scan_results: Vec, + transport_status_file: PathBuf, + bond_store_root: PathBuf, + ) -> Self { + for adapter in &adapters { + assert!( + validate_adapter_name(adapter).is_ok(), + "invalid test adapter name: {adapter}" + ); + } + + Self { + runtime_state: adapters + .iter() + .cloned() + .map(|adapter| (adapter, AdapterRuntimeState::new())) + .collect(), + adapters, + scan_results, + transport_status_file, + bond_store: BondStore::new(bond_store_root), + } + } + + fn knows_adapter(&self, adapter: &str) -> bool { + self.adapters.iter().any(|candidate| candidate == adapter) + } + + fn runtime_visible(&self) -> bool { + fs::read_to_string(&self.transport_status_file) + .map(|content| transport_status_is_runtime_visible(&content)) + .unwrap_or(false) + } + + fn read_transport_status(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let parts = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } + } + + fn ensure_adapter(&self, adapter: &str) -> Result<(), String> { + if self.knows_adapter(adapter) { + Ok(()) + } else { + Err("unknown Bluetooth adapter".to_string()) + } + } + + fn runtime_state(&self, adapter: &str) -> Result<&AdapterRuntimeState, String> { + self.runtime_state + .get(adapter) + .ok_or_else(|| "unknown Bluetooth adapter".to_string()) + } + + fn runtime_state_mut(&mut self, adapter: &str) -> Result<&mut AdapterRuntimeState, String> { + self.runtime_state + .get_mut(adapter) + .ok_or_else(|| "unknown Bluetooth adapter".to_string()) + } + + fn bond_exists(&self, adapter: &str, bond_id: &str) -> Result { + Ok(self + .load_bonds(adapter)? + .iter() + .any(|bond| bond.bond_id == bond_id)) + } +} + +fn current_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn transport_status_is_runtime_visible(content: &str) -> bool { + let runtime_visible = content + .lines() + .map(str::trim) + .any(|line| line == "runtime_visibility=runtime-visible"); + + let updated_at = content.lines().find_map(|line| { + line.trim() + .strip_prefix("updated_at_epoch=") + .and_then(|value| value.parse::().ok()) + }); + + runtime_visible + && updated_at + .map(|timestamp| { + current_epoch_seconds().saturating_sub(timestamp) <= STATUS_FRESHNESS_SECS + }) + .unwrap_or(false) +} + +impl Backend for StubBackend { + fn adapters(&self) -> Vec { + self.adapters.clone() + } + + fn capabilities(&self) -> Vec { + vec![ + "backend=stub".to_string(), + "transport=usb".to_string(), + "startup=explicit".to_string(), + "mode=ble-first".to_string(), + "scan=true".to_string(), + format!("workload={}", EXPERIMENTAL_WORKLOAD), + "read_char=true".to_string(), + "write_char=false".to_string(), + "notify=false".to_string(), + "bond_store=stub-cli".to_string(), + "scheme=btctl".to_string(), + format!("status_file={}", self.transport_status_file.display()), + format!("bond_store_root={}", self.bond_store.root().display()), + ] + } + + fn initial_status(&self, adapter: &str) -> AdapterStatus { + if !self.knows_adapter(adapter) { + AdapterStatus::Failed + } else if self.runtime_visible() { + AdapterStatus::AdapterVisible + } else { + AdapterStatus::ExplicitStartupRequired + } + } + + fn transport_status(&self, adapter: &str) -> String { + if !self.knows_adapter(adapter) { + return "transport=unknown-adapter".to_string(); + } + + if self.runtime_visible() { + return Self::read_transport_status(&self.transport_status_file).unwrap_or_else(|| { + format!( + "transport=usb startup=explicit runtime_visibility=installed-only status_file={}", + self.transport_status_file.display() + ) + }); + } + + format!( + "transport=usb startup=explicit runtime_visibility=installed-only status_file={}", + self.transport_status_file.display() + ) + } + + fn default_scan_results(&self, _adapter: &str) -> Vec { + Vec::new() + } + + fn connected_bond_ids(&self, adapter: &str) -> Result, String> { + self.ensure_adapter(adapter)?; + Ok(self + .runtime_state(adapter)? + .connected_bond_ids + .iter() + .cloned() + .collect()) + } + + fn connect_result(&self, adapter: &str) -> Result { + self.ensure_adapter(adapter)?; + Ok(self.runtime_state(adapter)?.last_connect_result.clone()) + } + + fn disconnect_result(&self, adapter: &str) -> Result { + self.ensure_adapter(adapter)?; + Ok(self.runtime_state(adapter)?.last_disconnect_result.clone()) + } + + fn read_char_result(&self, adapter: &str) -> Result { + self.ensure_adapter(adapter)?; + Ok(self.runtime_state(adapter)?.last_read_char_result.clone()) + } + + fn status(&self, adapter: &str) -> Result { + self.ensure_adapter(adapter)?; + Ok(self.initial_status(adapter)) + } + + fn scan(&mut self, adapter: &str) -> Result, String> { + self.ensure_adapter(adapter)?; + if !self.runtime_visible() { + return Err( + "transport not runtime-visible; start redbear-btusb explicitly".to_string(), + ); + } + Ok(self.scan_results.clone()) + } + + fn connect(&mut self, adapter: &str, bond_id: &str) -> Result<(), String> { + self.ensure_adapter(adapter)?; + + if !self.runtime_visible() { + let state = self.runtime_state_mut(adapter)?; + state.last_connect_result = + format!("connect_result=rejected-transport-not-runtime-visible bond_id={bond_id}"); + return Err( + "transport not runtime-visible; start redbear-btusb explicitly".to_string(), + ); + } + + if !self.bond_exists(adapter, bond_id)? { + let state = self.runtime_state_mut(adapter)?; + state.last_connect_result = + format!("connect_result=rejected-missing-bond bond_id={bond_id}"); + return Err("bond record not found; add a stub bond record first".to_string()); + } + + let state = self.runtime_state_mut(adapter)?; + let outcome = if state.connected_bond_ids.insert(bond_id.to_string()) { + "connected" + } else { + "already-connected" + }; + state.last_connect_result = + format!("connect_result=stub-connected bond_id={bond_id} state={outcome}"); + Ok(()) + } + + fn disconnect(&mut self, adapter: &str, bond_id: &str) -> Result<(), String> { + self.ensure_adapter(adapter)?; + + if !self.runtime_visible() { + let state = self.runtime_state_mut(adapter)?; + state.last_disconnect_result = format!( + "disconnect_result=rejected-transport-not-runtime-visible bond_id={bond_id}" + ); + return Err( + "transport not runtime-visible; start redbear-btusb explicitly".to_string(), + ); + } + + if !self.bond_exists(adapter, bond_id)? { + let state = self.runtime_state_mut(adapter)?; + state.last_disconnect_result = + format!("disconnect_result=rejected-missing-bond bond_id={bond_id}"); + return Err("bond record not found; add a stub bond record first".to_string()); + } + + let state = self.runtime_state_mut(adapter)?; + let outcome = if state.connected_bond_ids.remove(bond_id) { + "disconnected" + } else { + "already-disconnected" + }; + state.last_disconnect_result = + format!("disconnect_result=stub-disconnected bond_id={bond_id} state={outcome}"); + Ok(()) + } + + fn read_char( + &mut self, + adapter: &str, + bond_id: &str, + service_uuid: &str, + char_uuid: &str, + ) -> Result<(), String> { + self.ensure_adapter(adapter)?; + + if !self.runtime_visible() { + let state = self.runtime_state_mut(adapter)?; + state.last_read_char_result = rejected_read_char_result( + "rejected-transport-not-runtime-visible", + bond_id, + service_uuid, + char_uuid, + ); + return Err( + "transport not runtime-visible; start redbear-btusb explicitly".to_string(), + ); + } + + if !self.bond_exists(adapter, bond_id)? { + let state = self.runtime_state_mut(adapter)?; + state.last_read_char_result = rejected_read_char_result( + "rejected-missing-bond", + bond_id, + service_uuid, + char_uuid, + ); + return Err("bond record not found; add a stub bond record first".to_string()); + } + + if !self + .runtime_state(adapter)? + .connected_bond_ids + .contains(bond_id) + { + let state = self.runtime_state_mut(adapter)?; + state.last_read_char_result = rejected_read_char_result( + "rejected-not-connected", + bond_id, + service_uuid, + char_uuid, + ); + return Err( + "bond is not connected; run --connect before the experimental read".to_string(), + ); + } + + if normalize_uuid(service_uuid) != EXPERIMENTAL_SERVICE_UUID + || normalize_uuid(char_uuid) != EXPERIMENTAL_CHAR_UUID + { + let state = self.runtime_state_mut(adapter)?; + state.last_read_char_result = rejected_read_char_result( + "rejected-unsupported-characteristic", + bond_id, + service_uuid, + char_uuid, + ); + return Err(format!( + "only the experimental {} workload is supported: service {} characteristic {}", + EXPERIMENTAL_WORKLOAD, EXPERIMENTAL_SERVICE_UUID, EXPERIMENTAL_CHAR_UUID + )); + } + + self.runtime_state_mut(adapter)?.last_read_char_result = success_read_char_result(bond_id); + Ok(()) + } + + fn bond_store_path(&self, adapter: &str) -> Result { + self.ensure_adapter(adapter)?; + Ok(self + .bond_store + .adapter_bonds_dir(adapter) + .display() + .to_string()) + } + + fn load_bonds(&self, adapter: &str) -> Result, String> { + self.ensure_adapter(adapter)?; + self.bond_store + .load(adapter) + .map_err(|err| format!("failed to load bond store: {err}")) + } + + fn add_stub_bond( + &mut self, + adapter: &str, + bond_id: &str, + alias: Option<&str>, + ) -> Result { + self.ensure_adapter(adapter)?; + self.bond_store + .add_stub(adapter, bond_id, alias) + .map_err(|err| format!("failed to persist stub bond record: {err}")) + } + + fn remove_bond(&mut self, adapter: &str, bond_id: &str) -> Result { + self.ensure_adapter(adapter)?; + let removed = self + .bond_store + .remove(adapter, bond_id) + .map_err(|err| format!("failed to remove stub bond record: {err}"))?; + + if removed { + let state = self.runtime_state_mut(adapter)?; + if state.connected_bond_ids.remove(bond_id) { + state.last_disconnect_result = format!( + "disconnect_result=stub-disconnected bond_id={bond_id} state=removed-with-bond" + ); + } + } + + Ok(removed) + } +} + +fn parse_list(raw: Option<&str>, default: &[&str]) -> Vec { + raw.map(|value| { + value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect::>() + }) + .filter(|entries| !entries.is_empty()) + .unwrap_or_else(|| default.iter().map(|entry| (*entry).to_string()).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{name}-{stamp}")) + } + + #[test] + fn stub_status_requires_explicit_transport_startup() { + let backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-missing-transport"), + temp_path("rbos-btctl-bond-store-missing"), + ); + assert_eq!( + backend.initial_status("hci0"), + AdapterStatus::ExplicitStartupRequired + ); + assert!(backend + .transport_status("hci0") + .contains("runtime_visibility=installed-only")); + } + + #[test] + fn stub_scan_uses_transport_status_file() { + let status_path = temp_path("rbos-btctl-transport-present"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + current_epoch_seconds() + ), + ) + .unwrap(); + + let mut backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string(), "demo-sensor".to_string()], + status_path.clone(), + temp_path("rbos-btctl-bond-store-visible"), + ); + + assert_eq!( + backend.status("hci0").unwrap(), + AdapterStatus::AdapterVisible + ); + assert_eq!( + backend.scan("hci0").unwrap(), + vec!["demo-beacon".to_string(), "demo-sensor".to_string()] + ); + + fs::remove_file(status_path).unwrap(); + } + + #[test] + fn stale_transport_status_requires_explicit_startup() { + let status_path = temp_path("rbos-btctl-transport-stale"); + fs::write( + &status_path, + "transport=usb\nstartup=explicit\nupdated_at_epoch=1\nruntime_visibility=runtime-visible\n", + ) + .unwrap(); + + let backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + status_path.clone(), + temp_path("rbos-btctl-bond-store-stale"), + ); + + assert_eq!( + backend.initial_status("hci0"), + AdapterStatus::ExplicitStartupRequired + ); + + fs::remove_file(status_path).unwrap(); + } + + #[test] + fn connect_requires_runtime_visible_transport() { + let bond_store_root = temp_path("rbos-btctl-connect-missing-transport-bonds"); + let mut backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-connect-missing-transport"), + bond_store_root.clone(), + ); + + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + + let err = backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap_err(); + assert!(err.contains("start redbear-btusb explicitly")); + assert_eq!( + backend.connected_bond_ids("hci0").unwrap(), + Vec::::new() + ); + assert_eq!( + backend.connect_result("hci0").unwrap(), + "connect_result=rejected-transport-not-runtime-visible bond_id=AA:BB:CC:DD:EE:FF" + ); + + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn read_char_requires_connected_bond_and_exact_workload_uuid_pair() { + let status_path = temp_path("rbos-btctl-read-char-visible"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + current_epoch_seconds() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-read-char-visible-bonds"); + let mut backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + status_path.clone(), + bond_store_root.clone(), + ); + + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-battery-sensor")) + .unwrap(); + + let err = backend + .read_char( + "hci0", + "AA:BB:CC:DD:EE:FF", + EXPERIMENTAL_SERVICE_UUID, + EXPERIMENTAL_CHAR_UUID, + ) + .unwrap_err(); + assert!(err.contains("run --connect")); + assert!(backend + .read_char_result("hci0") + .unwrap() + .contains("read_char_result=rejected-not-connected")); + + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let unsupported = backend + .read_char( + "hci0", + "AA:BB:CC:DD:EE:FF", + EXPERIMENTAL_SERVICE_UUID, + "00002a1a-0000-1000-8000-00805f9b34fb", + ) + .unwrap_err(); + assert!(unsupported.contains("only the experimental")); + assert!(backend + .read_char_result("hci0") + .unwrap() + .contains("read_char_result=rejected-unsupported-characteristic")); + + backend + .read_char( + "hci0", + "AA:BB:CC:DD:EE:FF", + EXPERIMENTAL_SERVICE_UUID, + EXPERIMENTAL_CHAR_UUID, + ) + .unwrap(); + + let result = backend.read_char_result("hci0").unwrap(); + assert!(result.contains("read_char_result=stub-value")); + assert!(result.contains(&format!("workload={}", EXPERIMENTAL_WORKLOAD))); + assert!(result.contains("bond_id=AA:BB:CC:DD:EE:FF")); + assert!(result.contains("access=read-only")); + assert!(result.contains("value_percent=87")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn connect_and_disconnect_track_stub_connection_state() { + let status_path = temp_path("rbos-btctl-connect-visible"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + current_epoch_seconds() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-connect-visible-bonds"); + let mut backend = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + status_path.clone(), + bond_store_root.clone(), + ); + + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + assert_eq!( + backend.connected_bond_ids("hci0").unwrap(), + vec!["AA:BB:CC:DD:EE:FF".to_string()] + ); + assert_eq!( + backend.connect_result("hci0").unwrap(), + "connect_result=stub-connected bond_id=AA:BB:CC:DD:EE:FF state=already-connected" + ); + + backend.disconnect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + backend.disconnect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + assert_eq!( + backend.connected_bond_ids("hci0").unwrap(), + Vec::::new() + ); + assert_eq!( + backend.disconnect_result("hci0").unwrap(), + "disconnect_result=stub-disconnected bond_id=AA:BB:CC:DD:EE:FF state=already-disconnected" + ); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn invalid_adapter_names_are_rejected_in_test_backend() { + let result = std::panic::catch_unwind(|| { + StubBackend::new_for_test( + vec!["../escape".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-invalid-adapter-status"), + temp_path("rbos-btctl-invalid-adapter-bonds"), + ) + }); + + assert!(result.is_err()); + + let dot_result = std::panic::catch_unwind(|| { + StubBackend::new_for_test( + vec!["..".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-dotdot-status"), + temp_path("rbos-btctl-dotdot-bonds"), + ) + }); + + assert!(dot_result.is_err()); + } + + #[test] + fn stub_bond_store_persists_across_backend_instances() { + let bond_store_root = temp_path("rbos-btctl-bond-store-persist"); + let mut writer = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-transport-unused"), + bond_store_root.clone(), + ); + + let record = writer + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + assert_eq!(record.source, "stub-cli"); + + let reader = StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string()], + temp_path("rbos-btctl-transport-unused-reader"), + bond_store_root.clone(), + ); + + let bonds = reader.load_bonds("hci0").unwrap(); + assert_eq!(bonds.len(), 1); + assert_eq!(bonds[0].bond_id, "AA:BB:CC:DD:EE:FF"); + assert_eq!(bonds[0].alias.as_deref(), Some("demo-sensor")); + assert_eq!( + reader.bond_store_path("hci0").unwrap(), + bond_store_root + .join("hci0") + .join("bonds") + .display() + .to_string() + ); + + fs::remove_dir_all(bond_store_root).unwrap(); + } +} diff --git a/local/recipes/system/redbear-btctl/source/src/bond_store.rs b/local/recipes/system/redbear-btctl/source/src/bond_store.rs new file mode 100644 index 00000000..847adaad --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/src/bond_store.rs @@ -0,0 +1,291 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const DEFAULT_BOND_STORE_ROOT: &str = "/var/lib/bluetooth"; +pub const STUB_BOND_SOURCE: &str = "stub-cli"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BondRecord { + pub bond_id: String, + pub alias: Option, + pub created_at_epoch: u64, + pub source: String, +} + +#[derive(Clone, Debug)] +pub struct BondStore { + root: PathBuf, +} + +impl BondStore { + pub fn from_env() -> Self { + Self { + root: env::var_os("REDBEAR_BTCTL_BOND_STORE_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_BOND_STORE_ROOT)), + } + } + + #[cfg(test)] + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn adapter_bonds_dir(&self, adapter: &str) -> PathBuf { + debug_assert!(validate_adapter_name(adapter).is_ok()); + self.root.join(adapter).join("bonds") + } + + pub fn load(&self, adapter: &str) -> io::Result> { + validate_adapter_name(adapter)?; + let dir = self.adapter_bonds_dir(adapter); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut bonds = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + bonds.push(parse_record(&path)?); + } + bonds.sort_by(|left, right| left.bond_id.cmp(&right.bond_id)); + Ok(bonds) + } + + pub fn add_stub( + &self, + adapter: &str, + bond_id: &str, + alias: Option<&str>, + ) -> io::Result { + validate_adapter_name(adapter)?; + validate_component("bond_id", bond_id)?; + validate_optional_field("alias", alias)?; + + let record = BondRecord { + bond_id: bond_id.to_string(), + alias: alias.map(str::to_string), + created_at_epoch: current_epoch_seconds(), + source: STUB_BOND_SOURCE.to_string(), + }; + + let dir = self.adapter_bonds_dir(adapter); + fs::create_dir_all(&dir)?; + fs::write( + self.record_path(adapter, bond_id), + serialize_record(&record), + )?; + Ok(record) + } + + pub fn remove(&self, adapter: &str, bond_id: &str) -> io::Result { + validate_adapter_name(adapter)?; + validate_component("bond_id", bond_id)?; + + let path = self.record_path(adapter, bond_id); + if !path.exists() { + return Ok(false); + } + + fs::remove_file(path)?; + Ok(true) + } + + fn record_path(&self, adapter: &str, bond_id: &str) -> PathBuf { + self.adapter_bonds_dir(adapter) + .join(format!("{}.bond", hex_encode(bond_id.as_bytes()))) + } +} + +fn current_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn validate_adapter_name(adapter: &str) -> io::Result<()> { + validate_component("adapter", adapter)?; + if adapter == "." || adapter == ".." { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "adapter cannot be a dot path segment", + )); + } + Ok(()) +} + +fn validate_component(name: &str, value: &str) -> io::Result<()> { + if value.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} cannot be empty"), + )); + } + if value.contains('/') || value.contains('\\') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} cannot contain path separators"), + )); + } + if value.contains('\n') || value.contains('\r') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} cannot contain newlines"), + )); + } + Ok(()) +} + +fn validate_optional_field(name: &str, value: Option<&str>) -> io::Result<()> { + if let Some(value) = value { + if value.contains('\n') || value.contains('\r') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} cannot contain newlines"), + )); + } + } + Ok(()) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push_str(&format!("{byte:02x}")); + } + out +} + +fn serialize_record(record: &BondRecord) -> String { + let mut lines = vec![format!("bond_id={}", record.bond_id)]; + if let Some(alias) = &record.alias { + lines.push(format!("alias={alias}")); + } + lines.push(format!("created_at_epoch={}", record.created_at_epoch)); + lines.push(format!("source={}", record.source)); + format!("{}\n", lines.join("\n")) +} + +fn parse_record(path: &Path) -> io::Result { + let content = fs::read_to_string(path)?; + let mut bond_id = None; + let mut alias = None; + let mut created_at_epoch = None; + let mut source = None; + + for line in content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + if let Some(value) = line.strip_prefix("bond_id=") { + bond_id = Some(value.to_string()); + } else if let Some(value) = line.strip_prefix("alias=") { + alias = Some(value.to_string()); + } else if let Some(value) = line.strip_prefix("created_at_epoch=") { + created_at_epoch = value.parse::().ok(); + } else if let Some(value) = line.strip_prefix("source=") { + source = Some(value.to_string()); + } + } + + let Some(bond_id) = bond_id else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("missing bond_id in {}", path.display()), + )); + }; + let Some(created_at_epoch) = created_at_epoch else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("missing created_at_epoch in {}", path.display()), + )); + }; + let Some(source) = source else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("missing source in {}", path.display()), + )); + }; + + Ok(BondRecord { + bond_id, + alias, + created_at_epoch, + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{name}-{stamp}")) + } + + #[test] + fn stub_bond_records_round_trip_through_files() { + let root = temp_path("rbos-bond-store"); + let store = BondStore::new(root.clone()); + + let first = store + .add_stub("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + let second = store.add_stub("hci0", "11:22:33:44:55:66", None).unwrap(); + + let bonds = store.load("hci0").unwrap(); + assert_eq!(bonds.len(), 2); + assert_eq!(bonds[0].bond_id, second.bond_id); + assert_eq!(bonds[0].alias, None); + assert_eq!(bonds[1].bond_id, first.bond_id); + assert_eq!(bonds[1].alias.as_deref(), Some("demo-sensor")); + assert!(store.remove("hci0", "AA:BB:CC:DD:EE:FF").unwrap()); + assert!(!store.remove("hci0", "AA:BB:CC:DD:EE:FF").unwrap()); + assert_eq!(store.load("hci0").unwrap().len(), 1); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn invalid_components_are_rejected() { + let root = temp_path("rbos-bond-store-invalid"); + let store = BondStore::new(root.clone()); + + let err = store.add_stub("hci0", "demo/bond", None).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn load_rejects_invalid_adapter_component() { + let root = temp_path("rbos-bond-store-invalid-load"); + let store = BondStore::new(root.clone()); + + let err = store.load("../escape").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + + let err = store.load("..").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + + fs::remove_dir_all(root).ok(); + } +} diff --git a/local/recipes/system/redbear-btctl/source/src/main.rs b/local/recipes/system/redbear-btctl/source/src/main.rs new file mode 100644 index 00000000..5342d49d --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/src/main.rs @@ -0,0 +1,747 @@ +mod backend; +mod bond_store; +mod scheme; + +use std::env; +#[cfg(target_os = "redox")] +use std::fs; +#[cfg(target_os = "redox")] +use std::os::fd::RawFd; +use std::process; + +#[cfg(not(target_os = "redox"))] +use backend::connection_state_lines; +use backend::{Backend, StubBackend}; +use bond_store::BondRecord; +#[cfg(target_os = "redox")] +use log::info; +#[cfg(target_os = "redox")] +use log::warn; +use log::LevelFilter; +#[cfg(target_os = "redox")] +use redox_scheme::{scheme::SchemeSync, SignalBehavior, Socket}; +#[cfg(target_os = "redox")] +use scheme::BtCtlScheme; + +fn init_logging(level: LevelFilter) { + log::set_max_level(level); +} + +#[cfg(target_os = "redox")] +unsafe fn get_init_notify_fd() -> Option { + let Ok(value) = env::var("INIT_NOTIFY") else { + return None; + }; + let Ok(fd) = value.parse::() else { + return None; + }; + unsafe { + libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); + } + Some(fd) +} + +#[cfg(target_os = "redox")] +fn notify_scheme_ready(notify_fd: Option, socket: &Socket, scheme: &mut BtCtlScheme) { + let Some(notify_fd) = notify_fd else { + return; + }; + + let cap_id = scheme + .scheme_root() + .expect("redbear-btctl: scheme_root failed"); + let cap_fd = socket + .create_this_scheme_fd(0, cap_id, 0, 0) + .expect("redbear-btctl: create_this_scheme_fd failed"); + + if let Err(err) = syscall::call_wo( + notify_fd as usize, + &libredox::Fd::new(cap_fd).into_raw().to_ne_bytes(), + syscall::CallFlags::FD, + &[], + ) { + warn!( + "redbear-btctl: failed to notify init that scheme is ready ({err}); continuing with manual startup" + ); + } +} + +fn build_backend() -> Box { + Box::new(StubBackend::from_env()) +} + +fn default_adapter(backend: &dyn Backend) -> String { + backend + .adapters() + .into_iter() + .next() + .unwrap_or_else(|| "hci0".to_string()) +} + +fn format_lines(lines: &[String]) -> String { + if lines.is_empty() { + "\n".to_string() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn format_bond_record(lines: &mut Vec, prefix: &str, bond: &BondRecord) { + lines.push(format!("{prefix}.bond_id={}", bond.bond_id)); + if let Some(alias) = &bond.alias { + lines.push(format!("{prefix}.alias={alias}")); + } + lines.push(format!( + "{prefix}.created_at_epoch={}", + bond.created_at_epoch + )); + lines.push(format!("{prefix}.source={}", bond.source)); +} + +fn required_arg(args: &[String], index: usize, usage: &str) -> Result { + args.get(index) + .cloned() + .ok_or_else(|| format!("missing argument; usage: {usage}")) +} + +#[cfg(target_os = "redox")] +fn scheme_lines(path: &str) -> Result, String> { + fs::read_to_string(path) + .map_err(|err| format!("failed to read {path}: {err}")) + .map(|content| { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>() + }) +} + +#[cfg(target_os = "redox")] +fn scheme_line(path: &str, prefix: &str) -> Result { + scheme_lines(path)? + .into_iter() + .find(|line| prefix.is_empty() || line.starts_with(prefix)) + .ok_or_else(|| format!("missing expected data in {path}")) +} + +#[cfg(target_os = "redox")] +fn execute_scheme_connection(action: &str, adapter: &str, bond_id: &str) -> Result { + let control_path = format!("/scheme/btctl/adapters/{adapter}/{action}"); + if !std::path::Path::new(&control_path).exists() { + return Err(format!( + "redbear-btctl daemon is not serving scheme:btctl; {action} requires the live daemon surface on Redox" + )); + } + + fs::write(&control_path, bond_id) + .map_err(|err| format!("failed to write {control_path}: {err}"))?; + + let status_line = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/status"), + "status=", + )?; + let transport_status = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/transport-status"), + "", + )?; + let connection_state = scheme_lines(&format!( + "/scheme/btctl/adapters/{adapter}/connection-state" + ))?; + let result_line = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/{action}-result"), + &format!("{action}_result="), + )?; + + let mut lines = vec![ + format!("adapter={adapter}"), + status_line, + format!("transport_status={transport_status}"), + ]; + lines.extend(connection_state); + lines.push(result_line); + Ok(format_lines(&lines)) +} + +#[cfg(target_os = "redox")] +fn execute_scheme_read_char( + adapter: &str, + bond_id: &str, + service_uuid: &str, + char_uuid: &str, +) -> Result { + let control_path = format!("/scheme/btctl/adapters/{adapter}/read-char"); + if !std::path::Path::new(&control_path).exists() { + return Err( + "redbear-btctl daemon is not serving scheme:btctl; read-char requires the live daemon surface on Redox" + .to_string(), + ); + } + + let request = + format!("bond_id={bond_id}\nservice_uuid={service_uuid}\nchar_uuid={char_uuid}\n"); + fs::write(&control_path, request) + .map_err(|err| format!("failed to write {control_path}: {err}"))?; + + let status_line = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/status"), + "status=", + )?; + let transport_status = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/transport-status"), + "", + )?; + let connection_state = scheme_lines(&format!( + "/scheme/btctl/adapters/{adapter}/connection-state" + ))?; + let result_line = scheme_line( + &format!("/scheme/btctl/adapters/{adapter}/read-char-result"), + "read_char_result=", + )?; + + let mut lines = vec![ + format!("adapter={adapter}"), + status_line, + format!("transport_status={transport_status}"), + ]; + lines.extend(connection_state); + lines.push(result_line); + Ok(format_lines(&lines)) +} + +fn execute(args: &[String], backend: &mut dyn Backend) -> Result, String> { + match args.first().map(String::as_str) { + Some("--probe") => Ok(Some(format_lines(&[ + format!("adapters={}", backend.adapters().join(",")), + format!("capabilities={}", backend.capabilities().join(",")), + ]))), + Some("--status") => { + let adapter = args + .get(1) + .cloned() + .unwrap_or_else(|| default_adapter(backend)); + let status = backend.status(&adapter)?; + let bond_store_root = backend.bond_store_path(&adapter)?; + let bond_count = backend.load_bonds(&adapter)?.len(); + Ok(Some(format_lines(&[ + format!("adapter={adapter}"), + format!("status={}", status.as_str()), + format!("transport_status={}", backend.transport_status(&adapter)), + format!( + "scan_results_count={}", + backend.default_scan_results(&adapter).len() + ), + format!( + "connected_bond_count={}", + backend.connected_bond_ids(&adapter)?.len() + ), + format!("bond_count={bond_count}"), + format!("bond_store_root={bond_store_root}"), + ]))) + } + Some("--scan") => { + let adapter = args + .get(1) + .cloned() + .unwrap_or_else(|| default_adapter(backend)); + let results = backend.scan(&adapter)?; + Ok(Some(format_lines(&[ + format!("adapter={adapter}"), + format!("status={}", backend::AdapterStatus::Scanning.as_str()), + format!("transport_status={}", backend.transport_status(&adapter)), + format!("scan_results={}", results.join(",")), + ]))) + } + Some("--bond-list") => { + let adapter = args + .get(1) + .cloned() + .unwrap_or_else(|| default_adapter(backend)); + let bonds = backend.load_bonds(&adapter)?; + let bond_store_root = backend.bond_store_path(&adapter)?; + let mut lines = vec![ + format!("adapter={adapter}"), + format!("bond_store_root={bond_store_root}"), + format!("bond_count={}", bonds.len()), + "note=stub-bond-records-only".to_string(), + ]; + for (index, bond) in bonds.iter().enumerate() { + format_bond_record(&mut lines, &format!("bond.{index}"), bond); + } + Ok(Some(format_lines(&lines))) + } + Some("--bond-add-stub") => { + let adapter = required_arg(args, 1, "--bond-add-stub [alias]")?; + let bond_id = required_arg(args, 2, "--bond-add-stub [alias]")?; + let alias = args.get(3).map(String::as_str); + let bond = backend.add_stub_bond(&adapter, &bond_id, alias)?; + let mut lines = vec![ + format!("adapter={adapter}"), + format!("bond_store_root={}", backend.bond_store_path(&adapter)?), + "persisted=true".to_string(), + "note=stub-bond-record-only".to_string(), + ]; + format_bond_record(&mut lines, "bond", &bond); + Ok(Some(format_lines(&lines))) + } + Some("--bond-remove") => { + let adapter = required_arg(args, 1, "--bond-remove ")?; + let bond_id = required_arg(args, 2, "--bond-remove ")?; + let removed = backend.remove_bond(&adapter, &bond_id)?; + Ok(Some(format_lines(&[ + format!("adapter={adapter}"), + format!("bond_store_root={}", backend.bond_store_path(&adapter)?), + format!("bond_id={bond_id}"), + format!("removed={removed}"), + "note=stub-bond-record-only".to_string(), + ]))) + } + Some("--connect") => { + let adapter = required_arg(args, 1, "--connect ")?; + let bond_id = required_arg(args, 2, "--connect ")?; + #[cfg(target_os = "redox")] + { + execute_scheme_connection("connect", &adapter, &bond_id).map(Some) + } + + #[cfg(not(target_os = "redox"))] + { + backend.connect(&adapter, &bond_id)?; + let connected_bond_ids = backend.connected_bond_ids(&adapter)?; + let mut lines = vec![ + format!("adapter={adapter}"), + format!("status={}", backend.status(&adapter)?.as_str()), + format!("transport_status={}", backend.transport_status(&adapter)), + ]; + lines.extend(connection_state_lines(&connected_bond_ids)); + lines.push(backend.connect_result(&adapter)?); + lines.push("runtime_scope=process-local-host-cli".to_string()); + lines.push( + "note=host-cli-connect-output-is-ephemeral-until-a-live-btctl-daemon-serves-scheme-btctl" + .to_string(), + ); + Ok(Some(format_lines(&lines))) + } + } + Some("--disconnect") => { + let adapter = required_arg(args, 1, "--disconnect ")?; + let bond_id = required_arg(args, 2, "--disconnect ")?; + #[cfg(target_os = "redox")] + { + execute_scheme_connection("disconnect", &adapter, &bond_id).map(Some) + } + + #[cfg(not(target_os = "redox"))] + { + backend.disconnect(&adapter, &bond_id)?; + let connected_bond_ids = backend.connected_bond_ids(&adapter)?; + let mut lines = vec![ + format!("adapter={adapter}"), + format!("status={}", backend.status(&adapter)?.as_str()), + format!("transport_status={}", backend.transport_status(&adapter)), + ]; + lines.extend(connection_state_lines(&connected_bond_ids)); + lines.push(backend.disconnect_result(&adapter)?); + lines.push("runtime_scope=process-local-host-cli".to_string()); + lines.push( + "note=host-cli-disconnect-output-is-ephemeral-until-a-live-btctl-daemon-serves-scheme-btctl" + .to_string(), + ); + Ok(Some(format_lines(&lines))) + } + } + Some("--read-char") => { + let adapter = required_arg( + args, + 1, + "--read-char ", + )?; + let bond_id = required_arg( + args, + 2, + "--read-char ", + )?; + let service_uuid = required_arg( + args, + 3, + "--read-char ", + )?; + let char_uuid = required_arg( + args, + 4, + "--read-char ", + )?; + #[cfg(target_os = "redox")] + { + execute_scheme_read_char(&adapter, &bond_id, &service_uuid, &char_uuid).map(Some) + } + + #[cfg(not(target_os = "redox"))] + { + backend.read_char(&adapter, &bond_id, &service_uuid, &char_uuid)?; + let connected_bond_ids = backend.connected_bond_ids(&adapter)?; + let mut lines = vec![ + format!("adapter={adapter}"), + format!("status={}", backend.status(&adapter)?.as_str()), + format!("transport_status={}", backend.transport_status(&adapter)), + ]; + lines.extend(connection_state_lines(&connected_bond_ids)); + lines.push(backend.read_char_result(&adapter)?); + lines.push("runtime_scope=process-local-host-cli".to_string()); + lines.push( + "note=host-cli-read-char-output-is-ephemeral-until-a-live-btctl-daemon-serves-scheme-btctl" + .to_string(), + ); + Ok(Some(format_lines(&lines))) + } + } + None => Ok(None), + Some(other) => Err(format!("unknown argument: {other}")), + } +} + +fn main() { + let log_level = match env::var("REDBEAR_BTCTL_LOG").as_deref() { + Ok("debug") => LevelFilter::Debug, + Ok("trace") => LevelFilter::Trace, + Ok("warn") => LevelFilter::Warn, + Ok("error") => LevelFilter::Error, + _ => LevelFilter::Info, + }; + init_logging(log_level); + + let args = env::args().skip(1).collect::>(); + let mut backend = build_backend(); + + match execute(&args, backend.as_mut()) { + Ok(Some(output)) => { + print!("{output}"); + return; + } + Ok(None) => {} + Err(err) => { + eprintln!("redbear-btctl: {err}"); + process::exit(1); + } + } + + #[cfg(not(target_os = "redox"))] + { + eprintln!("redbear-btctl: daemon mode is only supported on Redox; use --probe on host"); + process::exit(1); + } + + #[cfg(target_os = "redox")] + { + let notify_fd = unsafe { get_init_notify_fd() }; + let socket = Socket::create().expect("redbear-btctl: failed to create scheme socket"); + let mut scheme = BtCtlScheme::new(build_backend()); + let mut state = redox_scheme::scheme::SchemeState::new(); + + notify_scheme_ready(notify_fd, &socket, &mut scheme); + libredox::call::setrens(0, 0).expect("redbear-btctl: failed to enter null namespace"); + info!("redbear-btctl: registered scheme:btctl"); + + while let Some(request) = socket + .next_request(SignalBehavior::Restart) + .expect("redbear-btctl: failed to read scheme request") + { + if let redox_scheme::RequestKind::Call(request) = request.kind() { + let response = request.handle_sync(&mut scheme, &mut state); + socket + .write_response(response, SignalBehavior::Restart) + .expect("redbear-btctl: failed to write response"); + } + } + + process::exit(0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::StubBackend; + use std::env; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{name}-{stamp}")) + } + + fn stub_backend(status_path: PathBuf, bond_store_root: PathBuf) -> StubBackend { + StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string(), "demo-sensor".to_string()], + status_path, + bond_store_root, + ) + } + + #[test] + fn probe_output_matches_bounded_shape() { + let mut backend = stub_backend( + temp_path("rbos-btctl-probe"), + temp_path("rbos-btctl-probe-bonds"), + ); + let output = execute(&["--probe".to_string()], &mut backend) + .unwrap() + .unwrap(); + assert!(output.contains("adapters=hci0")); + assert!(output.contains("capabilities=backend=stub")); + assert!(output.contains("transport=usb")); + assert!(output.contains("mode=ble-first")); + assert!(output.contains("workload=battery-sensor-battery-level-read")); + assert!(output.contains("read_char=true")); + assert!(output.contains("bond_store=stub-cli")); + } + + #[test] + fn status_reports_explicit_startup_requirement_without_transport_runtime() { + let bond_store_root = temp_path("rbos-btctl-status-bonds"); + let mut backend = stub_backend(temp_path("rbos-btctl-status-missing"), bond_store_root); + let output = execute(&["--status".to_string()], &mut backend) + .unwrap() + .unwrap(); + assert!(output.contains("status=explicit-startup-required")); + assert!(output.contains("runtime_visibility=installed-only")); + assert!(output.contains("bond_count=0")); + } + + #[test] + fn scan_reports_stub_results_when_transport_runtime_is_visible() { + let status_path = temp_path("rbos-btctl-status-visible"); + let bond_store_root = temp_path("rbos-btctl-status-visible-bonds"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let mut backend = stub_backend(status_path.clone(), bond_store_root.clone()); + + let output = execute(&["--scan".to_string()], &mut backend) + .unwrap() + .unwrap(); + assert!(output.contains("status=scanning")); + assert!(output.contains("scan_results=demo-beacon,demo-sensor")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).ok(); + } + + #[test] + fn bond_commands_persist_stub_records_across_cli_restarts() { + let status_path = temp_path("rbos-btctl-bond-status"); + let bond_store_root = temp_path("rbos-btctl-bond-root"); + let mut writer = stub_backend(status_path.clone(), bond_store_root.clone()); + + let empty = execute(&["--bond-list".to_string()], &mut writer) + .unwrap() + .unwrap(); + assert!(empty.contains("bond_count=0")); + + let added = execute( + &[ + "--bond-add-stub".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + "demo-sensor".to_string(), + ], + &mut writer, + ) + .unwrap() + .unwrap(); + assert!(added.contains("persisted=true")); + assert!(added.contains("bond.bond_id=AA:BB:CC:DD:EE:FF")); + + let mut reader = stub_backend(status_path.clone(), bond_store_root.clone()); + let listed = execute(&["--bond-list".to_string()], &mut reader) + .unwrap() + .unwrap(); + assert!(listed.contains("bond_count=1")); + assert!(listed.contains("bond.0.bond_id=AA:BB:CC:DD:EE:FF")); + assert!(listed.contains("bond.0.alias=demo-sensor")); + + let removed = execute( + &[ + "--bond-remove".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + ], + &mut reader, + ) + .unwrap() + .unwrap(); + assert!(removed.contains("removed=true")); + + let mut verifier = stub_backend(status_path, bond_store_root.clone()); + let final_list = execute(&["--bond-list".to_string()], &mut verifier) + .unwrap() + .unwrap(); + assert!(final_list.contains("bond_count=0")); + + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn connect_and_disconnect_commands_report_stub_control_results() { + let status_path = temp_path("rbos-btctl-connect-cli-status"); + let bond_store_root = temp_path("rbos-btctl-connect-cli-bonds"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let mut backend = stub_backend(status_path.clone(), bond_store_root.clone()); + + execute( + &[ + "--bond-add-stub".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + "demo-sensor".to_string(), + ], + &mut backend, + ) + .unwrap(); + + let connected = execute( + &[ + "--connect".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + ], + &mut backend, + ) + .unwrap() + .unwrap(); + assert!(connected.contains("connection_state=stub-connected")); + assert!(connected.contains("connected_bond_ids=AA:BB:CC:DD:EE:FF")); + assert!(connected.contains("connect_result=stub-connected bond_id=AA:BB:CC:DD:EE:FF")); + assert!(connected.contains("runtime_scope=process-local-host-cli")); + + let disconnected = execute( + &[ + "--disconnect".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + ], + &mut backend, + ) + .unwrap() + .unwrap(); + assert!(disconnected.contains("connection_state=stub-disconnected")); + assert!(!disconnected.contains("connected_bond_ids=AA:BB:CC:DD:EE:FF")); + assert!( + disconnected.contains("disconnect_result=stub-disconnected bond_id=AA:BB:CC:DD:EE:FF") + ); + assert!(disconnected.contains("runtime_scope=process-local-host-cli")); + + let missing_disconnect = execute( + &[ + "--disconnect".to_string(), + "hci0".to_string(), + "11:22:33:44:55:66".to_string(), + ], + &mut backend, + ) + .unwrap_err(); + assert!(missing_disconnect.contains("bond record not found")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn read_char_command_reports_bounded_battery_level_result() { + let status_path = temp_path("rbos-btctl-read-char-cli-status"); + let bond_store_root = temp_path("rbos-btctl-read-char-cli-bonds"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let mut backend = stub_backend(status_path.clone(), bond_store_root.clone()); + + execute( + &[ + "--bond-add-stub".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + "demo-battery-sensor".to_string(), + ], + &mut backend, + ) + .unwrap(); + execute( + &[ + "--connect".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + ], + &mut backend, + ) + .unwrap(); + + let read_output = execute( + &[ + "--read-char".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + "0000180f-0000-1000-8000-00805f9b34fb".to_string(), + "00002a19-0000-1000-8000-00805f9b34fb".to_string(), + ], + &mut backend, + ) + .unwrap() + .unwrap(); + assert!(read_output.contains("connection_state=stub-connected")); + assert!(read_output.contains("read_char_result=stub-value")); + assert!(read_output.contains("workload=battery-sensor-battery-level-read")); + assert!(read_output.contains("peripheral_class=ble-battery-sensor")); + assert!(read_output.contains("access=read-only")); + assert!(read_output.contains("value_percent=87")); + assert!(read_output.contains("runtime_scope=process-local-host-cli")); + + let read_err = execute( + &[ + "--read-char".to_string(), + "hci0".to_string(), + "AA:BB:CC:DD:EE:FF".to_string(), + "0000180f-0000-1000-8000-00805f9b34fb".to_string(), + "00002a1a-0000-1000-8000-00805f9b34fb".to_string(), + ], + &mut backend, + ) + .unwrap_err(); + assert!(read_err.contains("only the experimental")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } +} diff --git a/local/recipes/system/redbear-btctl/source/src/scheme.rs b/local/recipes/system/redbear-btctl/source/src/scheme.rs new file mode 100644 index 00000000..102830bd --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/src/scheme.rs @@ -0,0 +1,995 @@ +use std::collections::BTreeMap; + +use redox_scheme::scheme::SchemeSync; +use redox_scheme::{CallerCtx, OpenResult}; +use syscall::error::{Error, Result, EACCES, EBADF, EINVAL, ENOENT, EROFS}; +use syscall::flag::{EventFlags, MODE_DIR, MODE_FILE}; +use syscall::schemev2::NewFdFlags; +use syscall::Stat; + +use crate::backend::{connection_state_lines, AdapterState, AdapterStatus, Backend}; +use crate::bond_store::BondRecord; + +const SCHEME_ROOT_ID: usize = 1; + +#[derive(Clone)] +enum HandleKind { + Root, + Adapters, + Adapter(String), + Capabilities, + Status(String), + TransportStatus(String), + ScanResults(String), + ConnectionState(String), + ConnectResult(String), + DisconnectResult(String), + ReadCharResult(String), + LastError(String), + BondStorePath(String), + BondCount(String), + Bonds(String), + BondMetadata(String, String), + Scan(String), + Connect(String), + Disconnect(String), + ReadChar(String), +} + +pub struct BtCtlScheme { + backend: Box, + next_id: usize, + handles: BTreeMap, + states: BTreeMap, +} + +impl BtCtlScheme { + pub fn new(backend: Box) -> Self { + let mut states = BTreeMap::new(); + for adapter in backend.adapters() { + states.insert( + adapter.clone(), + AdapterState { + status: backend.initial_status(&adapter).as_str().to_string(), + transport_status: backend.transport_status(&adapter), + scan_results: backend.default_scan_results(&adapter), + connected_bond_ids: backend.connected_bond_ids(&adapter).unwrap_or_default(), + connect_result: backend + .connect_result(&adapter) + .unwrap_or_else(|_| "connect_result=not-run".to_string()), + disconnect_result: backend + .disconnect_result(&adapter) + .unwrap_or_else(|_| "disconnect_result=not-run".to_string()), + read_char_result: backend + .read_char_result(&adapter) + .unwrap_or_else(|_| "read_char_result=not-run".to_string()), + bond_store_path: backend.bond_store_path(&adapter).unwrap_or_default(), + bonds: backend.load_bonds(&adapter).unwrap_or_default(), + ..Default::default() + }, + ); + } + + Self { + backend, + next_id: SCHEME_ROOT_ID + 1, + handles: BTreeMap::new(), + states, + } + } + + fn alloc_handle(&mut self, kind: HandleKind) -> usize { + let id = self.next_id; + self.next_id += 1; + self.handles.insert(id, kind); + id + } + + fn handle(&self, id: usize) -> Result<&HandleKind> { + self.handles.get(&id).ok_or(Error::new(EBADF)) + } + + fn state(&self, adapter: &str) -> Result<&AdapterState> { + self.states.get(adapter).ok_or(Error::new(ENOENT)) + } + + fn state_mut(&mut self, adapter: &str) -> Result<&mut AdapterState> { + self.states.get_mut(adapter).ok_or(Error::new(ENOENT)) + } + + fn refreshed_status(&mut self, adapter: &str) -> Result { + let status = self + .backend + .status(adapter) + .map_err(|_| Error::new(ENOENT))? + .as_str() + .to_string(); + let transport_status = self.backend.transport_status(adapter); + let state = self.state_mut(adapter)?; + state.status = status.clone(); + state.transport_status = transport_status.clone(); + if status != AdapterStatus::Scanning.as_str() { + state.scan_results.clear(); + } + Ok(status) + } + + fn refreshed_transport_status(&mut self, adapter: &str) -> Result { + let transport_status = self.backend.transport_status(adapter); + let state = self.state_mut(adapter)?; + state.transport_status = transport_status.clone(); + Ok(transport_status) + } + + fn refreshed_bonds(&mut self, adapter: &str) -> Result> { + let bond_store_path = self + .backend + .bond_store_path(adapter) + .map_err(|_| Error::new(ENOENT))?; + let bonds = self + .backend + .load_bonds(adapter) + .map_err(|_| Error::new(ENOENT))?; + let state = self.state_mut(adapter)?; + state.bond_store_path = bond_store_path; + state.bonds = bonds.clone(); + Ok(bonds) + } + + fn refreshed_connected_bond_ids(&mut self, adapter: &str) -> Result> { + let connected_bond_ids = self + .backend + .connected_bond_ids(adapter) + .map_err(|_| Error::new(ENOENT))?; + self.state_mut(adapter)?.connected_bond_ids = connected_bond_ids.clone(); + Ok(connected_bond_ids) + } + + fn refreshed_connect_result(&mut self, adapter: &str) -> Result { + let connect_result = self + .backend + .connect_result(adapter) + .map_err(|_| Error::new(ENOENT))?; + self.state_mut(adapter)?.connect_result = connect_result.clone(); + Ok(connect_result) + } + + fn refreshed_disconnect_result(&mut self, adapter: &str) -> Result { + let disconnect_result = self + .backend + .disconnect_result(adapter) + .map_err(|_| Error::new(ENOENT))?; + self.state_mut(adapter)?.disconnect_result = disconnect_result.clone(); + Ok(disconnect_result) + } + + fn refreshed_read_char_result(&mut self, adapter: &str) -> Result { + let read_char_result = self + .backend + .read_char_result(adapter) + .map_err(|_| Error::new(ENOENT))?; + self.state_mut(adapter)?.read_char_result = read_char_result.clone(); + Ok(read_char_result) + } + + fn parse_read_char_request(value: &str) -> Result<(String, String, String)> { + let mut bond_id = None; + let mut service_uuid = None; + let mut char_uuid = None; + + for line in value.lines().map(str::trim).filter(|line| !line.is_empty()) { + let Some((key, raw_value)) = line.split_once('=') else { + return Err(Error::new(EINVAL)); + }; + let parsed = raw_value.trim().to_string(); + if parsed.is_empty() { + return Err(Error::new(EINVAL)); + } + match key.trim() { + "bond_id" => bond_id = Some(parsed), + "service_uuid" => service_uuid = Some(parsed), + "char_uuid" => char_uuid = Some(parsed), + _ => return Err(Error::new(EINVAL)), + } + } + + match (bond_id, service_uuid, char_uuid) { + (Some(bond_id), Some(service_uuid), Some(char_uuid)) => { + Ok((bond_id, service_uuid, char_uuid)) + } + _ => Err(Error::new(EINVAL)), + } + } + + fn format_bond_metadata(bond: &BondRecord) -> String { + let mut lines = vec![format!("bond_id={}", bond.bond_id)]; + if let Some(alias) = &bond.alias { + lines.push(format!("alias={alias}")); + } + lines.push(format!("created_at_epoch={}", bond.created_at_epoch)); + lines.push(format!("source={}", bond.source)); + format!("{}\n", lines.join("\n")) + } + + fn status_string(&self, adapter: &str) -> String { + self.backend + .status(adapter) + .map(|status| status.as_str().to_string()) + .unwrap_or_else(|_| AdapterStatus::Failed.as_str().to_string()) + } + + fn write_handle(&mut self, kind: HandleKind, value: &str) -> Result<()> { + match kind { + HandleKind::Scan(adapter) => { + let results = match self.backend.scan(&adapter) { + Ok(results) => results, + Err(err) => { + let transport_status = self.backend.transport_status(&adapter); + let status = self.status_string(&adapter); + let state = self.state_mut(&adapter)?; + state.last_error = err; + state.status = status; + state.transport_status = transport_status; + return Ok(()); + } + }; + + let transport_status = self.backend.transport_status(&adapter); + let state = self.state_mut(&adapter)?; + state.status = AdapterStatus::Scanning.as_str().to_string(); + state.transport_status = transport_status; + state.scan_results = results; + state.last_error.clear(); + } + HandleKind::Connect(adapter) => { + let bond_id = value.trim(); + if bond_id.is_empty() { + return Err(Error::new(EINVAL)); + } + + let outcome = self.backend.connect(&adapter, bond_id); + let status = self.status_string(&adapter); + let transport_status = self.backend.transport_status(&adapter); + let connected_bond_ids = self.refreshed_connected_bond_ids(&adapter)?; + let connect_result = self.refreshed_connect_result(&adapter)?; + let disconnect_result = self.refreshed_disconnect_result(&adapter)?; + let rejected = outcome.is_err(); + let state = self.state_mut(&adapter)?; + state.status = status; + state.transport_status = transport_status; + state.connected_bond_ids = connected_bond_ids; + state.connect_result = connect_result; + state.disconnect_result = disconnect_result; + let last_error = match outcome { + Ok(()) => String::new(), + Err(err) => err, + }; + state.last_error = last_error; + if rejected { + return Err(Error::new(EINVAL)); + } + } + HandleKind::Disconnect(adapter) => { + let bond_id = value.trim(); + if bond_id.is_empty() { + return Err(Error::new(EINVAL)); + } + + let outcome = self.backend.disconnect(&adapter, bond_id); + let status = self.status_string(&adapter); + let transport_status = self.backend.transport_status(&adapter); + let connected_bond_ids = self.refreshed_connected_bond_ids(&adapter)?; + let connect_result = self.refreshed_connect_result(&adapter)?; + let disconnect_result = self.refreshed_disconnect_result(&adapter)?; + let rejected = outcome.is_err(); + let state = self.state_mut(&adapter)?; + state.status = status; + state.transport_status = transport_status; + state.connected_bond_ids = connected_bond_ids; + state.connect_result = connect_result; + state.disconnect_result = disconnect_result; + let last_error = match outcome { + Ok(()) => String::new(), + Err(err) => err, + }; + state.last_error = last_error; + if rejected { + return Err(Error::new(EINVAL)); + } + } + HandleKind::ReadChar(adapter) => { + let (bond_id, service_uuid, char_uuid) = Self::parse_read_char_request(value)?; + + let outcome = self + .backend + .read_char(&adapter, &bond_id, &service_uuid, &char_uuid); + let status = self.status_string(&adapter); + let transport_status = self.backend.transport_status(&adapter); + let connected_bond_ids = self.refreshed_connected_bond_ids(&adapter)?; + let connect_result = self.refreshed_connect_result(&adapter)?; + let disconnect_result = self.refreshed_disconnect_result(&adapter)?; + let read_char_result = self.refreshed_read_char_result(&adapter)?; + let rejected = outcome.is_err(); + let state = self.state_mut(&adapter)?; + state.status = status; + state.transport_status = transport_status; + state.connected_bond_ids = connected_bond_ids; + state.connect_result = connect_result; + state.disconnect_result = disconnect_result; + state.read_char_result = read_char_result; + let last_error = match outcome { + Ok(()) => String::new(), + Err(err) => err, + }; + state.last_error = last_error; + if rejected { + return Err(Error::new(EINVAL)); + } + } + _ => return Err(Error::new(EROFS)), + } + + Ok(()) + } + + fn read_handle(&mut self, kind: &HandleKind) -> Result { + Ok(match kind { + HandleKind::Root => "adapters\ncapabilities\n".to_string(), + HandleKind::Adapters => { + self.states.keys().cloned().collect::>().join("\n") + "\n" + } + HandleKind::Adapter(_) => { + "status\ntransport-status\nscan-results\nconnection-state\nconnect-result\ndisconnect-result\nread-char-result\nlast-error\nbond-store-path\nbond-count\nbonds\nscan\nconnect\ndisconnect\nread-char\n" + .to_string() + } + HandleKind::Capabilities => self.backend.capabilities().join("\n") + "\n", + HandleKind::Status(adapter) => { + let status = self.refreshed_status(adapter)?; + let transport_status = self.refreshed_transport_status(adapter)?; + let connected_bond_ids = self.refreshed_connected_bond_ids(adapter)?; + let bonds = self.refreshed_bonds(adapter)?; + let scan_results_count = self.state(adapter)?.scan_results.len(); + let state = self.state(adapter)?; + format!( + "status={}\ntransport_status={}\nscan_results_count={}\nconnected_bond_count={}\nbond_count={}\nbond_store_path={}\n", + status, + transport_status, + scan_results_count.max(state.scan_results.len()), + connected_bond_ids.len(), + bonds.len(), + state.bond_store_path + ) + } + HandleKind::TransportStatus(adapter) => { + format!("{}\n", self.refreshed_transport_status(adapter)?) + } + HandleKind::ScanResults(adapter) => self.state(adapter)?.scan_results.join("\n") + "\n", + HandleKind::ConnectionState(adapter) => { + let connected_bond_ids = self.refreshed_connected_bond_ids(adapter)?; + format!("{}\n", connection_state_lines(&connected_bond_ids).join("\n")) + } + HandleKind::ConnectResult(adapter) => { + format!("{}\n", self.refreshed_connect_result(adapter)?) + } + HandleKind::DisconnectResult(adapter) => { + format!("{}\n", self.refreshed_disconnect_result(adapter)?) + } + HandleKind::ReadCharResult(adapter) => { + format!("{}\n", self.refreshed_read_char_result(adapter)?) + } + HandleKind::LastError(adapter) => format!("{}\n", self.state(adapter)?.last_error), + HandleKind::BondStorePath(adapter) => { + let bonds = self.refreshed_bonds(adapter)?; + let _ = bonds; + format!("{}\n", self.state(adapter)?.bond_store_path) + } + HandleKind::BondCount(adapter) => { + let bonds = self.refreshed_bonds(adapter)?; + format!( + "bond_count={}\nbond_store_path={}\n", + bonds.len(), + self.state(adapter)?.bond_store_path + ) + } + HandleKind::Bonds(adapter) => { + let bonds = self.refreshed_bonds(adapter)?; + if bonds.is_empty() { + "\n".to_string() + } else { + bonds + .iter() + .map(|bond| bond.bond_id.clone()) + .collect::>() + .join("\n") + + "\n" + } + } + HandleKind::BondMetadata(adapter, bond_id) => self + .refreshed_bonds(adapter)? + .into_iter() + .find(|bond| &bond.bond_id == bond_id) + .map(|bond| Self::format_bond_metadata(&bond)) + .ok_or(Error::new(ENOENT))?, + HandleKind::Scan(_) | HandleKind::Connect(_) | HandleKind::Disconnect(_) | HandleKind::ReadChar(_) => { + String::new() + } + }) + } +} + +impl SchemeSync for BtCtlScheme { + fn scheme_root(&mut self) -> Result { + Ok(SCHEME_ROOT_ID) + } + + fn openat( + &mut self, + dirfd: usize, + path: &str, + _flags: usize, + _fcntl_flags: u32, + _ctx: &CallerCtx, + ) -> Result { + let kind = if dirfd == SCHEME_ROOT_ID { + match path.trim_matches('/') { + "" => HandleKind::Root, + "adapters" => HandleKind::Adapters, + "capabilities" => HandleKind::Capabilities, + _ => return Err(Error::new(ENOENT)), + } + } else { + let parent = self.handle(dirfd)?.clone(); + match parent { + HandleKind::Adapters => { + let adapter = path.trim_matches('/'); + self.state(adapter)?; + HandleKind::Adapter(adapter.to_string()) + } + HandleKind::Adapter(adapter) => match path.trim_matches('/') { + "status" => HandleKind::Status(adapter.clone()), + "transport-status" => HandleKind::TransportStatus(adapter.clone()), + "scan-results" => HandleKind::ScanResults(adapter.clone()), + "connection-state" => HandleKind::ConnectionState(adapter.clone()), + "connect-result" => HandleKind::ConnectResult(adapter.clone()), + "disconnect-result" => HandleKind::DisconnectResult(adapter.clone()), + "read-char-result" => HandleKind::ReadCharResult(adapter.clone()), + "last-error" => HandleKind::LastError(adapter.clone()), + "bond-store-path" => HandleKind::BondStorePath(adapter.clone()), + "bond-count" => HandleKind::BondCount(adapter.clone()), + "bonds" => HandleKind::Bonds(adapter.clone()), + "scan" => HandleKind::Scan(adapter.clone()), + "connect" => HandleKind::Connect(adapter.clone()), + "disconnect" => HandleKind::Disconnect(adapter.clone()), + "read-char" => HandleKind::ReadChar(adapter.clone()), + _ => return Err(Error::new(ENOENT)), + }, + HandleKind::Bonds(adapter) => { + let bond_id = path.trim_matches('/'); + if bond_id.is_empty() { + return Err(Error::new(ENOENT)); + } + let adapter_name = adapter.clone(); + let exists = self + .refreshed_bonds(&adapter_name)? + .into_iter() + .any(|bond| bond.bond_id == bond_id); + if exists { + HandleKind::BondMetadata(adapter_name, bond_id.to_string()) + } else { + return Err(Error::new(ENOENT)); + } + } + _ => return Err(Error::new(EACCES)), + } + }; + + Ok(OpenResult::ThisScheme { + number: self.alloc_handle(kind), + flags: NewFdFlags::empty(), + }) + } + + fn read( + &mut self, + id: usize, + buf: &mut [u8], + offset: u64, + _flags: u32, + _ctx: &CallerCtx, + ) -> Result { + let kind = self.handle(id)?.clone(); + let data = self.read_handle(&kind)?; + let bytes = data.as_bytes(); + let offset = usize::try_from(offset).map_err(|_| Error::new(EINVAL))?; + if offset >= bytes.len() { + return Ok(0); + } + let count = (bytes.len() - offset).min(buf.len()); + buf[..count].copy_from_slice(&bytes[offset..offset + count]); + Ok(count) + } + + fn write( + &mut self, + id: usize, + buf: &[u8], + _offset: u64, + _flags: u32, + _ctx: &CallerCtx, + ) -> Result { + let value = std::str::from_utf8(buf).map_err(|_| Error::new(EINVAL))?; + let kind = self.handle(id)?.clone(); + self.write_handle(kind, value)?; + + Ok(buf.len()) + } + + fn fstat(&mut self, id: usize, stat: &mut Stat, _ctx: &CallerCtx) -> Result<()> { + let kind = self.handle(id)?; + stat.st_mode = match kind { + HandleKind::Root + | HandleKind::Adapters + | HandleKind::Adapter(_) + | HandleKind::Bonds(_) => MODE_DIR | 0o755, + HandleKind::Scan(_) + | HandleKind::Connect(_) + | HandleKind::Disconnect(_) + | HandleKind::ReadChar(_) => MODE_FILE | 0o644, + _ => MODE_FILE | 0o444, + }; + Ok(()) + } + + fn fsync(&mut self, id: usize, _ctx: &CallerCtx) -> Result<()> { + let _ = self.handle(id)?; + Ok(()) + } + + fn fpath(&mut self, id: usize, buf: &mut [u8], _ctx: &CallerCtx) -> Result { + let path = match self.handle(id)? { + HandleKind::Root => "btctl:/".to_string(), + HandleKind::Adapters => "btctl:/adapters".to_string(), + HandleKind::Adapter(adapter) => format!("btctl:/adapters/{adapter}"), + HandleKind::Capabilities => "btctl:/capabilities".to_string(), + HandleKind::Status(adapter) => format!("btctl:/adapters/{adapter}/status"), + HandleKind::TransportStatus(adapter) => { + format!("btctl:/adapters/{adapter}/transport-status") + } + HandleKind::ScanResults(adapter) => format!("btctl:/adapters/{adapter}/scan-results"), + HandleKind::ConnectionState(adapter) => { + format!("btctl:/adapters/{adapter}/connection-state") + } + HandleKind::ConnectResult(adapter) => { + format!("btctl:/adapters/{adapter}/connect-result") + } + HandleKind::DisconnectResult(adapter) => { + format!("btctl:/adapters/{adapter}/disconnect-result") + } + HandleKind::ReadCharResult(adapter) => { + format!("btctl:/adapters/{adapter}/read-char-result") + } + HandleKind::LastError(adapter) => format!("btctl:/adapters/{adapter}/last-error"), + HandleKind::BondStorePath(adapter) => { + format!("btctl:/adapters/{adapter}/bond-store-path") + } + HandleKind::BondCount(adapter) => format!("btctl:/adapters/{adapter}/bond-count"), + HandleKind::Bonds(adapter) => format!("btctl:/adapters/{adapter}/bonds"), + HandleKind::BondMetadata(adapter, bond_id) => { + format!("btctl:/adapters/{adapter}/bonds/{bond_id}") + } + HandleKind::Scan(adapter) => format!("btctl:/adapters/{adapter}/scan"), + HandleKind::Connect(adapter) => format!("btctl:/adapters/{adapter}/connect"), + HandleKind::Disconnect(adapter) => format!("btctl:/adapters/{adapter}/disconnect"), + HandleKind::ReadChar(adapter) => format!("btctl:/adapters/{adapter}/read-char"), + }; + let bytes = path.as_bytes(); + let count = bytes.len().min(buf.len()); + buf[..count].copy_from_slice(&bytes[..count]); + Ok(count) + } + + fn fevent(&mut self, id: usize, _flags: EventFlags, _ctx: &CallerCtx) -> Result { + let _ = self.handle(id)?; + Ok(EventFlags::empty()) + } + + fn on_close(&mut self, id: usize) { + if id != SCHEME_ROOT_ID { + self.handles.remove(&id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::StubBackend; + use std::env; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{name}-{stamp}")) + } + + fn build_scheme(status_path: PathBuf, bond_store_root: PathBuf) -> BtCtlScheme { + BtCtlScheme::new(Box::new(StubBackend::new_for_test( + vec!["hci0".to_string()], + vec!["demo-beacon".to_string(), "demo-sensor".to_string()], + status_path, + bond_store_root, + ))) + } + + #[test] + fn root_surface_lists_expected_nodes() { + let mut scheme = build_scheme( + temp_path("rbos-btctl-root"), + temp_path("rbos-btctl-root-bonds"), + ); + assert_eq!( + scheme.read_handle(&HandleKind::Root).unwrap(), + "adapters\ncapabilities\n" + ); + } + + #[test] + fn adapter_surface_lists_bond_nodes() { + let mut scheme = build_scheme( + temp_path("rbos-btctl-adapter-root"), + temp_path("rbos-btctl-adapter-root-bonds"), + ); + assert_eq!( + scheme + .read_handle(&HandleKind::Adapter("hci0".to_string())) + .unwrap(), + "status\ntransport-status\nscan-results\nconnection-state\nconnect-result\ndisconnect-result\nread-char-result\nlast-error\nbond-store-path\nbond-count\nbonds\nscan\nconnect\ndisconnect\nread-char\n" + ); + } + + #[test] + fn scan_failure_records_last_error_when_transport_is_missing() { + let missing = temp_path("rbos-btctl-scan-missing"); + let mut scheme = build_scheme(missing, temp_path("rbos-btctl-scan-missing-bonds")); + let adapter = "hci0".to_string(); + + let err = scheme.backend.scan(&adapter).unwrap_err(); + let transport_status = scheme.backend.transport_status(&adapter); + let state = scheme.state_mut(&adapter).unwrap(); + state.last_error = err.clone(); + state.status = AdapterStatus::Failed.as_str().to_string(); + state.transport_status = transport_status; + + assert!(scheme + .state(&adapter) + .unwrap() + .last_error + .contains("start redbear-btusb explicitly")); + assert_eq!(scheme.state(&adapter).unwrap().status, "failed"); + } + + #[test] + fn scan_updates_state_when_transport_status_file_is_present() { + let status_path = temp_path("rbos-btctl-scan-visible"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-scan-visible-bonds"); + let mut scheme = build_scheme(status_path.clone(), bond_store_root.clone()); + let adapter = "hci0".to_string(); + + let results = scheme.backend.scan(&adapter).unwrap(); + let transport_status = scheme.backend.transport_status(&adapter); + let state = scheme.state_mut(&adapter).unwrap(); + state.status = AdapterStatus::Scanning.as_str().to_string(); + state.transport_status = transport_status; + state.scan_results = results; + + assert_eq!( + scheme + .read_handle(&HandleKind::Status(adapter.clone())) + .unwrap() + .lines() + .next() + .unwrap(), + "status=adapter-visible" + ); + assert_eq!( + scheme.state(&adapter).unwrap().scan_results, + Vec::::new() + ); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).ok(); + } + + #[test] + fn status_read_refreshes_when_transport_becomes_stale() { + let status_path = temp_path("rbos-btctl-scan-stale-read"); + fs::write( + &status_path, + "transport=usb\nstartup=explicit\nupdated_at_epoch=1\nruntime_visibility=runtime-visible\n", + ) + .unwrap(); + let mut scheme = build_scheme( + status_path.clone(), + temp_path("rbos-btctl-scan-stale-read-bonds"), + ); + + let status = scheme + .read_handle(&HandleKind::Status("hci0".to_string())) + .unwrap(); + let transport = scheme + .read_handle(&HandleKind::TransportStatus("hci0".to_string())) + .unwrap(); + + assert!(status.contains("status=explicit-startup-required")); + assert!(transport.contains("runtime_visibility=installed-only")); + + fs::remove_file(status_path).unwrap(); + } + + #[test] + fn bond_nodes_refresh_from_store_without_write_api() { + let bond_store_root = temp_path("rbos-btctl-scheme-bonds"); + let mut scheme = build_scheme( + temp_path("rbos-btctl-scheme-status"), + bond_store_root.clone(), + ); + let adapter = "hci0".to_string(); + + assert_eq!( + scheme + .read_handle(&HandleKind::BondCount(adapter.clone())) + .unwrap(), + format!( + "bond_count=0\nbond_store_path={}\n", + bond_store_root.join("hci0").join("bonds").display() + ) + ); + + scheme + .backend + .add_stub_bond(&adapter, "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + + let count = scheme + .read_handle(&HandleKind::BondCount(adapter.clone())) + .unwrap(); + let bonds = scheme + .read_handle(&HandleKind::Bonds(adapter.clone())) + .unwrap(); + let metadata = scheme + .read_handle(&HandleKind::BondMetadata( + adapter.clone(), + "AA:BB:CC:DD:EE:FF".to_string(), + )) + .unwrap(); + + assert!(count.contains("bond_count=1")); + assert!(bonds.contains("AA:BB:CC:DD:EE:FF")); + assert!(metadata.contains("bond_id=AA:BB:CC:DD:EE:FF")); + assert!(metadata.contains("alias=demo-sensor")); + assert!(metadata.contains("source=stub-cli")); + + scheme + .backend + .remove_bond(&adapter, "AA:BB:CC:DD:EE:FF") + .unwrap(); + + let count_after_remove = scheme + .read_handle(&HandleKind::BondCount(adapter.clone())) + .unwrap(); + assert!(count_after_remove.contains("bond_count=0")); + + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn connect_write_updates_connection_surfaces() { + let status_path = temp_path("rbos-btctl-scheme-connect-status"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-scheme-connect-bonds"); + let mut scheme = build_scheme(status_path.clone(), bond_store_root.clone()); + let adapter = "hci0".to_string(); + + scheme + .backend + .add_stub_bond(&adapter, "AA:BB:CC:DD:EE:FF", Some("demo-sensor")) + .unwrap(); + + scheme + .write_handle(HandleKind::Connect(adapter.clone()), "AA:BB:CC:DD:EE:FF") + .unwrap(); + + let connection_state = scheme + .read_handle(&HandleKind::ConnectionState(adapter.clone())) + .unwrap(); + let connect_result = scheme + .read_handle(&HandleKind::ConnectResult(adapter.clone())) + .unwrap(); + + assert!(connection_state.contains("connection_state=stub-connected")); + assert!(connection_state.contains("connected_bond_ids=AA:BB:CC:DD:EE:FF")); + assert!(connect_result.contains("connect_result=stub-connected bond_id=AA:BB:CC:DD:EE:FF")); + + scheme + .write_handle(HandleKind::Disconnect(adapter.clone()), "AA:BB:CC:DD:EE:FF") + .unwrap(); + + let disconnected_state = scheme + .read_handle(&HandleKind::ConnectionState(adapter.clone())) + .unwrap(); + let disconnect_result = scheme + .read_handle(&HandleKind::DisconnectResult(adapter.clone())) + .unwrap(); + + assert!(disconnected_state.contains("connection_state=stub-disconnected")); + assert!(disconnected_state.contains("connected_bond_ids=")); + assert!(!disconnected_state.contains("connected_bond_ids=AA:BB:CC:DD:EE:FF")); + assert!(disconnect_result + .contains("disconnect_result=stub-disconnected bond_id=AA:BB:CC:DD:EE:FF")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn read_char_write_updates_bounded_result_surface() { + let status_path = temp_path("rbos-btctl-scheme-read-char-status"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-scheme-read-char-bonds"); + let mut scheme = build_scheme(status_path.clone(), bond_store_root.clone()); + let adapter = "hci0".to_string(); + + scheme + .backend + .add_stub_bond(&adapter, "AA:BB:CC:DD:EE:FF", Some("demo-battery-sensor")) + .unwrap(); + scheme + .write_handle(HandleKind::Connect(adapter.clone()), "AA:BB:CC:DD:EE:FF") + .unwrap(); + + scheme + .write_handle( + HandleKind::ReadChar(adapter.clone()), + "bond_id=AA:BB:CC:DD:EE:FF\nservice_uuid=0000180f-0000-1000-8000-00805f9b34fb\nchar_uuid=00002a19-0000-1000-8000-00805f9b34fb\n", + ) + .unwrap(); + + let read_result = scheme + .read_handle(&HandleKind::ReadCharResult(adapter.clone())) + .unwrap(); + assert!(read_result.contains("read_char_result=stub-value")); + assert!(read_result.contains("workload=battery-sensor-battery-level-read")); + assert!(read_result.contains("access=read-only")); + assert!(read_result.contains("value_percent=87")); + + scheme + .write_handle( + HandleKind::ReadChar(adapter.clone()), + "bond_id=AA:BB:CC:DD:EE:FF\nservice_uuid=0000180f-0000-1000-8000-00805f9b34fb\nchar_uuid=00002a1a-0000-1000-8000-00805f9b34fb\n", + ) + .unwrap_err(); + + let read_result_after_reject = scheme + .read_handle(&HandleKind::ReadCharResult(adapter.clone())) + .unwrap(); + let last_error = scheme + .read_handle(&HandleKind::LastError(adapter.clone())) + .unwrap(); + assert!(read_result_after_reject + .contains("read_char_result=rejected-unsupported-characteristic")); + assert!(last_error.contains("only the experimental")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).unwrap(); + } + + #[test] + fn connect_write_records_last_error_when_bond_is_missing() { + let status_path = temp_path("rbos-btctl-scheme-connect-missing-bond-status"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-scheme-connect-missing-bond-bonds"); + let mut scheme = build_scheme(status_path.clone(), bond_store_root.clone()); + let adapter = "hci0".to_string(); + + scheme + .write_handle(HandleKind::Connect(adapter.clone()), "AA:BB:CC:DD:EE:FF") + .unwrap_err(); + + let last_error = scheme + .read_handle(&HandleKind::LastError(adapter.clone())) + .unwrap(); + let connect_result = scheme + .read_handle(&HandleKind::ConnectResult(adapter.clone())) + .unwrap(); + + assert!(last_error.contains("bond record not found")); + assert!(connect_result + .contains("connect_result=rejected-missing-bond bond_id=AA:BB:CC:DD:EE:FF")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).ok(); + } + + #[test] + fn disconnect_write_records_last_error_when_bond_is_missing() { + let status_path = temp_path("rbos-btctl-scheme-disconnect-missing-bond-status"); + fs::write( + &status_path, + &format!( + "transport=usb\nstartup=explicit\nupdated_at_epoch={}\nruntime_visibility=runtime-visible\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ), + ) + .unwrap(); + let bond_store_root = temp_path("rbos-btctl-scheme-disconnect-missing-bond-bonds"); + let mut scheme = build_scheme(status_path.clone(), bond_store_root.clone()); + let adapter = "hci0".to_string(); + + scheme + .write_handle(HandleKind::Disconnect(adapter.clone()), "AA:BB:CC:DD:EE:FF") + .unwrap_err(); + + let last_error = scheme + .read_handle(&HandleKind::LastError(adapter.clone())) + .unwrap(); + let disconnect_result = scheme + .read_handle(&HandleKind::DisconnectResult(adapter.clone())) + .unwrap(); + + assert!(last_error.contains("bond record not found")); + assert!(disconnect_result + .contains("disconnect_result=rejected-missing-bond bond_id=AA:BB:CC:DD:EE:FF")); + + fs::remove_file(status_path).unwrap(); + fs::remove_dir_all(bond_store_root).ok(); + } +} diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs new file mode 100644 index 00000000..14fe0c7c --- /dev/null +++ b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs @@ -0,0 +1,663 @@ +use std::fs; +use std::path::Path; +use std::process::{self, Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use redbear_hwutils::parse_args; + +const PROGRAM: &str = "redbear-bluetooth-battery-check"; +const USAGE: &str = "Usage: redbear-bluetooth-battery-check\n\nExercise the bounded Bluetooth Battery Level runtime slice inside a Red Bear OS guest or target runtime."; +const ADAPTER: &str = "hci0"; +const BOND_ID: &str = "AA:BB:CC:DD:EE:FF"; +const BOND_ALIAS: &str = "demo-battery-sensor"; +const EXPERIMENTAL_WORKLOAD: &str = "battery-sensor-battery-level-read"; +const PERIPHERAL_CLASS: &str = "ble-battery-sensor"; +const BATTERY_SERVICE_UUID: &str = "0000180f-0000-1000-8000-00805f9b34fb"; +const BATTERY_LEVEL_CHAR_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb"; +const TRANSPORT_STATUS_PATH: &str = "/var/run/redbear-btusb/status"; +const BTCTL_ROOT: &str = "/scheme/btctl"; +const BTCTL_ADAPTER_ROOT: &str = "/scheme/btctl/adapters/hci0"; +const BTCTL_CONNECTION_STATE_PATH: &str = "/scheme/btctl/adapters/hci0/connection-state"; +const BTCTL_CONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/connect-result"; +const BTCTL_DISCONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/disconnect-result"; +const BTCTL_READ_CHAR_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/read-char-result"; +const BTCTL_LAST_ERROR_PATH: &str = "/scheme/btctl/adapters/hci0/last-error"; +const BTCTL_BONDS_PATH: &str = "/scheme/btctl/adapters/hci0/bonds"; +const BTCTL_BOND_PATH: &str = "/scheme/btctl/adapters/hci0/bonds/AA:BB:CC:DD:EE:FF"; +const BTUSB_LOG_PATH: &str = "/tmp/redbear-btusb-runtime.log"; + +struct CommandCapture { + stdout: String, + stderr: String, + success: bool, +} + +#[derive(Default)] +struct RuntimeSession { + btusb: Option, +} + +impl Drop for RuntimeSession { + fn drop(&mut self) { + let _ = self.remove_test_bond(); + self.stop_btusb_quietly(); + } +} + +impl RuntimeSession { + fn ensure_btusb_running(&mut self) -> Result<(), String> { + if transport_runtime_visible() { + return Ok(()); + } + + let child = Command::new("redbear-btusb") + .stdout(open_log_file(BTUSB_LOG_PATH)?) + .stderr(open_log_file(BTUSB_LOG_PATH)?) + .spawn() + .map_err(|err| format!("failed to start redbear-btusb: {err}"))?; + self.btusb = Some(child); + wait_for_condition( + "redbear-btusb runtime visibility", + Duration::from_secs(5), + || Ok::(transport_runtime_visible()), + ) + } + + fn ensure_btctl_running(&mut self) -> Result<(), String> { + wait_for_condition( + "redbear-btctl scheme registration", + Duration::from_secs(20), + || { + Ok::( + Path::new(BTCTL_ROOT).exists() && Path::new(BTCTL_ADAPTER_ROOT).exists(), + ) + }, + ) + .map_err(|err| { + format!( + "{err} (redbear-btctl must be launched through init/profile wiring so /scheme/btctl is visible in the runtime namespace)" + ) + }) + } + + fn restart_btusb(&mut self) -> Result<(), String> { + self.stop_btusb()?; + self.ensure_btusb_running() + } + + fn stop_btusb(&mut self) -> Result<(), String> { + let Some(mut child) = self.btusb.take() else { + return Err("redbear-btusb is not owned by this checker run".to_string()); + }; + + child + .kill() + .map_err(|err| format!("failed to stop redbear-btusb: {err}"))?; + let _ = child.wait(); + wait_for_condition( + "redbear-btusb runtime disappearance", + Duration::from_secs(5), + || Ok::(!transport_runtime_visible()), + ) + } + + fn stop_btusb_quietly(&mut self) { + if let Some(mut child) = self.btusb.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + fn remove_test_bond(&self) -> Result<(), String> { + if !Path::new(BTCTL_ROOT).exists() { + return Ok(()); + } + + let _ = run_command("redbear-btctl", &["--bond-remove", ADAPTER, BOND_ID])?; + Ok(()) + } +} + +fn main() { + if let Err(err) = run() { + eprintln!("{PROGRAM}: {err}"); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + parse_args(PROGRAM, USAGE, std::env::args()).map_err(|err| { + if err.is_empty() { + process::exit(0); + } + err + })?; + + println!("=== Red Bear OS Bluetooth Battery Check ==="); + require_path("/usr/bin/redbear-btusb")?; + require_path("/usr/bin/redbear-btctl")?; + require_path("/usr/bin/redbear-info")?; + + let mut session = RuntimeSession::default(); + print_checked_command("transport probe", "redbear-btusb", &["--probe"])?; + print_checked_command("host/control probe", "redbear-btctl", &["--probe"])?; + + session.ensure_btusb_running()?; + session.ensure_btctl_running()?; + verify_scheme_surface()?; + verify_runtime_status()?; + + run_cycle("cycle-1", true)?; + run_cycle("cycle-2", false)?; + verify_btctl_restart_cleanup(&mut session)?; + verify_btusb_restart_path(&mut session)?; + + ensure_bond_absent()?; + verify_disconnected_state("final-state")?; + + println!("BLUETOOTH_BATTERY_CHECK=pass"); + println!("PASS: bounded Bluetooth Battery Level slice exercised inside target runtime"); + println!("NOTE: this proves explicit-startup btusb/btctl startup, repeated packaged helper runs in one boot, daemon restart cleanup, stale-state cleanup after disconnect, and one experimental battery-sensor Battery Level read-only workload; it does not prove controller bring-up, general device traffic, generic GATT, real pairing, write support, notify support, or broad BLE maturity"); + Ok(()) +} + +fn require_path(path: &str) -> Result<(), String> { + if Path::new(path).exists() { + println!("{path}"); + Ok(()) + } else { + Err(format!("missing {path}")) + } +} + +fn open_log_file(path: &str) -> Result { + let file = + fs::File::create(path).map_err(|err| format!("failed to open log file {path}: {err}"))?; + Ok(Stdio::from(file)) +} + +fn run_cycle(label: &str, verify_info: bool) -> Result<(), String> { + println!("=== {label}: bounded battery-level workload ==="); + ensure_bond_absent()?; + verify_disconnected_state(label)?; + let scan_output = + print_checked_command(&format!("{label}: scan"), "redbear-btctl", &["--scan"])?; + require_contains(&scan_output, &format!("adapter={ADAPTER}"))?; + require_contains(&scan_output, "status=scanning")?; + require_contains(&scan_output, "scan_results=")?; + expect_read_failure( + &format!("{label}: read-before-connect"), + "rejected-not-connected", + "run --connect before the experimental read", + )?; + + let add_output = print_checked_command( + &format!("{label}: add stub bond"), + "redbear-btctl", + &["--bond-add-stub", ADAPTER, BOND_ID, BOND_ALIAS], + )?; + require_contains(&add_output, "persisted=true")?; + require_contains(&add_output, &format!("bond.bond_id={BOND_ID}"))?; + require_contains(&add_output, &format!("bond.alias={BOND_ALIAS}"))?; + verify_bond_present()?; + + let connect_output = print_checked_command( + &format!("{label}: connect"), + "redbear-btctl", + &["--connect", ADAPTER, BOND_ID], + )?; + require_contains(&connect_output, "connection_state=stub-connected")?; + require_contains(&connect_output, &format!("connected_bond_ids={BOND_ID}"))?; + require_contains( + &connect_output, + &format!("connect_result=stub-connected bond_id={BOND_ID}"), + )?; + verify_connection_state_contains(BOND_ID)?; + require_file_contains( + BTCTL_CONNECT_RESULT_PATH, + &format!("connect_result=stub-connected bond_id={BOND_ID}"), + )?; + + let read_output = print_checked_command( + &format!("{label}: battery-level read"), + "redbear-btctl", + &[ + "--read-char", + ADAPTER, + BOND_ID, + BATTERY_SERVICE_UUID, + BATTERY_LEVEL_CHAR_UUID, + ], + )?; + require_contains(&read_output, "read_char_result=stub-value")?; + require_contains(&read_output, &format!("workload={EXPERIMENTAL_WORKLOAD}"))?; + require_contains( + &read_output, + &format!("peripheral_class={PERIPHERAL_CLASS}"), + )?; + require_contains(&read_output, &format!("bond_id={BOND_ID}"))?; + require_contains( + &read_output, + &format!("service_uuid={BATTERY_SERVICE_UUID}"), + )?; + require_contains( + &read_output, + &format!("char_uuid={BATTERY_LEVEL_CHAR_UUID}"), + )?; + require_contains(&read_output, "access=read-only")?; + require_contains(&read_output, "value_percent=87")?; + require_file_contains(BTCTL_READ_CHAR_RESULT_PATH, "read_char_result=stub-value")?; + require_file_contains( + BTCTL_READ_CHAR_RESULT_PATH, + &format!("workload={EXPERIMENTAL_WORKLOAD}"), + )?; + + if verify_info { + let info = print_checked_command( + &format!("{label}: redbear-info --verbose"), + "redbear-info", + &["--verbose"], + )?; + require_contains( + &info, + &format!("Bluetooth connection state: connection_state=stub-connected"), + )?; + require_contains( + &info, + &format!("Bluetooth connect result: connect_result=stub-connected bond_id={BOND_ID}"), + )?; + require_contains(&info, "Bluetooth bond store: /var/lib/bluetooth/hci0/bonds")?; + require_contains(&info, "Bluetooth bond count: ")?; + require_contains( + &info, + "Bluetooth experimental BLE read: read_char_result=stub-value", + )?; + require_contains(&info, &format!("workload={EXPERIMENTAL_WORKLOAD}"))?; + require_contains(&info, &format!("peripheral_class={PERIPHERAL_CLASS}"))?; + require_contains(&info, "does not prove controller bring-up, general device traffic, generic GATT, real pairing, validated reconnect semantics, write support, or notify support beyond the experimental battery-sensor read-only workload")?; + } + + let disconnect_output = print_checked_command( + &format!("{label}: disconnect"), + "redbear-btctl", + &["--disconnect", ADAPTER, BOND_ID], + )?; + require_contains( + &disconnect_output, + &format!("disconnect_result=stub-disconnected bond_id={BOND_ID}"), + )?; + verify_disconnected_state(label)?; + expect_read_failure( + &format!("{label}: read-after-disconnect"), + "rejected-not-connected", + "run --connect before the experimental read", + )?; + + let remove_output = print_checked_command( + &format!("{label}: remove stub bond"), + "redbear-btctl", + &["--bond-remove", ADAPTER, BOND_ID], + )?; + require_contains(&remove_output, "removed=true")?; + ensure_bond_absent()?; + Ok(()) +} + +fn verify_btctl_restart_cleanup(_session: &mut RuntimeSession) -> Result<(), String> { + println!("=== init-managed btctl wiring ==="); + ensure_bond_absent()?; + verify_scheme_surface()?; + require_file_contains( + BTCTL_CONNECTION_STATE_PATH, + "connection_state=stub-disconnected", + )?; + require_file_not_contains(BTCTL_CONNECTION_STATE_PATH, BOND_ID)?; + require_file_contains(BTCTL_READ_CHAR_RESULT_PATH, "read_char_result=not-run")?; + let status = print_checked_command("btctl wiring status", "redbear-btctl", &["--status"])?; + require_contains(&status, "status=adapter-visible")?; + Ok(()) +} + +fn verify_btusb_restart_path(session: &mut RuntimeSession) -> Result<(), String> { + println!("=== restart: redbear-btusb transport honesty ==="); + ensure_bond_absent()?; + + let _ = print_checked_command( + "transport-restart-prep: add stub bond", + "redbear-btctl", + &["--bond-add-stub", ADAPTER, BOND_ID, BOND_ALIAS], + )?; + + session.stop_btusb()?; + let btusb_status = print_checked_command( + "transport status after stop", + "redbear-btusb", + &["--status"], + )?; + require_contains(&btusb_status, "runtime_visibility=installed-only")?; + require_contains(&btusb_status, "daemon_status=inactive")?; + + let failure = run_command("redbear-btctl", &["--connect", ADAPTER, BOND_ID])?; + if failure.success { + return Err("expected redbear-btctl --connect to fail while btusb is stopped".to_string()); + } + print_capture("transport-stop connect failure", &failure); + require_file_contains( + BTCTL_CONNECT_RESULT_PATH, + &format!("connect_result=rejected-transport-not-runtime-visible bond_id={BOND_ID}"), + )?; + require_file_contains(BTCTL_LAST_ERROR_PATH, "start redbear-btusb explicitly")?; + + session.restart_btusb()?; + verify_runtime_status()?; + + let connect_output = print_checked_command( + "transport-restart: connect", + "redbear-btctl", + &["--connect", ADAPTER, BOND_ID], + )?; + require_contains(&connect_output, "connection_state=stub-connected")?; + + let read_output = print_checked_command( + "transport-restart: battery-level read", + "redbear-btctl", + &[ + "--read-char", + ADAPTER, + BOND_ID, + BATTERY_SERVICE_UUID, + BATTERY_LEVEL_CHAR_UUID, + ], + )?; + require_contains(&read_output, "read_char_result=stub-value")?; + + let disconnect_output = print_checked_command( + "transport-restart: disconnect", + "redbear-btctl", + &["--disconnect", ADAPTER, BOND_ID], + )?; + require_contains( + &disconnect_output, + &format!("disconnect_result=stub-disconnected bond_id={BOND_ID}"), + )?; + + let remove_output = print_checked_command( + "transport-restart: remove bond", + "redbear-btctl", + &["--bond-remove", ADAPTER, BOND_ID], + )?; + require_contains(&remove_output, "removed=true")?; + ensure_bond_absent()?; + Ok(()) +} + +fn verify_scheme_surface() -> Result<(), String> { + require_path(BTCTL_ROOT)?; + require_path("/scheme/btctl/adapters")?; + require_path(BTCTL_ADAPTER_ROOT)?; + require_path(BTCTL_CONNECTION_STATE_PATH)?; + require_path(BTCTL_CONNECT_RESULT_PATH)?; + require_path(BTCTL_DISCONNECT_RESULT_PATH)?; + require_path(BTCTL_READ_CHAR_RESULT_PATH)?; + require_path(BTCTL_LAST_ERROR_PATH)?; + require_path(BTCTL_BONDS_PATH)?; + Ok(()) +} + +fn verify_runtime_status() -> Result<(), String> { + let btusb_status = print_checked_command("transport status", "redbear-btusb", &["--status"])?; + require_contains(&btusb_status, "runtime_visibility=runtime-visible")?; + require_contains(&btusb_status, "daemon_status=running")?; + + let btctl_status = + print_checked_command("host/control status", "redbear-btctl", &["--status"])?; + require_contains(&btctl_status, &format!("adapter={ADAPTER}"))?; + require_contains(&btctl_status, "status=adapter-visible")?; + require_contains(&btctl_status, "transport_status=transport=usb")?; + Ok(()) +} + +fn verify_bond_present() -> Result<(), String> { + let bond_list = print_checked_command("bond list", "redbear-btctl", &["--bond-list", ADAPTER])?; + require_contains(&bond_list, &format!("bond_id={BOND_ID}"))?; + require_contains(&bond_list, &format!("alias={BOND_ALIAS}"))?; + require_file_contains(BTCTL_BONDS_PATH, BOND_ID)?; + require_path(BTCTL_BOND_PATH)?; + require_file_contains(BTCTL_BOND_PATH, &format!("bond_id={BOND_ID}"))?; + require_file_contains(BTCTL_BOND_PATH, &format!("alias={BOND_ALIAS}"))?; + require_file_contains(BTCTL_BOND_PATH, "source=stub-cli")?; + Ok(()) +} + +fn ensure_bond_absent() -> Result<(), String> { + let bond_list = print_checked_command( + "bond list cleanup", + "redbear-btctl", + &["--bond-list", ADAPTER], + )?; + if bond_list_contains(&bond_list, BOND_ID) { + return Err(format!("expected {BOND_ID} to be absent from bond list")); + } + require_file_not_contains(BTCTL_BONDS_PATH, BOND_ID).or_else(|_| { + if Path::new(BTCTL_BONDS_PATH).exists() { + Ok(()) + } else { + Err("bond listing path unexpectedly missing".to_string()) + } + })?; + if Path::new(BTCTL_BOND_PATH).exists() { + return Err(format!("expected {BTCTL_BOND_PATH} to be absent")); + } + Ok(()) +} + +fn verify_connection_state_contains(bond_id: &str) -> Result<(), String> { + let state = read_text(BTCTL_CONNECTION_STATE_PATH)?; + require_contains(&state, "connection_state=stub-connected")?; + if connected_bond_ids_include(&state, bond_id) { + Ok(()) + } else { + Err(format!("connection state did not include {bond_id}")) + } +} + +fn verify_disconnected_state(context: &str) -> Result<(), String> { + let state = read_text(BTCTL_CONNECTION_STATE_PATH)?; + require_contains(&state, "connection_state=stub-disconnected")?; + if connected_bond_ids_include(&state, BOND_ID) { + return Err(format!( + "{context}: expected disconnected state to exclude {BOND_ID}" + )); + } + let _ = require_file_contains( + BTCTL_DISCONNECT_RESULT_PATH, + "disconnect_result=stub-disconnected", + ); + Ok(()) +} + +fn expect_read_failure( + label: &str, + result_marker: &str, + last_error_marker: &str, +) -> Result<(), String> { + let failure = run_command( + "redbear-btctl", + &[ + "--read-char", + ADAPTER, + BOND_ID, + BATTERY_SERVICE_UUID, + BATTERY_LEVEL_CHAR_UUID, + ], + )?; + if failure.success { + return Err(format!("{label}: expected read-char command to fail")); + } + print_capture(label, &failure); + require_file_contains( + BTCTL_READ_CHAR_RESULT_PATH, + &format!("read_char_result={result_marker}"), + )?; + require_file_contains(BTCTL_LAST_ERROR_PATH, last_error_marker)?; + Ok(()) +} + +fn print_checked_command(label: &str, program: &str, args: &[&str]) -> Result { + let capture = run_command(program, args)?; + if !capture.success { + return Err(command_failure( + &format!("{program} {}", args.join(" ")), + &capture, + )); + } + print_capture(label, &capture); + Ok(capture.stdout) +} + +fn print_capture(label: &str, capture: &CommandCapture) { + println!("--- {label} ---"); + if !capture.stdout.trim().is_empty() { + print!("{}", capture.stdout); + } + if !capture.stderr.trim().is_empty() { + eprint!("{}", capture.stderr); + } +} + +fn run_command(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|err| format!("failed to run {program} {:?}: {err}", args))?; + Ok(CommandCapture { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + success: output.status.success(), + }) +} + +fn command_failure(label: &str, capture: &CommandCapture) -> String { + let stderr = capture.stderr.trim(); + if !stderr.is_empty() { + format!("{label} failed: {stderr}") + } else { + let stdout = capture.stdout.trim(); + if stdout.is_empty() { + format!("{label} failed") + } else { + format!("{label} failed: {stdout}") + } + } +} + +fn wait_for_condition(label: &str, timeout: Duration, mut predicate: F) -> Result<(), String> +where + F: FnMut() -> Result, +{ + let start = Instant::now(); + loop { + if predicate()? { + return Ok(()); + } + if start.elapsed() >= timeout { + return Err(format!("timed out waiting for {label}")); + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn transport_runtime_visible() -> bool { + read_text(TRANSPORT_STATUS_PATH) + .map(|status| status.contains("runtime_visibility=runtime-visible")) + .unwrap_or(false) +} + +fn read_text(path: &str) -> Result { + fs::read_to_string(path).map_err(|err| format!("failed to read {path}: {err}")) +} + +fn require_contains(haystack: &str, needle: &str) -> Result<(), String> { + if haystack.contains(needle) { + Ok(()) + } else { + Err(format!("missing {needle}")) + } +} + +fn require_file_contains(path: &str, needle: &str) -> Result<(), String> { + let content = read_text(path)?; + require_contains(&content, needle) +} + +fn require_file_not_contains(path: &str, needle: &str) -> Result<(), String> { + let content = read_text(path)?; + if content.contains(needle) { + Err(format!("unexpected {needle} in {path}")) + } else { + Ok(()) + } +} + +fn line_with_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + text.lines() + .map(str::trim) + .find(|line| line.starts_with(prefix)) +} + +fn connected_bond_ids_include(text: &str, bond_id: &str) -> bool { + line_with_prefix(text, "connected_bond_ids=") + .map(|line| { + line.trim_start_matches("connected_bond_ids=") + .split(',') + .map(str::trim) + .any(|candidate| !candidate.is_empty() && candidate == bond_id) + }) + .unwrap_or(false) +} + +fn bond_list_contains(text: &str, bond_id: &str) -> bool { + text.lines().map(str::trim).any(|line| { + line == format!("bond_id={bond_id}") + || line.ends_with(&format!(".bond_id={bond_id}")) + || line == bond_id + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_with_prefix_returns_matching_line() { + let text = "alpha=1\nconnected_bond_ids=AA:BB:CC:DD:EE:FF\n"; + assert_eq!( + line_with_prefix(text, "connected_bond_ids="), + Some("connected_bond_ids=AA:BB:CC:DD:EE:FF") + ); + } + + #[test] + fn connected_bond_ids_include_detects_present_and_absent_ids() { + let text = "connection_state=stub-connected\nconnected_bond_ids=AA:BB:CC:DD:EE:FF,11:22:33:44:55:66\n"; + assert!(connected_bond_ids_include(text, "AA:BB:CC:DD:EE:FF")); + assert!(!connected_bond_ids_include(text, "77:88:99:AA:BB:CC")); + } + + #[test] + fn bond_list_contains_accepts_cli_and_scheme_shapes() { + assert!(bond_list_contains( + "bond.0.bond_id=AA:BB:CC:DD:EE:FF\n", + "AA:BB:CC:DD:EE:FF" + )); + assert!(bond_list_contains( + "AA:BB:CC:DD:EE:FF\n", + "AA:BB:CC:DD:EE:FF" + )); + assert!(!bond_list_contains( + "bond.0.bond_id=11:22:33:44:55:66\n", + "AA:BB:CC:DD:EE:FF" + )); + } +} diff --git a/recipes/drivers/redbear-btusb b/recipes/drivers/redbear-btusb new file mode 120000 index 00000000..c4e7e559 --- /dev/null +++ b/recipes/drivers/redbear-btusb @@ -0,0 +1 @@ +../../local/recipes/drivers/redbear-btusb \ No newline at end of file diff --git a/recipes/system/redbear-btctl b/recipes/system/redbear-btctl new file mode 120000 index 00000000..2acf028b --- /dev/null +++ b/recipes/system/redbear-btctl @@ -0,0 +1 @@ +../../local/recipes/system/redbear-btctl \ No newline at end of file