diff --git a/local/recipes/drivers/redbear-btusb/source/Cargo.toml b/local/recipes/drivers/redbear-btusb/source/Cargo.toml index 83ac67e1..268e9779 100644 --- a/local/recipes/drivers/redbear-btusb/source/Cargo.toml +++ b/local/recipes/drivers/redbear-btusb/source/Cargo.toml @@ -6,3 +6,10 @@ edition = "2024" [[bin]] name = "redbear-btusb" 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/drivers/redbear-btusb/source/src/main.rs b/local/recipes/drivers/redbear-btusb/source/src/main.rs index 0ad1b556..3f973001 100644 --- a/local/recipes/drivers/redbear-btusb/source/src/main.rs +++ b/local/recipes/drivers/redbear-btusb/source/src/main.rs @@ -1,4 +1,5 @@ mod hci; +mod scheme; mod usb_transport; use std::fs; @@ -761,6 +762,10 @@ fn daemon_main(_config: &TransportConfig) -> Result<(), String> { #[cfg(target_os = "redox")] fn daemon_main(config: &TransportConfig) -> Result<(), String> { + use scheme::HciScheme; + use redox_scheme::Socket; + use redox_scheme::SignalBehavior; + struct StatusFileGuard<'a> { path: &'a Path, } @@ -772,6 +777,9 @@ fn daemon_main(config: &TransportConfig) -> Result<(), String> { } let mut runtime_config = config.refreshed(); + let mut controller_info = ControllerInfo::default(); + + let mut transport: Option> = None; for adapter in &runtime_config.adapters { let transport_config = UsbTransportConfig { @@ -783,32 +791,74 @@ fn daemon_main(config: &TransportConfig) -> Result<(), String> { bulk_out_endpoint: adapter.endpoints.acl_out_endpoint, }; - let mut transport = StubTransport::new(transport_config); + let mut t = StubTransport::new(transport_config); - match hci_init_sequence(&mut transport) { + match hci_init_sequence(&mut t) { Ok(info) => { - runtime_config.controller_info = info; + controller_info = info; } Err(err) => { - runtime_config.controller_info.state = ControllerState::Error; - runtime_config.controller_info.init_error = Some(err); + controller_info.state = ControllerState::Error; + controller_info.init_error = Some(err); } } + transport = Some(Box::new(t) as Box); break; } + runtime_config.controller_info = controller_info.clone(); runtime_config.write_status_file()?; let _status_file_guard = StatusFileGuard { path: &config.status_file, }; - loop { - thread::sleep(Duration::from_secs(30)); - let controller_info = runtime_config.controller_info.clone(); - runtime_config = config.refreshed(); - runtime_config.controller_info = controller_info; - runtime_config.write_status_file()?; + let Some(t) = transport else { + loop { + thread::sleep(Duration::from_secs(30)); + let ci = runtime_config.controller_info.clone(); + runtime_config = config.refreshed(); + runtime_config.controller_info = ci; + runtime_config.write_status_file()?; + } + }; + + let scheme = HciScheme::new(t, controller_info); + let socket = Socket::create() + .map_err(|err| format!("failed to create scheme socket: {err}"))?; + let mut scheme_state = redox_scheme::scheme::SchemeState::new(); + + match libredox::call::setrens(0, 0) { + Ok(_) => log::info!("redbear-btusb: registered HCI scheme"), + Err(err) => { + return Err(format!("failed to enter null namespace: {err}")); + } } + + loop { + let request = match socket.next_request(SignalBehavior::Restart) { + Ok(Some(req)) => req, + Ok(None) => { + log::info!("redbear-btusb: scheme socket closed, shutting down"); + break; + } + Err(err) => { + log::error!("redbear-btusb: failed to read scheme request: {err}"); + break; + } + }; + match request.kind() { + redox_scheme::RequestKind::Call(request) => { + let response = request.handle_sync(&mut scheme, &mut scheme_state); + if let Err(err) = socket.write_response(response, SignalBehavior::Restart) { + log::error!("redbear-btusb: failed to write response: {err}"); + break; + } + } + _ => {} + } + } + + Ok(()) } #[cfg(test)] diff --git a/local/recipes/drivers/redbear-btusb/source/src/scheme.rs b/local/recipes/drivers/redbear-btusb/source/src/scheme.rs new file mode 100644 index 00000000..1e0fc1e0 --- /dev/null +++ b/local/recipes/drivers/redbear-btusb/source/src/scheme.rs @@ -0,0 +1,850 @@ +//! HCI scheme daemon (`scheme:hciN`) for Bluetooth USB transport. +//! +//! Exposes an HCI controller through the Redox scheme filesystem so that +//! the host daemon (redbear-btctl) can send HCI commands and receive HCI +//! events through standard file I/O. + +use std::collections::BTreeMap; + +use redox_scheme::scheme::SchemeSync; +use redox_scheme::{CallerCtx, OpenResult}; +use syscall::error::{Error, Result, EBADF, EINVAL, ENOENT, EROFS}; +use syscall::flag::{EventFlags, MODE_DIR, MODE_FILE}; +use syscall::schemev2::NewFdFlags; +use syscall::Stat; + +use crate::hci::{ + cmd_disconnect, cmd_le_create_connection, cmd_le_set_scan_enable, HciAcl, HciCommand, HciEvent, +}; +use crate::usb_transport::UsbHciTransport; +use crate::ControllerInfo; + +const SCHEME_ROOT_ID: usize = 1; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum HandleKind { + Root, + Status, + Info, + Command, + Events, + AclOut, + AclIn, + LeScan, + LeScanResults, + Connect, + Disconnect, + Connections, +} + +pub struct HciScheme { + transport: Box, + controller_info: ControllerInfo, + le_scan_active: bool, + le_scan_results: Vec, + le_connections: Vec<(u16, [u8; 6])>, + next_id: usize, + handles: BTreeMap, +} + +impl HciScheme { + pub fn new(transport: Box, controller_info: ControllerInfo) -> Self { + Self { + transport, + controller_info, + le_scan_active: false, + le_scan_results: Vec::new(), + le_connections: Vec::new(), + next_id: SCHEME_ROOT_ID + 1, + handles: BTreeMap::new(), + } + } + + pub fn new_for_test(transport: Box, controller_info: ControllerInfo) -> Self { + Self::new(transport, controller_info) + } + + 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> { + if id == SCHEME_ROOT_ID { + static ROOT: HandleKind = HandleKind::Root; + return Ok(&ROOT); + } + self.handles.get(&id).ok_or(Error::new(EBADF)) + } + + fn format_status(&self) -> String { + let state_str = match self.controller_info.state { + crate::ControllerState::Closed => "closed", + crate::ControllerState::Initializing => "initializing", + crate::ControllerState::Active => "active", + crate::ControllerState::Error => "error", + }; + let mut lines = vec![format!("controller_state={state_str}")]; + if let Some(addr) = &self.controller_info.bd_address { + lines.push(format!( + "bd_address={:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0] + )); + } + if let Some(version) = self.controller_info.hci_version { + lines.push(format!("hci_version={version}")); + } + if let Some(revision) = self.controller_info.hci_revision { + lines.push(format!("hci_revision={revision}")); + } + if let Some(manufacturer) = self.controller_info.manufacturer_name { + lines.push(format!("manufacturer={manufacturer}")); + } + lines.push(format!("le_scan_active={}", self.le_scan_active)); + lines.push(format!("le_connections={}", self.le_connections.len())); + if let Some(err) = &self.controller_info.init_error { + lines.push(format!("init_error={err}")); + } + format!("{}\n", lines.join("\n")) + } + + fn format_info(&self) -> String { + let mut lines = Vec::new(); + if let Some(addr) = &self.controller_info.bd_address { + lines.push(format!( + "bd_address={:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0] + )); + } else { + lines.push("bd_address=00:00:00:00:00:00".to_string()); + } + lines.push(format!( + "hci_version={}", + self.controller_info.hci_version.unwrap_or(0) + )); + lines.push(format!( + "hci_revision={}", + self.controller_info.hci_revision.unwrap_or(0) + )); + lines.push(format!( + "manufacturer={}", + self.controller_info.manufacturer_name.unwrap_or(0) + )); + format!("{}\n", lines.join("\n")) + } + + fn format_scan_results(&self) -> String { + if self.le_scan_results.is_empty() { + "\n".to_string() + } else { + format!("{}\n", self.le_scan_results.join("\n")) + } + } + + fn format_connections(&self) -> String { + if self.le_connections.is_empty() { + "\n".to_string() + } else { + let lines: Vec = self + .le_connections + .iter() + .map(|(handle, addr)| { + format!( + "handle={handle:04X};addr={:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + addr[5], addr[4], addr[3], addr[2], addr[1], addr[0] + ) + }) + .collect(); + format!("{}\n", lines.join("\n")) + } + } + + fn parse_addr(text: &str) -> Option<[u8; 6]> { + let cleaned = text.trim(); + let prefix = cleaned.strip_prefix("addr=")?; + let parts: Vec<&str> = prefix.split(':').collect(); + if parts.len() != 6 { + return None; + } + let bytes: Vec = parts.iter().filter_map(|p| u8::from_str_radix(p, 16).ok()).collect(); + if bytes.len() != 6 { + return None; + } + let mut addr = [0u8; 6]; + addr.copy_from_slice(&bytes); + Some(addr) + } + + fn parse_handle(text: &str) -> Option { + let cleaned = text.trim(); + let prefix = cleaned.strip_prefix("handle=")?; + let hex_str = prefix.strip_prefix("0x").unwrap_or(prefix); + u16::from_str_radix(hex_str, 16).ok() + } + + fn read_handle(&mut self, kind: &HandleKind) -> Result> { + match kind { + HandleKind::Root => Ok("status\ninfo\ncommand\nevents\nacl-out\nacl-in\nle-scan\nle-scan-results\nconnect\ndisconnect\nconnections\n".to_string().into_bytes()), + HandleKind::Status => Ok(self.format_status().into_bytes()), + HandleKind::Info => Ok(self.format_info().into_bytes()), + HandleKind::LeScanResults => Ok(self.format_scan_results().into_bytes()), + HandleKind::Connections => Ok(self.format_connections().into_bytes()), + HandleKind::Events => { + let event = self + .transport + .recv_event() + .map_err(|_| Error::new(EINVAL))?; + match event { + Some(event) => Ok(event_to_bytes(&event)), + None => Ok(Vec::new()), + } + } + HandleKind::AclIn => { + let acl = self.transport.recv_acl().map_err(|_| Error::new(EINVAL))?; + match acl { + Some(acl) => Ok(acl.to_bytes()), + None => Ok(Vec::new()), + } + } + _ => Ok(Vec::new()), + } + } + + fn write_handle(&mut self, kind: &HandleKind, buf: &[u8]) -> Result<()> { + match kind { + HandleKind::Command => { + let cmd = HciCommand::from_bytes(buf).map_err(|_| Error::new(EINVAL))?; + self.transport + .send_command(&cmd) + .map_err(|_| Error::new(EINVAL))?; + Ok(()) + } + HandleKind::AclOut => { + let acl = HciAcl::from_bytes(buf).map_err(|_| Error::new(EINVAL))?; + self.transport + .send_acl(&acl) + .map_err(|_| Error::new(EINVAL))?; + Ok(()) + } + HandleKind::LeScan => { + let text = + std::str::from_utf8(buf).map_err(|_| Error::new(EINVAL))?; + match text.trim() { + "start" => { + let cmd = cmd_le_set_scan_enable(0x01, 0x00); + self.transport + .send_command(&cmd) + .map_err(|_| Error::new(EINVAL))?; + self.le_scan_active = true; + self.le_scan_results.clear(); + Ok(()) + } + "stop" => { + let cmd = cmd_le_set_scan_enable(0x00, 0x00); + self.transport + .send_command(&cmd) + .map_err(|_| Error::new(EINVAL))?; + self.le_scan_active = false; + Ok(()) + } + _ => Err(Error::new(EINVAL)), + } + } + HandleKind::Connect => { + let text = + std::str::from_utf8(buf).map_err(|_| Error::new(EINVAL))?; + let addr = Self::parse_addr(text).ok_or(Error::new(EINVAL))?; + let cmd = cmd_le_create_connection( + 0x0060, 0x0030, 0x00, 0x00, &addr, 0x00, + 0x0006, 0x000C, 0x0000, 0x00C8, 0x0001, 0x0002, + ); + self.transport + .send_command(&cmd) + .map_err(|_| Error::new(EINVAL))?; + Ok(()) + } + HandleKind::Disconnect => { + let text = + std::str::from_utf8(buf).map_err(|_| Error::new(EINVAL))?; + let handle_val = Self::parse_handle(text).ok_or(Error::new(EINVAL))?; + let cmd = cmd_disconnect(handle_val, 0x13); + self.transport + .send_command(&cmd) + .map_err(|_| Error::new(EINVAL))?; + Ok(()) + } + _ => Err(Error::new(EROFS)), + } + } +} + +fn event_to_bytes(event: &HciEvent) -> Vec { + let param_len = u8::try_from(event.parameters.len()).unwrap_or(0xFF); + let mut buf = Vec::with_capacity(2 + event.parameters.len()); + buf.push(event.event_code); + buf.push(param_len); + buf.extend_from_slice(&event.parameters); + buf +} + +impl SchemeSync for HciScheme { + 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, + "status" => HandleKind::Status, + "info" => HandleKind::Info, + "command" => HandleKind::Command, + "events" => HandleKind::Events, + "acl-out" => HandleKind::AclOut, + "acl-in" => HandleKind::AclIn, + "le-scan" => HandleKind::LeScan, + "le-scan-results" => HandleKind::LeScanResults, + "connect" => HandleKind::Connect, + "disconnect" => HandleKind::Disconnect, + "connections" => HandleKind::Connections, + _ => return Err(Error::new(ENOENT)), + } + } else { + let parent = self.handle(dirfd)?.clone(); + match parent { + HandleKind::Root => match path.trim_matches('/') { + "status" => HandleKind::Status, + "info" => HandleKind::Info, + "command" => HandleKind::Command, + "events" => HandleKind::Events, + "acl-out" => HandleKind::AclOut, + "acl-in" => HandleKind::AclIn, + "le-scan" => HandleKind::LeScan, + "le-scan-results" => HandleKind::LeScanResults, + "connect" => HandleKind::Connect, + "disconnect" => HandleKind::Disconnect, + "connections" => HandleKind::Connections, + _ => return Err(Error::new(ENOENT)), + }, + _ => return Err(Error::new(EINVAL)), + } + }; + + 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 offset = usize::try_from(offset).map_err(|_| Error::new(EINVAL))?; + if offset >= data.len() { + return Ok(0); + } + let count = (data.len() - offset).min(buf.len()); + buf[..count].copy_from_slice(&data[offset..offset + count]); + Ok(count) + } + + fn write( + &mut self, + id: usize, + buf: &[u8], + _offset: u64, + _flags: u32, + _ctx: &CallerCtx, + ) -> Result { + let kind = self.handle(id)?.clone(); + let len = buf.len(); + self.write_handle(&kind, buf)?; + Ok(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 => MODE_DIR | 0o755, + _ => 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 => "hci0:/".to_string(), + HandleKind::Status => "hci0:/status".to_string(), + HandleKind::Info => "hci0:/info".to_string(), + HandleKind::Command => "hci0:/command".to_string(), + HandleKind::Events => "hci0:/events".to_string(), + HandleKind::AclOut => "hci0:/acl-out".to_string(), + HandleKind::AclIn => "hci0:/acl-in".to_string(), + HandleKind::LeScan => "hci0:/le-scan".to_string(), + HandleKind::LeScanResults => "hci0:/le-scan-results".to_string(), + HandleKind::Connect => "hci0:/connect".to_string(), + HandleKind::Disconnect => "hci0:/disconnect".to_string(), + HandleKind::Connections => "hci0:/connections".to_string(), + }; + 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::hci::{ + EVT_COMMAND_COMPLETE, OP_DISCONNECT, OP_LE_CREATE_CONNECTION, OP_LE_SET_SCAN_ENABLE, + }; + use crate::usb_transport::TransportState; + use std::cell::RefCell; + use std::io; + use std::rc::Rc; + + struct TestTransportInner { + sent_commands: Vec, + sent_acl: Vec, + pending_events: Vec, + pending_acl: Vec, + } + + impl TestTransportInner { + fn new() -> Self { + Self { + sent_commands: Vec::new(), + sent_acl: Vec::new(), + pending_events: Vec::new(), + pending_acl: Vec::new(), + } + } + } + + struct TestTransport { + inner: Rc>, + } + + impl TestTransport { + fn new(inner: &Rc>) -> Self { + Self { inner: Rc::clone(inner) } + } + } + + impl UsbHciTransport for TestTransport { + fn send_command(&mut self, command: &HciCommand) -> io::Result<()> { + self.inner.borrow_mut().sent_commands.push(command.clone()); + Ok(()) + } + fn recv_event(&mut self) -> io::Result> { + let mut inner = self.inner.borrow_mut(); + Ok(if inner.pending_events.is_empty() { + None + } else { + Some(inner.pending_events.remove(0)) + }) + } + fn send_acl(&mut self, acl: &HciAcl) -> io::Result<()> { + self.inner.borrow_mut().sent_acl.push(acl.clone()); + Ok(()) + } + fn recv_acl(&mut self) -> io::Result> { + let mut inner = self.inner.borrow_mut(); + Ok(if inner.pending_acl.is_empty() { + None + } else { + Some(inner.pending_acl.remove(0)) + }) + } + fn state(&self) -> TransportState { + TransportState::Active + } + fn close(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn active_info() -> ControllerInfo { + ControllerInfo { + state: crate::ControllerState::Active, + bd_address: Some([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), + hci_version: Some(9), + hci_revision: Some(1), + manufacturer_name: Some(2), + init_error: None, + } + } + + fn make_scheme() -> HciScheme { + let inner = Rc::new(RefCell::new(TestTransportInner::new())); + HciScheme::new_for_test(Box::new(TestTransport::new(&inner)), active_info()) + } + + fn make_scheme_with_inner() -> (HciScheme, Rc>) { + let inner = Rc::new(RefCell::new(TestTransportInner::new())); + let scheme = HciScheme::new_for_test(Box::new(TestTransport::new(&inner)), active_info()); + (scheme, inner) + } + + fn alloc(scheme: &mut HciScheme, kind: HandleKind) -> usize { + scheme.alloc_handle(kind) + } + + #[test] + fn root_lists_all_nodes() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::Root).unwrap(); + let text = String::from_utf8_lossy(&data); + assert!(text.contains("status")); + assert!(text.contains("info")); + assert!(text.contains("command")); + assert!(text.contains("events")); + assert!(text.contains("acl-out")); + assert!(text.contains("acl-in")); + assert!(text.contains("le-scan")); + assert!(text.contains("le-scan-results")); + assert!(text.contains("connect")); + assert!(text.contains("disconnect")); + assert!(text.contains("connections")); + } + + #[test] + fn read_status_shows_active_state() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::Status).unwrap(); + let text = String::from_utf8_lossy(&data); + assert!(text.contains("controller_state=active")); + assert!(text.contains("bd_address=FF:EE:DD:CC:BB:AA")); + assert!(text.contains("hci_version=9")); + assert!(text.contains("hci_revision=1")); + assert!(text.contains("manufacturer=2")); + assert!(text.contains("le_scan_active=false")); + assert!(text.contains("le_connections=0")); + } + + #[test] + fn read_info_shows_bd_address_and_version() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::Info).unwrap(); + let text = String::from_utf8_lossy(&data); + assert!(text.contains("bd_address=FF:EE:DD:CC:BB:AA")); + assert!(text.contains("hci_version=9")); + assert!(text.contains("hci_revision=1")); + assert!(text.contains("manufacturer=2")); + } + + #[test] + fn write_command_sends_correct_opcode_to_transport() { + let (mut scheme, inner) = make_scheme_with_inner(); + let wire = vec![0x03, 0x0C, 0x00]; + scheme.write_handle(&HandleKind::Command, &wire).unwrap(); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].opcode, 0x0C03); + } + + #[test] + fn write_command_with_params_round_trips() { + let (mut scheme, inner) = make_scheme_with_inner(); + let wire = vec![0x09, 0x10, 0x02, 0xAA, 0xBB]; + scheme.write_handle(&HandleKind::Command, &wire).unwrap(); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].opcode, 0x1009); + assert_eq!(sent[0].parameters, vec![0xAA, 0xBB]); + } + + #[test] + fn write_command_invalid_bytes_returns_einval() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::Command, &[0x03]); + assert!(result.is_err()); + } + + #[test] + fn read_events_returns_serialized_event() { + let inner = Rc::new(RefCell::new(TestTransportInner::new())); + let event = HciEvent { + event_code: EVT_COMMAND_COMPLETE, + parameters: vec![0x01, 0x03, 0x0C, 0x00], + }; + inner.borrow_mut().pending_events.push(event); + let mut scheme = HciScheme::new_for_test( + Box::new(TestTransport::new(&inner)), + active_info(), + ); + let data = scheme.read_handle(&HandleKind::Events).unwrap(); + assert_eq!(data.len(), 6); + assert_eq!(data[0], EVT_COMMAND_COMPLETE); + assert_eq!(data[1], 4); + assert_eq!(&data[2..6], &[0x01, 0x03, 0x0C, 0x00]); + } + + #[test] + fn read_events_returns_empty_when_no_events() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::Events).unwrap(); + assert!(data.is_empty()); + } + + #[test] + fn write_le_scan_start_sets_flag_and_sends_command() { + let (mut scheme, inner) = make_scheme_with_inner(); + scheme.write_handle(&HandleKind::LeScan, b"start").unwrap(); + assert!(scheme.le_scan_active); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].opcode, OP_LE_SET_SCAN_ENABLE); + assert_eq!(sent[0].parameters, vec![0x01, 0x00]); + } + + #[test] + fn write_le_scan_start_and_stop_cycle() { + let mut scheme = make_scheme(); + scheme.write_handle(&HandleKind::LeScan, b"start").unwrap(); + assert!(scheme.le_scan_active); + scheme.write_handle(&HandleKind::LeScan, b"stop").unwrap(); + assert!(!scheme.le_scan_active); + } + + #[test] + fn write_le_scan_invalid_text_returns_einval() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::LeScan, b"invalid"); + assert!(result.is_err()); + } + + #[test] + fn write_connect_parses_address_and_sends_command() { + let (mut scheme, inner) = make_scheme_with_inner(); + scheme + .write_handle(&HandleKind::Connect, b"addr=AA:BB:CC:DD:EE:FF") + .unwrap(); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].opcode, OP_LE_CREATE_CONNECTION); + assert_eq!(&sent[0].parameters[6..12], &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + } + + #[test] + fn write_connect_invalid_format_returns_einval() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::Connect, b"invalid"); + assert!(result.is_err()); + } + + #[test] + fn write_disconnect_parses_handle_and_sends_command() { + let (mut scheme, inner) = make_scheme_with_inner(); + scheme + .write_handle(&HandleKind::Disconnect, b"handle=0023") + .unwrap(); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].opcode, OP_DISCONNECT); + assert_eq!( + u16::from_le_bytes([sent[0].parameters[0], sent[0].parameters[1]]), + 0x0023 + ); + } + + #[test] + fn write_disconnect_hex_format() { + let (mut scheme, inner) = make_scheme_with_inner(); + scheme + .write_handle(&HandleKind::Disconnect, b"handle=0x0023") + .unwrap(); + let sent = inner.borrow_mut().sent_commands.clone(); + assert_eq!(sent.len(), 1); + assert_eq!( + u16::from_le_bytes([sent[0].parameters[0], sent[0].parameters[1]]), + 0x0023 + ); + } + + #[test] + fn write_disconnect_invalid_format_returns_einval() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::Disconnect, b"invalid"); + assert!(result.is_err()); + } + + #[test] + fn read_connections_shows_active_le_connections() { + let mut scheme = make_scheme(); + scheme.le_connections.push((0x0023, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])); + let data = scheme.read_handle(&HandleKind::Connections).unwrap(); + let text = String::from_utf8_lossy(&data); + assert!(text.contains("handle=0023")); + assert!(text.contains("addr=FF:EE:DD:CC:BB:AA")); + } + + #[test] + fn read_connections_empty_returns_newline() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::Connections).unwrap(); + assert_eq!(data, b"\n"); + } + + #[test] + fn read_scan_results_shows_accumulated_results() { + let mut scheme = make_scheme(); + scheme.le_scan_results.push( + "addr=AA:BB:CC:DD:EE:FF;rssi=-59;type=ADV_IND".to_string(), + ); + let data = scheme.read_handle(&HandleKind::LeScanResults).unwrap(); + let text = String::from_utf8_lossy(&data); + assert!(text.contains("addr=AA:BB:CC:DD:EE:FF")); + assert!(text.contains("rssi=-59")); + } + + #[test] + fn read_scan_results_empty_returns_newline() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::LeScanResults).unwrap(); + assert_eq!(data, b"\n"); + } + + #[test] + fn write_to_readonly_handle_returns_erofs() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::Status, b"test"); + assert!(result.is_err()); + } + + #[test] + fn write_to_events_handle_returns_erofs() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::Events, b"test"); + assert!(result.is_err()); + } + + #[test] + fn read_acl_in_returns_bytes_from_transport() { + let inner = Rc::new(RefCell::new(TestTransportInner::new())); + let acl = HciAcl::new(0x0001, 0x00, 0x00, vec![0xDE, 0xAD]); + inner.borrow_mut().pending_acl.push(acl); + let mut scheme = HciScheme::new_for_test( + Box::new(TestTransport::new(&inner)), + active_info(), + ); + let data = scheme.read_handle(&HandleKind::AclIn).unwrap(); + assert_eq!(data.len(), 6); + let parsed = HciAcl::from_bytes(&data).unwrap(); + assert_eq!(parsed.handle, 0x0001); + assert_eq!(parsed.data, vec![0xDE, 0xAD]); + } + + #[test] + fn read_acl_in_empty_returns_empty() { + let mut scheme = make_scheme(); + let data = scheme.read_handle(&HandleKind::AclIn).unwrap(); + assert!(data.is_empty()); + } + + #[test] + fn write_acl_out_sends_to_transport() { + let (mut scheme, inner) = make_scheme_with_inner(); + let acl = HciAcl::new(0x0001, 0x00, 0x00, vec![0xCA, 0xFE]); + let wire = acl.to_bytes(); + scheme.write_handle(&HandleKind::AclOut, &wire).unwrap(); + let sent = inner.borrow_mut().sent_acl.clone(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0], acl); + } + + #[test] + fn write_acl_out_invalid_bytes_returns_einval() { + let mut scheme = make_scheme(); + let result = scheme.write_handle(&HandleKind::AclOut, &[0x42]); + assert!(result.is_err()); + } + + #[test] + fn on_close_removes_handle() { + let mut scheme = make_scheme(); + let id = alloc(&mut scheme, HandleKind::Status); + assert!(scheme.handle(id).is_ok()); + scheme.on_close(id); + assert!(scheme.handle(id).is_err()); + } + + #[test] + fn on_close_does_not_remove_root() { + let mut scheme = make_scheme(); + scheme.on_close(SCHEME_ROOT_ID); + assert!(scheme.handle(SCHEME_ROOT_ID).is_ok()); + } + + #[test] + fn parse_addr_valid() { + let addr = HciScheme::parse_addr("addr=AA:BB:CC:DD:EE:FF").unwrap(); + assert_eq!(addr, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + } + + #[test] + fn parse_addr_invalid_returns_none() { + assert!(HciScheme::parse_addr("invalid").is_none()); + assert!(HciScheme::parse_addr("addr=AA:BB:CC").is_none()); + assert!(HciScheme::parse_addr("addr=GG:HH:II:JJ:KK:LL").is_none()); + } + + #[test] + fn parse_handle_without_0x_prefix() { + assert_eq!(HciScheme::parse_handle("handle=002A"), Some(0x002A)); + } + + #[test] + fn parse_handle_hex() { + assert_eq!(HciScheme::parse_handle("handle=0x0023"), Some(0x0023)); + } + + #[test] + fn parse_handle_invalid_returns_none() { + assert!(HciScheme::parse_handle("invalid").is_none()); + assert!(HciScheme::parse_handle("handle=").is_none()); + } + + #[test] + fn event_to_bytes_serializes_correctly() { + let event = HciEvent { + event_code: EVT_COMMAND_COMPLETE, + parameters: vec![0x01, 0x02, 0x03], + }; + let bytes = event_to_bytes(&event); + assert_eq!(bytes, vec![EVT_COMMAND_COMPLETE, 0x03, 0x01, 0x02, 0x03]); + } +} diff --git a/local/recipes/system/redbear-btctl/source/src/hci_backend.rs b/local/recipes/system/redbear-btctl/source/src/hci_backend.rs new file mode 100644 index 00000000..37fecf80 --- /dev/null +++ b/local/recipes/system/redbear-btctl/source/src/hci_backend.rs @@ -0,0 +1,980 @@ +//! HCI scheme backend for redbear-btctl. +//! +//! Implements the `Backend` trait by reading/writing HCI scheme files +//! (`/scheme/hciN/*`) instead of using hardcoded stub data. + +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::path::{Path, PathBuf}; + +use crate::backend::{AdapterStatus, Backend}; +use crate::bond_store::{validate_adapter_name, BondRecord, BondStore, STUB_BOND_SOURCE}; + +// --------------------------------------------------------------------------- +// Scheme filesystem abstraction +// --------------------------------------------------------------------------- + +/// Abstraction over filesystem operations so tests can use `std::fs` against +/// temp directories while production code uses libredox scheme calls. +trait SchemeFs { + fn read_file(&self, path: &Path) -> std::io::Result>; + fn write_file(&self, path: &Path, data: &[u8]) -> std::io::Result<()>; +} + +/// Standard filesystem adapter — used in tests and on non-Redox hosts. +struct StdFs; + +impl SchemeFs for StdFs { + fn read_file(&self, path: &Path) -> std::io::Result> { + std::fs::read(path) + } + + fn write_file(&self, path: &Path, data: &[u8]) -> std::io::Result<()> { + // Ensure parent directory exists for test mock filesystems. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, data) + } +} + +/// Redox scheme filesystem adapter — uses libredox for direct scheme I/O. +#[cfg(target_os = "redox")] +struct RedoxSchemeFs; + +#[cfg(target_os = "redox")] +impl SchemeFs for RedoxSchemeFs { + fn read_file(&self, path: &Path) -> std::io::Result> { + let path_str = path + .to_str() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "non-UTF-8 path"))?; + let fd = libredox::call::open(path_str, libc::O_RDONLY, 0)?; + let mut buf = vec![0u8; 4096]; + let n = libredox::call::read(fd, &mut buf)?; + buf.truncate(n); + Ok(buf) + } + + fn write_file(&self, path: &Path, data: &[u8]) -> std::io::Result<()> { + let path_str = path + .to_str() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "non-UTF-8 path"))?; + let fd = libredox::call::open(path_str, libc::O_WRONLY, 0)?; + libredox::call::write(fd, data)?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Experimental read-char constants (mirrors backend.rs internals) +// --------------------------------------------------------------------------- + +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; + +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 + ) +} + +// --------------------------------------------------------------------------- +// Per-adapter runtime state +// --------------------------------------------------------------------------- + +#[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() + } + } +} + +// --------------------------------------------------------------------------- +// HciBackend +// --------------------------------------------------------------------------- + +pub struct HciBackend { + scheme_path: PathBuf, + adapter: String, + fs: Box, + scan_results: Vec, + runtime_state: BTreeMap, + bond_store: BondStore, +} + +impl HciBackend { + /// Build an HciBackend from environment variables (production path). + /// + /// On Redox, uses `RedoxSchemeFs` for direct scheme I/O. + /// On non-Redox hosts, falls back to `StdFs` (useful for development). + pub fn from_env() -> Self { + let adapter = + env::var("REDBEAR_BTCTL_HCI_ADAPTER").unwrap_or_else(|_| "hci0".to_string()); + let scheme_path = PathBuf::from(format!("/scheme/{adapter}")); + + Self { + runtime_state: { + let mut map = BTreeMap::new(); + map.insert(adapter.clone(), AdapterRuntimeState::new()); + map + }, + adapter: adapter.clone(), + scheme_path, + fs: Self::create_fs(), + scan_results: Vec::new(), + bond_store: BondStore::from_env(), + } + } + + /// Build an HciBackend for testing with a mock filesystem root. + #[cfg(test)] + pub fn new_for_test(scheme_root: PathBuf, adapter: String, bond_store_root: PathBuf) -> Self { + validate_adapter_name(&adapter).expect("invalid test adapter name"); + + Self { + scheme_path: scheme_root.join(&adapter), + adapter: adapter.clone(), + fs: Box::new(StdFs), + scan_results: Vec::new(), + runtime_state: { + let mut map = BTreeMap::new(); + map.insert(adapter, AdapterRuntimeState::new()); + map + }, + bond_store: BondStore::new(bond_store_root), + } + } + + #[cfg(target_os = "redox")] + fn create_fs() -> Box { + Box::new(RedoxSchemeFs) + } + + #[cfg(not(target_os = "redox"))] + fn create_fs() -> Box { + Box::new(StdFs) + } + + fn ensure_adapter(&self, adapter: &str) -> Result<(), String> { + if adapter == self.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 read_scheme_text(&self, relative: &str) -> Result { + let path = self.scheme_path.join(relative); + self.fs + .read_file(&path) + .map_err(|err| format!("failed to read {}: {err}", path.display())) + .and_then(|bytes| { + String::from_utf8(bytes) + .map_err(|err| format!("non-UTF-8 response from {}: {err}", path.display())) + }) + } + + fn write_scheme(&self, relative: &str, data: &[u8]) -> Result<(), String> { + let path = self.scheme_path.join(relative); + self.fs + .write_file(&path, data) + .map_err(|err| format!("failed to write {}: {err}", path.display())) + } + + fn parse_controller_state(status: &str) -> AdapterStatus { + for line in status.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("controller_state=") { + return match value.trim() { + "active" => AdapterStatus::AdapterVisible, + "scanning" => AdapterStatus::Scanning, + _ => AdapterStatus::ExplicitStartupRequired, + }; + } + } + AdapterStatus::ExplicitStartupRequired + } + + fn parse_connections(content: &str) -> Vec { + let mut addrs: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .filter_map(|line| { + line.split_whitespace() + .find_map(|part| part.strip_prefix("addr=").map(str::to_string)) + }) + .collect(); + addrs.sort(); + addrs + } + + fn resolve_handle(&self, bond_id: &str) -> Result { + let content = self.read_scheme_text("connections")?; + for line in content.lines().map(str::trim) { + if line.is_empty() { + continue; + } + let mut handle = None; + let mut addr = None; + for part in line.split_whitespace() { + if let Some(v) = part.strip_prefix("handle=") { + handle = Some(v.to_string()); + } + if let Some(v) = part.strip_prefix("addr=") { + addr = Some(v.to_string()); + } + } + if addr.as_deref() == Some(bond_id) { + return handle.ok_or_else(|| { + format!("connection entry for {bond_id} has no handle field") + }); + } + } + Err(format!("bond {bond_id} not found in active connections")) + } +} + +impl Backend for HciBackend { + fn adapters(&self) -> Vec { + vec![self.adapter.clone()] + } + + fn capabilities(&self) -> Vec { + vec![ + "backend=hci-scheme".to_string(), + "transport=usb".to_string(), + "startup=auto".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(), + format!("bond_store={}", STUB_BOND_SOURCE), + "scheme=btctl".to_string(), + format!("scheme_path={}", self.scheme_path.display()), + format!("bond_store_root={}", self.bond_store.root().display()), + ] + } + + fn initial_status(&self, adapter: &str) -> AdapterStatus { + if self.ensure_adapter(adapter).is_err() { + return AdapterStatus::Failed; + } + match self.read_scheme_text("status") { + Ok(content) => Self::parse_controller_state(&content), + Err(_) => AdapterStatus::ExplicitStartupRequired, + } + } + + fn transport_status(&self, adapter: &str) -> String { + if self.ensure_adapter(adapter).is_err() { + return "transport=unknown-adapter".to_string(); + } + self.read_scheme_text("status").unwrap_or_else(|_| { + format!( + "transport=usb startup=auto scheme_path={}", + self.scheme_path.display() + ) + }) + } + + fn default_scan_results(&self, _adapter: &str) -> Vec { + Vec::new() + } + + fn connected_bond_ids(&self, adapter: &str) -> Result, String> { + self.ensure_adapter(adapter)?; + if let Ok(content) = self.read_scheme_text("connections") { + let parsed = Self::parse_connections(&content); + if !parsed.is_empty() { + return Ok(parsed); + } + } + 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)?; + self.write_scheme("le-scan", b"start")?; + let content = self.read_scheme_text("le-scan-results")?; + let results = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); + self.scan_results = results.clone(); + Ok(results) + } + + fn connect(&mut self, adapter: &str, bond_id: &str) -> Result<(), String> { + self.ensure_adapter(adapter)?; + 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()); + } + self.write_scheme("connect", format!("addr={bond_id}").as_bytes())?; + 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=hci-scheme-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.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()); + } + match self.resolve_handle(bond_id) { + Ok(h) => { + self.write_scheme("disconnect", format!("handle={h}").as_bytes())?; + } + Err(_) => { + // No active connection in scheme; proceed with local state update. + } + } + 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=hci-scheme-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.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=hci-scheme-disconnected bond_id={bond_id} state=removed-with-bond" + ); + } + } + Ok(removed) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + 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 setup_scheme(scheme_root: &Path, adapter: &str) -> PathBuf { + let adapter_dir = scheme_root.join(adapter); + fs::create_dir_all(&adapter_dir).unwrap(); + adapter_dir + } + + // -- Capabilities and adapter identity -- + + #[test] + fn hci_capabilities_report_backend_type() { + let root = temp_path("rbos-hci-cap"); + let backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-cap-bonds"), + ); + let caps = backend.capabilities(); + assert!(caps.iter().any(|c| c == "backend=hci-scheme")); + assert!(caps.iter().any(|c| c.starts_with("scheme_path="))); + assert!(caps.iter().any(|c| c == "startup=auto")); + assert_eq!(backend.adapters(), vec!["hci0".to_string()]); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_rejects_unknown_adapter() { + let root = temp_path("rbos-hci-unknown"); + let mut backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-unknown-bonds"), + ); + assert_eq!(backend.initial_status("hci9"), AdapterStatus::Failed); + assert!(backend.status("hci9").is_err()); + assert!(backend.scan("hci9").is_err()); + fs::remove_dir_all(root).ok(); + } + + // -- Status and transport -- + + #[test] + fn hci_initial_status_reads_controller_state() { + let root = temp_path("rbos-hci-status"); + let adapter_dir = setup_scheme(&root, "hci0"); + fs::write( + adapter_dir.join("status"), + "controller_state=active\ntransport=usb\n", + ) + .unwrap(); + + let backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-status-bonds"), + ); + assert_eq!( + backend.initial_status("hci0"), + AdapterStatus::AdapterVisible + ); + assert!(backend + .transport_status("hci0") + .contains("controller_state=active")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_initial_status_returns_startup_required_when_no_scheme() { + let root = temp_path("rbos-hci-no-scheme"); + let backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-no-scheme-bonds"), + ); + assert_eq!( + backend.initial_status("hci0"), + AdapterStatus::ExplicitStartupRequired + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_transport_status_falls_back_when_file_missing() { + let root = temp_path("rbos-hci-transport-missing"); + let backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-transport-missing-bonds"), + ); + let ts = backend.transport_status("hci0"); + assert!(ts.contains("transport=usb")); + assert!(ts.contains("startup=auto")); + fs::remove_dir_all(root).ok(); + } + + // -- Scan -- + + #[test] + fn hci_scan_writes_start_and_reads_results() { + let root = temp_path("rbos-hci-scan"); + let adapter_dir = setup_scheme(&root, "hci0"); + fs::write( + adapter_dir.join("le-scan-results"), + "AA:BB:CC:DD:EE:FF\n11:22:33:44:55:66\n", + ) + .unwrap(); + + let mut backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-scan-bonds"), + ); + let results = backend.scan("hci0").unwrap(); + assert_eq!( + results, + vec!["AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"] + ); + + let written = fs::read_to_string(adapter_dir.join("le-scan")).unwrap(); + assert_eq!(written, "start"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_scan_returns_error_when_scheme_not_present() { + let root = temp_path("rbos-hci-scan-missing"); + let mut backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-scan-missing-bonds"), + ); + assert!(backend.scan("hci0").is_err()); + fs::remove_dir_all(root).ok(); + } + + // -- Connect and disconnect -- + + #[test] + fn hci_connect_writes_addr_to_scheme() { + let root = temp_path("rbos-hci-connect"); + let adapter_dir = setup_scheme(&root, "hci0"); + let bond_store = temp_path("rbos-hci-connect-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo")) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let written = fs::read_to_string(adapter_dir.join("connect")).unwrap(); + assert_eq!(written, "addr=AA:BB:CC:DD:EE:FF"); + + let result = backend.connect_result("hci0").unwrap(); + assert!(result.contains("connect_result=hci-scheme-connected")); + assert!(result.contains("bond_id=AA:BB:CC:DD:EE:FF")); + + let connected = backend.connected_bond_ids("hci0").unwrap(); + assert_eq!(connected, vec!["AA:BB:CC:DD:EE:FF"]); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + #[test] + fn hci_connect_rejects_missing_bond() { + let root = temp_path("rbos-hci-connect-missing"); + let mut backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-connect-missing-bonds"), + ); + let err = backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap_err(); + assert!(err.contains("bond record not found")); + + let result = backend.connect_result("hci0").unwrap(); + assert!(result.contains("rejected-missing-bond")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_disconnect_resolves_handle_from_connections() { + let root = temp_path("rbos-hci-disconnect"); + let adapter_dir = setup_scheme(&root, "hci0"); + let bond_store = temp_path("rbos-hci-disconnect-bonds"); + + fs::write( + adapter_dir.join("connections"), + "handle=0042 addr=AA:BB:CC:DD:EE:FF\n", + ) + .unwrap(); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo")) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + backend.disconnect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let written = fs::read_to_string(adapter_dir.join("disconnect")).unwrap(); + assert_eq!(written, "handle=0042"); + + let result = backend.disconnect_result("hci0").unwrap(); + assert!(result.contains("disconnect_result=hci-scheme-disconnected")); + assert!(result.contains("bond_id=AA:BB:CC:DD:EE:FF")); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + #[test] + fn hci_disconnect_proceeds_without_handle_if_no_connection_file() { + let root = temp_path("rbos-hci-disconnect-noconn"); + let bond_store = temp_path("rbos-hci-disconnect-noconn-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo")) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + backend.disconnect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let result = backend.disconnect_result("hci0").unwrap(); + assert!(result.contains("disconnect_result=hci-scheme-disconnected")); + assert!(backend.connected_bond_ids("hci0").unwrap().is_empty()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + // -- Connected bond IDs from connections file -- + + #[test] + fn hci_connected_bond_ids_reads_from_scheme() { + let root = temp_path("rbos-hci-connected"); + let adapter_dir = setup_scheme(&root, "hci0"); + fs::write( + adapter_dir.join("connections"), + "handle=0001 addr=AA:BB:CC:DD:EE:FF\nhandle=0002 addr=11:22:33:44:55:66\n", + ) + .unwrap(); + + let backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-connected-bonds"), + ); + let ids = backend.connected_bond_ids("hci0").unwrap(); + assert_eq!(ids, vec!["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"]); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn hci_connected_bond_ids_falls_back_to_runtime_state() { + let root = temp_path("rbos-hci-connected-fallback"); + let _adapter_dir = setup_scheme(&root, "hci0"); + + let mut backend = HciBackend::new_for_test( + root.clone(), + "hci0".to_string(), + temp_path("rbos-hci-connected-fallback-bonds"), + ); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", None) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let ids = backend.connected_bond_ids("hci0").unwrap(); + assert_eq!(ids, vec!["AA:BB:CC:DD:EE:FF"]); + fs::remove_dir_all(root).ok(); + } + + // -- Read char (experimental stub) -- + + #[test] + fn hci_read_char_returns_experimental_stub_when_connected() { + let root = temp_path("rbos-hci-read-char"); + let bond_store = temp_path("rbos-hci-read-char-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("battery")) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + 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("value_percent=87")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + #[test] + fn hci_read_char_rejects_unsupported_characteristic() { + let root = temp_path("rbos-hci-read-char-unsupported"); + let bond_store = temp_path("rbos-hci-read-char-unsupported-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", None) + .unwrap(); + backend.connect("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + + let err = backend + .read_char( + "hci0", + "AA:BB:CC:DD:EE:FF", + EXPERIMENTAL_SERVICE_UUID, + "00002a1a-0000-1000-8000-00805f9b34fb", + ) + .unwrap_err(); + assert!(err.contains("only the experimental")); + + let result = backend.read_char_result("hci0").unwrap(); + assert!(result.contains("rejected-unsupported-characteristic")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + #[test] + fn hci_read_char_rejects_not_connected() { + let root = temp_path("rbos-hci-read-char-not-conn"); + let bond_store = temp_path("rbos-hci-read-char-not-conn-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", None) + .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")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + // -- Bond store -- + + #[test] + fn hci_bond_store_persists_across_backend_instances() { + let root = temp_path("rbos-hci-bond-persist"); + let bond_store = temp_path("rbos-hci-bond-persist-bonds"); + + let mut writer = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + let record = writer + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", Some("demo")) + .unwrap(); + assert_eq!(record.source, "stub-cli"); + + let reader = HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.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")); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } + + #[test] + fn hci_remove_bond_clears_connection_state() { + let root = temp_path("rbos-hci-remove-bond"); + let bond_store = temp_path("rbos-hci-remove-bond-bonds"); + + let mut backend = + HciBackend::new_for_test(root.clone(), "hci0".to_string(), bond_store.clone()); + backend + .add_stub_bond("hci0", "AA:BB:CC:DD:EE:FF", None) + .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"] + ); + + backend.remove_bond("hci0", "AA:BB:CC:DD:EE:FF").unwrap(); + assert!(backend.connected_bond_ids("hci0").unwrap().is_empty()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(bond_store).ok(); + } +} diff --git a/local/recipes/system/redbear-btctl/source/src/main.rs b/local/recipes/system/redbear-btctl/source/src/main.rs index 194fad6d..c8e0e2f7 100644 --- a/local/recipes/system/redbear-btctl/source/src/main.rs +++ b/local/recipes/system/redbear-btctl/source/src/main.rs @@ -1,5 +1,6 @@ mod backend; mod bond_store; +mod hci_backend; mod scheme; use std::env; @@ -71,7 +72,12 @@ fn notify_scheme_ready(notify_fd: Option, socket: &Socket, scheme: &mut B } fn build_backend() -> Box { - Box::new(StubBackend::from_env()) + let backend_type = env::var("REDBEAR_BTCTL_BACKEND") + .unwrap_or_else(|_| "stub".to_string()); + match backend_type.as_str() { + "hci" => Box::new(hci_backend::HciBackend::from_env()), + _ => Box::new(StubBackend::from_env()), + } } fn default_adapter(backend: &dyn Backend) -> String {