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