Files
RedBear-OS/local/recipes/drivers/redbear-btusb/source/src/main.rs
T
vasilito 8ff8c084f5 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.
2026-04-24 23:14:56 +01:00

1327 lines
43 KiB
Rust

mod hci;
mod scheme;
mod usb_transport;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process;
#[cfg(target_os = "redox")]
use std::thread;
#[cfg(target_os = "redox")]
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use hci::{cmd_read_bd_addr, cmd_read_local_version, cmd_reset, parse_read_bd_addr, parse_local_version};
use usb_transport::UsbHciTransport;
#[cfg(target_os = "redox")]
use usb_transport::{StubTransport, UsbTransportConfig};
const STATUS_FRESHNESS_SECS: u64 = 90;
const BLUETOOTH_USB_CLASS: u8 = 0xE0;
const BLUETOOTH_USB_SUBCLASS: u8 = 0x01;
const BLUETOOTH_USB_PROTOCOL: u8 = 0x01;
const KNOWN_BLUETOOTH_USB_VENDORS: [u16; 4] = [0x8087, 0x0BDA, 0x0A5C, 0x0A12];
#[derive(Clone, Debug, PartialEq, Eq)]
struct UsbBluetoothAdapter {
name: String,
vendor_id: u16,
device_id: u16,
bus: String,
device_path: PathBuf,
endpoints: HciEndpoints,
}
impl UsbBluetoothAdapter {
#[cfg(any(not(target_os = "redox"), test))]
fn stub(name: String) -> Self {
Self {
device_path: PathBuf::from(format!("/scheme/usb/stub/{name}")),
name,
vendor_id: 0,
device_id: 0,
bus: "stub".to_string(),
endpoints: HciEndpoints::default(),
}
}
fn detail_line(&self, index: usize) -> String {
format!(
"adapter_{index}=name={};vendor_id={:04x};device_id={:04x};bus={};device_path={};event_ep={};acl_in_ep={};acl_out_ep={}",
self.name,
self.vendor_id,
self.device_id,
self.bus,
self.device_path.display(),
self.endpoints.event_endpoint,
self.endpoints.acl_in_endpoint,
self.endpoints.acl_out_endpoint,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct UsbDeviceDescriptor {
vendor_id: u16,
device_id: u16,
class: u8,
subclass: u8,
protocol: u8,
}
impl UsbDeviceDescriptor {
fn looks_like_bluetooth(self) -> bool {
(self.class, self.subclass, self.protocol)
== (
BLUETOOTH_USB_CLASS,
BLUETOOTH_USB_SUBCLASS,
BLUETOOTH_USB_PROTOCOL,
)
|| KNOWN_BLUETOOTH_USB_VENDORS.contains(&self.vendor_id)
}
}
/// USB HCI transport endpoint addresses for a Bluetooth controller
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HciEndpoints {
/// Interrupt IN endpoint for HCI events (required)
pub event_endpoint: u8,
/// Bulk IN endpoint for ACL data from controller (required)
pub acl_in_endpoint: u8,
/// Bulk OUT endpoint for ACL data to controller (required)
pub acl_out_endpoint: u8,
/// Maximum packet size for the event endpoint
pub event_max_packet_size: u16,
/// Maximum packet size for the bulk IN endpoint
pub acl_in_max_packet_size: u16,
/// Maximum packet size for the bulk OUT endpoint
pub acl_out_max_packet_size: u16,
}
/// Controller initialization state
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ControllerState {
/// No communication with controller yet
#[default]
Closed,
/// Sending HCI initialization commands
Initializing,
/// Controller is ready for use
Active,
/// Initialization or communication failed
Error,
}
/// Information gathered from the controller during initialization
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ControllerInfo {
pub state: ControllerState,
pub bd_address: Option<[u8; 6]>,
pub hci_version: Option<u8>,
pub hci_revision: Option<u16>,
pub manufacturer_name: Option<u16>,
pub init_error: Option<String>,
}
/// USB endpoint descriptor fields extracted from raw descriptor data
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct UsbEndpointDescriptor {
endpoint_address: u8,
attributes: u8,
max_packet_size: u16,
interval: u8,
}
/// USB transfer type from bmAttributes bits 0-1
const USB_TRANSFER_INTERRUPT: u8 = 3;
const USB_TRANSFER_BULK: u8 = 2;
/// Parse a single USB endpoint descriptor from raw bytes.
///
/// USB endpoint descriptor is 7 bytes:
/// [0] bLength = 7
/// [1] bDescriptorType = 5 (ENDPOINT)
/// [2] bEndpointAddress (bit 7 = direction: 0=OUT, 1=IN; bits 0-3 = endpoint number)
/// [3] bmAttributes (bits 0-1 = transfer type: 0=control, 1=isochronous, 2=bulk, 3=interrupt)
/// [4-5] wMaxPacketSize (little-endian)
/// [6] bInterval
fn parse_usb_endpoint_descriptor(raw: &[u8]) -> Option<UsbEndpointDescriptor> {
if raw.len() < 7 {
return None;
}
if raw[0] != 7 {
return None; // bLength must be 7
}
if raw[1] != 5 {
return None; // bDescriptorType must be ENDPOINT (5)
}
Some(UsbEndpointDescriptor {
endpoint_address: raw[2],
attributes: raw[3],
max_packet_size: u16::from_le_bytes([raw[4], raw[5]]),
interval: raw[6],
})
}
/// Parse HCI endpoints from raw USB descriptors blob.
///
/// Walks the descriptor blob looking for endpoint descriptors that match
/// the Bluetooth HCI interface (interrupt IN, bulk IN, bulk OUT).
pub fn parse_hci_endpoints_from_descriptors(raw: &[u8]) -> Result<HciEndpoints, String> {
let mut endpoints = HciEndpoints::default();
let mut found_interrupt_in = false;
let mut found_bulk_in = false;
let mut found_bulk_out = false;
let mut offset = 0;
while offset + 2 <= raw.len() {
let desc_len = raw[offset] as usize;
let desc_type = raw[offset + 1];
if desc_len < 2 || offset + desc_len > raw.len() {
break;
}
if desc_type == 5 {
// ENDPOINT descriptor
if let Some(ep) = parse_usb_endpoint_descriptor(&raw[offset..]) {
let direction_in = (ep.endpoint_address & 0x80) != 0;
let endpoint_num = ep.endpoint_address & 0x0F;
let transfer_type = ep.attributes & 0x03;
match (transfer_type, direction_in) {
(USB_TRANSFER_INTERRUPT, true) => {
endpoints.event_endpoint = endpoint_num;
endpoints.event_max_packet_size = ep.max_packet_size;
found_interrupt_in = true;
}
(USB_TRANSFER_BULK, true) => {
endpoints.acl_in_endpoint = endpoint_num;
endpoints.acl_in_max_packet_size = ep.max_packet_size;
found_bulk_in = true;
}
(USB_TRANSFER_BULK, false) => {
endpoints.acl_out_endpoint = endpoint_num;
endpoints.acl_out_max_packet_size = ep.max_packet_size;
found_bulk_out = true;
}
_ => {}
}
}
}
offset += desc_len.max(1); // Avoid infinite loop on zero-length descriptor
}
if !found_interrupt_in {
return Err("missing HCI interrupt IN endpoint in USB descriptors".to_string());
}
if !found_bulk_in {
return Err("missing HCI bulk IN endpoint in USB descriptors".to_string());
}
if !found_bulk_out {
return Err("missing HCI bulk OUT endpoint in USB descriptors".to_string());
}
Ok(endpoints)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TransportConfig {
adapters: Vec<UsbBluetoothAdapter>,
controller_family: String,
status_file: PathBuf,
controller_info: ControllerInfo,
}
impl TransportConfig {
fn from_env() -> Self {
Self {
adapters: default_adapters_from_env(),
controller_family: std::env::var("REDBEAR_BTUSB_STUB_FAMILY")
.unwrap_or_else(|_| "usb-generic-bounded".to_string()),
status_file: std::env::var_os("REDBEAR_BTUSB_STATUS_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/run/redbear-btusb/status")),
controller_info: ControllerInfo::default(),
}
}
fn adapter_names(&self) -> Vec<String> {
self.adapters
.iter()
.map(|adapter| adapter.name.clone())
.collect()
}
fn refreshed(&self) -> Self {
let mut refreshed = self.clone();
if let Ok(adapters) = runtime_usb_bluetooth_adapters() {
refreshed.adapters = adapters;
}
refreshed
}
fn probe_lines(&self) -> Vec<String> {
let mut lines = vec![
format!("adapters={}", self.adapter_names().join(",")),
"transport=usb".to_string(),
"startup=explicit".to_string(),
"mode=ble-first".to_string(),
format!("controller_family={}", self.controller_family),
format!("adapter_count={}", self.adapters.len()),
];
lines.extend(
self.adapters
.iter()
.enumerate()
.map(|(index, adapter)| adapter.detail_line(index)),
);
lines
}
fn render_status_lines(&self, runtime_visible: bool) -> Vec<String> {
let mut lines = self.probe_lines();
lines.push(format!("updated_at_epoch={}", current_epoch_seconds()));
lines.push(format!(
"runtime_visibility={}",
if runtime_visible {
"runtime-visible"
} else {
"installed-only"
}
));
lines.push(format!(
"daemon_status={}",
if runtime_visible {
"running"
} else {
"inactive"
}
));
lines.push(format!("status_file={}", self.status_file.display()));
let state_str = match self.controller_info.state {
ControllerState::Closed => "closed",
ControllerState::Initializing => "initializing",
ControllerState::Active => "active",
ControllerState::Error => "error",
};
lines.push(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}"));
}
if let Some(err) = &self.controller_info.init_error {
lines.push(format!("init_error={err}"));
}
lines
}
fn current_status_lines(&self) -> Vec<String> {
read_status_lines(&self.status_file)
.filter(|lines| status_lines_are_fresh(lines))
.unwrap_or_else(|| self.render_status_lines(false))
}
fn write_status_file(&self) -> Result<(), String> {
if let Some(parent) = self.status_file.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create transport status directory {}: {err}",
parent.display()
)
})?;
}
fs::write(
&self.status_file,
format_lines(&self.render_status_lines(true)),
)
.map_err(|err| {
format!(
"failed to write transport status file {}: {err}",
self.status_file.display()
)
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Command {
Probe,
Status,
Daemon,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CommandOutcome {
Print(String),
RunDaemon,
}
#[cfg(any(not(target_os = "redox"), test))]
fn default_adapters_from_names(names: Vec<String>) -> Vec<UsbBluetoothAdapter> {
names.into_iter().map(UsbBluetoothAdapter::stub).collect()
}
#[cfg(target_os = "redox")]
fn default_adapters_from_env() -> Vec<UsbBluetoothAdapter> {
Vec::new()
}
#[cfg(not(target_os = "redox"))]
fn default_adapters_from_env() -> Vec<UsbBluetoothAdapter> {
default_adapters_from_names(parse_list(
std::env::var("REDBEAR_BTUSB_STUB_ADAPTERS").ok().as_deref(),
&["hci0"],
))
}
#[cfg(target_os = "redox")]
fn runtime_usb_bluetooth_adapters() -> Result<Vec<UsbBluetoothAdapter>, String> {
probe_usb_bluetooth_adapters()
}
#[cfg(not(target_os = "redox"))]
fn runtime_usb_bluetooth_adapters() -> Result<Vec<UsbBluetoothAdapter>, String> {
Ok(default_adapters_from_env())
}
#[cfg(any(not(target_os = "redox"), test))]
fn parse_list(raw: Option<&str>, default: &[&str]) -> Vec<String> {
raw.map(|value| {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter(|values| !values.is_empty())
.unwrap_or_else(|| default.iter().map(|value| (*value).to_string()).collect())
}
fn format_lines(lines: &[String]) -> String {
if lines.is_empty() {
"\n".to_string()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn current_epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_status_lines(path: &Path) -> Option<Vec<String>> {
let content = fs::read_to_string(path).ok()?;
let lines = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
Some(lines)
}
fn status_lines_are_fresh(lines: &[String]) -> bool {
let updated_at = lines.iter().find_map(|line| {
line.strip_prefix("updated_at_epoch=")
.and_then(|value| value.parse::<u64>().ok())
});
updated_at
.map(|timestamp| current_epoch_seconds().saturating_sub(timestamp) <= STATUS_FRESHNESS_SECS)
.unwrap_or(false)
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(String::as_str) {
Some("--probe") => Ok(Command::Probe),
Some("--status") => Ok(Command::Status),
Some("--daemon") | None => Ok(Command::Daemon),
Some(other) => Err(format!("unknown argument: {other}")),
}
}
fn execute(command: Command, config: &TransportConfig) -> CommandOutcome {
let effective_config = match command {
Command::Probe | Command::Status => config.refreshed(),
Command::Daemon => config.clone(),
};
match command {
Command::Probe => CommandOutcome::Print(format_lines(&effective_config.probe_lines())),
Command::Status => {
CommandOutcome::Print(format_lines(&effective_config.current_status_lines()))
}
Command::Daemon => CommandOutcome::RunDaemon,
}
}
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let config = TransportConfig::from_env();
let command = match parse_command(&args) {
Ok(command) => command,
Err(err) => {
eprintln!("redbear-btusb: {err}");
process::exit(1);
}
};
match execute(command, &config) {
CommandOutcome::Print(output) => {
print!("{output}");
}
CommandOutcome::RunDaemon => {
if let Err(err) = daemon_main(&config) {
eprintln!("redbear-btusb: {err}");
process::exit(1);
}
}
}
}
fn parse_numeric_value(value: &str) -> Result<u64, String> {
let trimmed = value.trim();
if let Some(hex) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16).map_err(|err| format!("invalid hex value {trimmed:?}: {err}"))
} else {
trimmed
.parse::<u64>()
.map_err(|err| format!("invalid numeric value {trimmed:?}: {err}"))
}
}
fn extract_jsonish_u64(raw: &str, keys: &[&str]) -> Result<u64, String> {
for key in keys {
let needle = format!("\"{key}\"");
let Some(key_start) = raw.find(&needle) else {
continue;
};
let after_key = &raw[key_start + needle.len()..];
let Some(colon_index) = after_key.find(':') else {
continue;
};
let after_colon = after_key[colon_index + 1..].trim_start();
if after_colon.is_empty() {
continue;
}
let token = if let Some(quoted) = after_colon.strip_prefix('"') {
let Some(end_quote) = quoted.find('"') else {
continue;
};
&quoted[..end_quote]
} else {
let end = after_colon
.find(|ch: char| matches!(ch, ',' | '}' | '\n' | '\r'))
.unwrap_or(after_colon.len());
after_colon[..end].trim()
};
if token.is_empty() {
continue;
}
return parse_numeric_value(token);
}
Err(format!(
"missing descriptor field; expected one of {keys:?}"
))
}
fn parse_usb_device_descriptor(raw: &[u8]) -> Result<UsbDeviceDescriptor, String> {
let text = String::from_utf8_lossy(raw);
let vendor_id = extract_jsonish_u64(&text, &["vendor", "vendor_id"]).and_then(|value| {
u16::try_from(value).map_err(|_| format!("vendor ID out of range: {value}"))
})?;
let device_id = extract_jsonish_u64(&text, &["product", "device", "product_id", "device_id"])
.and_then(|value| {
u16::try_from(value).map_err(|_| format!("device ID out of range: {value}"))
})?;
let class = extract_jsonish_u64(&text, &["class", "device_class"]).and_then(|value| {
u8::try_from(value).map_err(|_| format!("USB class out of range: {value}"))
})?;
let subclass = extract_jsonish_u64(&text, &["sub_class", "subclass", "device_subclass"])
.and_then(|value| {
u8::try_from(value).map_err(|_| format!("USB subclass out of range: {value}"))
})?;
let protocol =
extract_jsonish_u64(&text, &["protocol", "device_protocol"]).and_then(|value| {
u8::try_from(value).map_err(|_| format!("USB protocol out of range: {value}"))
})?;
Ok(UsbDeviceDescriptor {
vendor_id,
device_id,
class,
subclass,
protocol,
})
}
fn try_collect_bluetooth_adapter(
adapters: &mut Vec<UsbBluetoothAdapter>,
bus: &str,
device_path: &Path,
) -> Result<(), String> {
let descriptor_path = device_path.join("descriptors");
let raw = match fs::read(&descriptor_path) {
Ok(raw) => raw,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(_) => return Ok(()),
};
let descriptor = match parse_usb_device_descriptor(&raw) {
Ok(descriptor) => descriptor,
Err(_) => return Ok(()),
};
if descriptor.looks_like_bluetooth() {
let endpoints = parse_hci_endpoints_from_descriptors(&raw).unwrap_or_default();
adapters.push(UsbBluetoothAdapter {
name: String::new(),
vendor_id: descriptor.vendor_id,
device_id: descriptor.device_id,
bus: bus.to_string(),
device_path: device_path.to_path_buf(),
endpoints,
});
}
Ok(())
}
fn probe_usb_bluetooth_adapters_in(root: &Path) -> Result<Vec<UsbBluetoothAdapter>, String> {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(format!(
"failed to read USB scheme root {}: {err}",
root.display()
));
}
};
let mut adapters = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let bus_name = entry.file_name().to_string_lossy().into_owned();
if bus_name.is_empty() {
continue;
}
let bus_path = root.join(&bus_name);
try_collect_bluetooth_adapter(&mut adapters, &bus_name, &bus_path)?;
let nested_entries = match fs::read_dir(&bus_path) {
Ok(nested_entries) => nested_entries,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(_) => continue,
};
for nested_entry in nested_entries {
let nested_entry = match nested_entry {
Ok(nested_entry) => nested_entry,
Err(_) => continue,
};
let nested_name = nested_entry.file_name().to_string_lossy().into_owned();
if nested_name.is_empty() {
continue;
}
let device_path = bus_path.join(&nested_name);
try_collect_bluetooth_adapter(&mut adapters, &bus_name, &device_path)?;
}
}
adapters.sort_by(|left, right| {
left.bus
.cmp(&right.bus)
.then(left.device_path.cmp(&right.device_path))
.then(left.vendor_id.cmp(&right.vendor_id))
.then(left.device_id.cmp(&right.device_id))
});
for (index, adapter) in adapters.iter_mut().enumerate() {
adapter.name = format!("hci{index}");
}
Ok(adapters)
}
#[cfg(target_os = "redox")]
fn probe_usb_bluetooth_adapters() -> Result<Vec<UsbBluetoothAdapter>, String> {
probe_usb_bluetooth_adapters_in(Path::new("/scheme/usb"))
}
#[cfg(not(target_os = "redox"))]
#[allow(dead_code)]
fn probe_usb_bluetooth_adapters() -> Result<Vec<UsbBluetoothAdapter>, String> {
probe_usb_bluetooth_adapters_in(Path::new("/scheme/usb"))
}
fn hci_init_sequence(transport: &mut dyn UsbHciTransport) -> Result<ControllerInfo, String> {
let mut info = ControllerInfo::default();
info.state = ControllerState::Initializing;
let reset_cmd = cmd_reset();
transport
.send_command(&reset_cmd)
.map_err(|err| format!("HCI Reset send failed: {err}"))?;
let event = transport
.recv_event()
.map_err(|err| format!("HCI Reset response failed: {err}"))?;
let Some(event) = event else {
return Err("HCI Reset: no response from controller".to_string());
};
if !event.is_command_complete() {
return Err(format!(
"HCI Reset: unexpected event code 0x{:02X}",
event.event_code
));
}
let addr_cmd = cmd_read_bd_addr();
transport
.send_command(&addr_cmd)
.map_err(|err| format!("HCI Read BD Addr send failed: {err}"))?;
if let Some(event) =
transport
.recv_event()
.map_err(|err| format!("HCI Read BD Addr response: {err}"))?
{
if let Some(result) = parse_read_bd_addr(&event) {
if result.status == 0x00 {
info.bd_address = Some(result.address);
}
}
}
let version_cmd = cmd_read_local_version();
transport
.send_command(&version_cmd)
.map_err(|err| format!("HCI Read Local Version send failed: {err}"))?;
if let Some(event) = transport
.recv_event()
.map_err(|err| format!("HCI Read Local Version response: {err}"))?
{
if let Some(result) = parse_local_version(&event) {
if result.status == 0x00 {
info.hci_version = Some(result.hci_version);
info.hci_revision = Some(result.hci_revision);
info.manufacturer_name = Some(result.manufacturer_name);
}
}
}
info.state = ControllerState::Active;
Ok(info)
}
#[cfg(not(target_os = "redox"))]
fn daemon_main(_config: &TransportConfig) -> Result<(), String> {
Err("daemon mode is only supported on Redox; use --probe or --status on host".to_string())
}
#[cfg(target_os = "redox")]
fn daemon_main(config: &TransportConfig) -> Result<(), String> {
use scheme::HciScheme;
use redox_scheme::Socket;
use redox_scheme::SignalBehavior;
struct StatusFileGuard<'a> {
path: &'a Path,
}
impl Drop for StatusFileGuard<'_> {
fn drop(&mut self) {
let _ = fs::remove_file(self.path);
}
}
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 {
device_path: adapter.device_path.clone(),
vendor_id: adapter.vendor_id,
device_id: adapter.device_id,
interrupt_endpoint: adapter.endpoints.event_endpoint,
bulk_in_endpoint: adapter.endpoints.acl_in_endpoint,
bulk_out_endpoint: adapter.endpoints.acl_out_endpoint,
};
let mut t = StubTransport::new(transport_config);
match hci_init_sequence(&mut t) {
Ok(info) => {
controller_info = info;
}
Err(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,
};
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)]
mod tests {
use super::*;
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(name: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!("{name}-{stamp}"))
}
fn stub_adapter(name: &str) -> UsbBluetoothAdapter {
UsbBluetoothAdapter::stub(name.to_string())
}
fn test_config(status_file: PathBuf) -> TransportConfig {
TransportConfig {
adapters: vec![stub_adapter("hci0")],
controller_family: "usb-bounded-test".to_string(),
status_file,
controller_info: ControllerInfo::default(),
}
}
fn write_descriptor(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn probe_contract_is_bounded_and_usb_scoped() {
let output = execute(Command::Probe, &test_config(temp_path("rbos-btusb-status")));
let CommandOutcome::Print(output) = output else {
panic!("expected printable output");
};
assert!(output.contains("adapters=hci0"));
assert!(output.contains("transport=usb"));
assert!(output.contains("startup=explicit"));
assert!(output.contains("mode=ble-first"));
}
#[test]
fn status_defaults_to_installed_only_without_runtime_file() {
let status_file = temp_path("rbos-btusb-status-missing");
let output = execute(Command::Status, &test_config(status_file));
let CommandOutcome::Print(output) = output else {
panic!("expected printable output");
};
assert!(output.contains("runtime_visibility=installed-only"));
assert!(output.contains("daemon_status=inactive"));
}
#[test]
fn status_uses_runtime_file_when_present() {
let status_file = temp_path("rbos-btusb-status-present");
let config = test_config(status_file.clone());
config.write_status_file().unwrap();
let output = execute(Command::Status, &config);
let CommandOutcome::Print(output) = output else {
panic!("expected printable output");
};
assert!(output.contains("runtime_visibility=runtime-visible"));
assert!(output.contains("daemon_status=running"));
fs::remove_file(status_file).unwrap();
}
#[test]
fn stale_status_file_is_treated_as_installed_only() {
let status_file = temp_path("rbos-btusb-status-stale");
fs::write(
&status_file,
"adapters=hci0\ntransport=usb\nstartup=explicit\nmode=ble-first\nupdated_at_epoch=1\nruntime_visibility=runtime-visible\ndaemon_status=running\n",
)
.unwrap();
let output = execute(Command::Status, &test_config(status_file.clone()));
let CommandOutcome::Print(output) = output else {
panic!("expected printable output");
};
assert!(output.contains("runtime_visibility=installed-only"));
assert!(output.contains("daemon_status=inactive"));
fs::remove_file(status_file).unwrap();
}
#[test]
fn parse_command_accepts_probe_status_and_daemon() {
assert_eq!(
parse_command(&["--probe".to_string()]).unwrap(),
Command::Probe
);
assert_eq!(
parse_command(&["--status".to_string()]).unwrap(),
Command::Status
);
assert_eq!(parse_command(&[]).unwrap(), Command::Daemon);
}
#[test]
fn probe_usb_bluetooth_adapters_filters_and_enumerates_devices() {
let root = temp_path("rbos-btusb-usb-root");
write_descriptor(
&root
.join("usb.0000:00:14.0")
.join("port1")
.join("descriptors"),
r#"{"class":224,"sub_class":1,"protocol":1,"vendor":32903,"product":50}"#,
);
write_descriptor(
&root
.join("usb.0000:00:14.0")
.join("port2")
.join("descriptors"),
r#"{"class":3,"sub_class":1,"protocol":1,"vendor":4660,"product":22136}"#,
);
write_descriptor(
&root
.join("usb.0000:00:15.0")
.join("port3")
.join("descriptors"),
r#"{"class":224,"sub_class":1,"protocol":1,"vendor":3034,"product":4660}"#,
);
let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap();
assert_eq!(adapters.len(), 2);
assert_eq!(adapters[0].name, "hci0");
assert_eq!(adapters[0].vendor_id, 0x8087u16);
assert_eq!(adapters[0].device_id, 0x0032u16);
assert_eq!(adapters[0].bus, "usb.0000:00:14.0");
assert!(adapters[0]
.device_path
.ends_with(Path::new("usb.0000:00:14.0/port1")));
assert_eq!(adapters[1].name, "hci1");
assert_eq!(adapters[1].vendor_id, 0x0bdau16);
assert_eq!(adapters[1].device_id, 0x1234u16);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn probe_usb_bluetooth_adapters_accepts_known_vendor_fallback() {
let root = temp_path("rbos-btusb-known-vendor");
write_descriptor(
&root
.join("usb.0000:00:16.0")
.join("port7")
.join("descriptors"),
r#"{"class":255,"sub_class":255,"protocol":255,"vendor":2652,"product":4660}"#,
);
let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap();
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name, "hci0");
assert_eq!(adapters[0].vendor_id, 0x0a5cu16);
assert_eq!(adapters[0].device_id, 0x1234u16);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn probe_usb_bluetooth_adapters_handles_missing_usb_tree() {
let root = temp_path("rbos-btusb-missing-root");
let adapters = probe_usb_bluetooth_adapters_in(&root).unwrap();
assert!(adapters.is_empty());
}
#[test]
fn parse_usb_endpoint_descriptor_valid() {
let raw: &[u8] = &[7, 5, 0x81, 0x03, 0x40, 0x00, 0x01];
let ep = parse_usb_endpoint_descriptor(raw).unwrap();
assert_eq!(ep.endpoint_address, 0x81);
assert_eq!(ep.attributes, 0x03);
assert_eq!(ep.max_packet_size, 64);
assert_eq!(ep.interval, 1);
}
#[test]
fn parse_usb_endpoint_descriptor_too_short() {
let raw: &[u8] = &[7, 5, 0x81, 0x03, 0x40];
assert!(parse_usb_endpoint_descriptor(raw).is_none());
}
#[test]
fn parse_usb_endpoint_descriptor_wrong_length() {
let raw: &[u8] = &[9, 5, 0x81, 0x03, 0x40, 0x00, 0x01];
assert!(parse_usb_endpoint_descriptor(raw).is_none());
}
#[test]
fn parse_usb_endpoint_descriptor_wrong_type() {
let raw: &[u8] = &[7, 4, 0x81, 0x03, 0x40, 0x00, 0x01];
assert!(parse_usb_endpoint_descriptor(raw).is_none());
}
#[test]
fn parse_hci_endpoints_from_descriptors_extracts_all_three() {
let blob: Vec<u8> = vec![
// Interface descriptor (9 bytes)
9, 4, 0, 0, 3, 0xE0, 0x01, 0x01, 0x00,
// Interrupt IN endpoint: address=0x81 (EP1 IN), attributes=0x03 (interrupt), max_packet=64
7, 5, 0x81, 0x03, 0x40, 0x00, 0x01,
// Bulk IN endpoint: address=0x82 (EP2 IN), attributes=0x02 (bulk), max_packet=512
7, 5, 0x82, 0x02, 0x00, 0x02, 0x00,
// Bulk OUT endpoint: address=0x02 (EP2 OUT), attributes=0x02 (bulk), max_packet=512
7, 5, 0x02, 0x02, 0x00, 0x02, 0x00,
];
let endpoints = parse_hci_endpoints_from_descriptors(&blob).unwrap();
assert_eq!(endpoints.event_endpoint, 1);
assert_eq!(endpoints.event_max_packet_size, 64);
assert_eq!(endpoints.acl_in_endpoint, 2);
assert_eq!(endpoints.acl_in_max_packet_size, 512);
assert_eq!(endpoints.acl_out_endpoint, 2);
assert_eq!(endpoints.acl_out_max_packet_size, 512);
}
#[test]
fn parse_hci_endpoints_from_descriptors_missing_interrupt_in() {
let blob: Vec<u8> = vec![
// Bulk IN only, no interrupt IN
7, 5, 0x82, 0x02, 0x00, 0x02, 0x00,
7, 5, 0x02, 0x02, 0x00, 0x02, 0x00,
];
let result = parse_hci_endpoints_from_descriptors(&blob);
assert!(result.is_err());
assert!(result.unwrap_err().contains("interrupt IN"));
}
#[test]
fn parse_hci_endpoints_from_descriptors_empty_blob() {
let result = parse_hci_endpoints_from_descriptors(&[]);
assert!(result.is_err());
}
#[test]
fn parse_hci_endpoints_from_descriptors_ignores_other_endpoints() {
let blob: Vec<u8> = vec![
// Isochronous IN endpoint (should be ignored)
7, 5, 0x83, 0x01, 0x00, 0x01, 0x01,
// Control endpoint (should be ignored)
7, 5, 0x04, 0x00, 0x40, 0x00, 0x00,
// Interrupt IN endpoint (should be picked up)
7, 5, 0x81, 0x03, 0x40, 0x00, 0x01,
// Bulk IN endpoint (should be picked up)
7, 5, 0x82, 0x02, 0x00, 0x02, 0x00,
// Bulk OUT endpoint (should be picked up)
7, 5, 0x02, 0x02, 0x00, 0x02, 0x00,
];
let endpoints = parse_hci_endpoints_from_descriptors(&blob).unwrap();
assert_eq!(endpoints.event_endpoint, 1);
assert_eq!(endpoints.acl_in_endpoint, 2);
assert_eq!(endpoints.acl_out_endpoint, 2);
}
#[test]
fn hci_endpoints_default_is_zeroed() {
let ep = HciEndpoints::default();
assert_eq!(ep.event_endpoint, 0);
assert_eq!(ep.acl_in_endpoint, 0);
assert_eq!(ep.acl_out_endpoint, 0);
assert_eq!(ep.event_max_packet_size, 0);
assert_eq!(ep.acl_in_max_packet_size, 0);
assert_eq!(ep.acl_out_max_packet_size, 0);
}
#[test]
fn detail_line_includes_endpoint_fields() {
let adapter = UsbBluetoothAdapter {
name: "hci0".to_string(),
vendor_id: 0x8087,
device_id: 0x0032,
bus: "usb.0000:00:14.0".to_string(),
device_path: PathBuf::from("/scheme/usb/usb.0000:00:14.0/port1"),
endpoints: HciEndpoints {
event_endpoint: 1,
acl_in_endpoint: 2,
acl_out_endpoint: 2,
event_max_packet_size: 64,
acl_in_max_packet_size: 512,
acl_out_max_packet_size: 512,
},
};
let line = adapter.detail_line(0);
assert!(line.contains("event_ep=1"));
assert!(line.contains("acl_in_ep=2"));
assert!(line.contains("acl_out_ep=2"));
assert!(line.contains("vendor_id=8087"));
assert!(line.contains("device_id=0032"));
}
// -- Controller state and HCI init tests --------------------------------
#[test]
fn controller_state_default_is_closed() {
let info = ControllerInfo::default();
assert_eq!(info.state, ControllerState::Closed);
assert!(info.bd_address.is_none());
assert!(info.hci_version.is_none());
assert!(info.hci_revision.is_none());
assert!(info.manufacturer_name.is_none());
assert!(info.init_error.is_none());
}
struct TestTransport {
pending_events: Vec<hci::HciEvent>,
}
impl TestTransport {
fn new() -> Self {
Self {
pending_events: Vec::new(),
}
}
fn inject_event(&mut self, event: hci::HciEvent) {
self.pending_events.push(event);
}
}
impl usb_transport::UsbHciTransport for TestTransport {
fn send_command(&mut self, _command: &hci::HciCommand) -> std::io::Result<()> {
Ok(())
}
fn recv_event(&mut self) -> std::io::Result<Option<hci::HciEvent>> {
Ok(if self.pending_events.is_empty() {
None
} else {
Some(self.pending_events.remove(0))
})
}
fn send_acl(&mut self, _acl: &hci::HciAcl) -> std::io::Result<()> {
Ok(())
}
fn recv_acl(&mut self) -> std::io::Result<Option<hci::HciAcl>> {
Ok(None)
}
fn state(&self) -> usb_transport::TransportState {
usb_transport::TransportState::Active
}
fn close(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn inject_cc_event(transport: &mut TestTransport, opcode: u16, return_params: Vec<u8>) {
let mut params = vec![0x01];
params.push(opcode as u8);
params.push((opcode >> 8) as u8);
params.extend(return_params);
let event = hci::HciEvent {
event_code: hci::EVT_COMMAND_COMPLETE,
parameters: params,
};
transport.inject_event(event);
}
#[test]
fn hci_init_sequence_with_stub_succeeds() {
let mut transport = TestTransport::new();
// Reset CC: status=0x00
inject_cc_event(&mut transport, hci::OP_RESET, vec![0x00]);
// Read BD Addr CC: status=0x00 + 6-byte address
inject_cc_event(
&mut transport,
hci::OP_READ_BD_ADDR,
vec![0x00, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF],
);
// Read Local Version CC: status + hci_version + hci_revision(2) + lmp_version + manufacturer(2) + lmp_subversion(2)
inject_cc_event(
&mut transport,
hci::OP_READ_LOCAL_VERSION,
vec![0x00, 0x09, 0x01, 0x00, 0x09, 0x02, 0x00, 0x01, 0x00],
);
let info = hci_init_sequence(&mut transport).expect("init should succeed");
assert_eq!(info.state, ControllerState::Active);
assert_eq!(info.bd_address, Some([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]));
assert_eq!(info.hci_version, Some(0x09));
assert_eq!(info.hci_revision, Some(0x0001));
assert_eq!(info.manufacturer_name, Some(0x0002));
assert!(info.init_error.is_none());
}
#[test]
fn hci_init_sequence_fails_when_reset_gets_no_response() {
let mut transport = TestTransport::new();
// No events injected — recv_event returns None
let result = hci_init_sequence(&mut transport);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(
err.contains("no response"),
"expected 'no response' error, got: {err}"
);
}
#[test]
fn status_lines_include_controller_state() {
let status_file = temp_path("rbos-btusb-status-active");
let mut config = test_config(status_file);
config.controller_info = ControllerInfo {
state: ControllerState::Active,
bd_address: Some([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]),
hci_version: Some(9),
hci_revision: Some(1),
manufacturer_name: Some(2),
init_error: None,
};
let lines = config.render_status_lines(true);
let output = lines.join("\n");
assert!(
output.contains("controller_state=active"),
"missing controller_state=active, got: {output}"
);
assert!(
output.contains("bd_address="),
"missing bd_address, got: {output}"
);
assert!(
output.contains("hci_version=9"),
"missing hci_version, got: {output}"
);
assert!(
output.contains("manufacturer=2"),
"missing manufacturer, got: {output}"
);
}
#[test]
fn status_lines_include_init_error() {
let status_file = temp_path("rbos-btusb-status-error");
let mut config = test_config(status_file);
config.controller_info = ControllerInfo {
state: ControllerState::Error,
bd_address: None,
hci_version: None,
hci_revision: None,
manufacturer_name: None,
init_error: Some("HCI Reset send failed: transport is closed".to_string()),
};
let lines = config.render_status_lines(true);
let output = lines.join("\n");
assert!(
output.contains("controller_state=error"),
"missing controller_state=error, got: {output}"
);
assert!(
output.contains("init_error=HCI Reset send failed"),
"missing init_error, got: {output}"
);
}
}