Files
RedBear-OS/local/recipes/system/redbear-btctl/source/src/main.rs
T
2026-04-16 12:44:51 +01:00

748 lines
26 KiB
Rust

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();
}
}