Bluetooth B2: HCI scheme daemon and HciBackend transport bridge
Add scheme.rs to btusb daemon serving scheme:hciN with full SchemeSync implementation (status, info, command, events, ACL, LE scan/connect/ disconnect). Add hci_backend.rs to btctl implementing Backend trait via scheme filesystem reads/writes instead of stub data. Backend selection via REDBEAR_BTCTL_BACKEND=hci env var, StubBackend remains default. Fix daemon_main to use correct redox-scheme 0.11 API (Socket::create, next_request/handle_sync/write_response loop) instead of non-existent SchemeBlock. 125 btusb tests, 45 btctl tests, 2 wifictl tests passing.
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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<Box<dyn UsbHciTransport>> = 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<dyn UsbHciTransport>);
|
||||
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)]
|
||||
|
||||
@@ -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<dyn UsbHciTransport>,
|
||||
controller_info: ControllerInfo,
|
||||
le_scan_active: bool,
|
||||
le_scan_results: Vec<String>,
|
||||
le_connections: Vec<(u16, [u8; 6])>,
|
||||
next_id: usize,
|
||||
handles: BTreeMap<usize, HandleKind>,
|
||||
}
|
||||
|
||||
impl HciScheme {
|
||||
pub fn new(transport: Box<dyn UsbHciTransport>, 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<dyn UsbHciTransport>, 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<String> = 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<u8> = 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<u16> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<usize> {
|
||||
Ok(SCHEME_ROOT_ID)
|
||||
}
|
||||
|
||||
fn openat(
|
||||
&mut self,
|
||||
dirfd: usize,
|
||||
path: &str,
|
||||
_flags: usize,
|
||||
_fcntl_flags: u32,
|
||||
_ctx: &CallerCtx,
|
||||
) -> Result<OpenResult> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<EventFlags> {
|
||||
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<HciCommand>,
|
||||
sent_acl: Vec<HciAcl>,
|
||||
pending_events: Vec<HciEvent>,
|
||||
pending_acl: Vec<HciAcl>,
|
||||
}
|
||||
|
||||
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<RefCell<TestTransportInner>>,
|
||||
}
|
||||
|
||||
impl TestTransport {
|
||||
fn new(inner: &Rc<RefCell<TestTransportInner>>) -> 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<Option<HciEvent>> {
|
||||
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<Option<HciAcl>> {
|
||||
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<RefCell<TestTransportInner>>) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>;
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<String>,
|
||||
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<dyn SchemeFs>,
|
||||
scan_results: Vec<String>,
|
||||
runtime_state: BTreeMap<String, AdapterRuntimeState>,
|
||||
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<dyn SchemeFs> {
|
||||
Box::new(RedoxSchemeFs)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "redox"))]
|
||||
fn create_fs() -> Box<dyn SchemeFs> {
|
||||
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<bool, String> {
|
||||
Ok(self
|
||||
.load_bonds(adapter)?
|
||||
.iter()
|
||||
.any(|bond| bond.bond_id == bond_id))
|
||||
}
|
||||
|
||||
fn read_scheme_text(&self, relative: &str) -> Result<String, String> {
|
||||
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<String> {
|
||||
let mut addrs: Vec<String> = 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<String, String> {
|
||||
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<String> {
|
||||
vec![self.adapter.clone()]
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<String> {
|
||||
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<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn connected_bond_ids(&self, adapter: &str) -> Result<Vec<String>, 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<String, String> {
|
||||
self.ensure_adapter(adapter)?;
|
||||
Ok(self.runtime_state(adapter)?.last_connect_result.clone())
|
||||
}
|
||||
|
||||
fn disconnect_result(&self, adapter: &str) -> Result<String, String> {
|
||||
self.ensure_adapter(adapter)?;
|
||||
Ok(self.runtime_state(adapter)?.last_disconnect_result.clone())
|
||||
}
|
||||
|
||||
fn read_char_result(&self, adapter: &str) -> Result<String, String> {
|
||||
self.ensure_adapter(adapter)?;
|
||||
Ok(self.runtime_state(adapter)?.last_read_char_result.clone())
|
||||
}
|
||||
|
||||
fn status(&self, adapter: &str) -> Result<AdapterStatus, String> {
|
||||
self.ensure_adapter(adapter)?;
|
||||
Ok(self.initial_status(adapter))
|
||||
}
|
||||
|
||||
fn scan(&mut self, adapter: &str) -> Result<Vec<String>, 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::<Vec<_>>();
|
||||
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<String, String> {
|
||||
self.ensure_adapter(adapter)?;
|
||||
Ok(self
|
||||
.bond_store
|
||||
.adapter_bonds_dir(adapter)
|
||||
.display()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn load_bonds(&self, adapter: &str) -> Result<Vec<BondRecord>, 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<BondRecord, String> {
|
||||
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<bool, String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<RawFd>, socket: &Socket, scheme: &mut B
|
||||
}
|
||||
|
||||
fn build_backend() -> Box<dyn Backend> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user