system: update redbear-info, redbear-wifictl, add redbear-hid-core recipe

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-11 10:57:44 +03:00
parent 9ff1d84b38
commit 836715a9ad
7 changed files with 2034 additions and 5 deletions
Submodule local/recipes/drivers/redbear-hid-core added at 7b82f4d396
@@ -69,6 +69,15 @@ struct IdentityReport {
hostname: Option<String>,
}
#[derive(Clone)]
struct WifiJournalEventView {
serial: u64,
timestamp_ns: u64,
interface: String,
kind: String,
data: String,
}
struct NetworkReport {
state: ProbeState,
connected: bool,
@@ -88,6 +97,15 @@ struct NetworkReport {
wifi_connect_result: Option<String>,
wifi_disconnect_result: Option<String>,
wifi_scan_results: Vec<String>,
wifi_journal_event_count: usize,
wifi_journal_last_serial: Option<u64>,
wifi_journal_last_kind: Option<String>,
wifi_journal_last_data: Option<String>,
wifi_journal_last_interface: Option<String>,
wifi_journal_last_timestamp_ns: Option<u64>,
wifi_journal_recent_events: Vec<WifiJournalEventView>,
wifi_journal_error_kind: Option<String>,
wifi_journal_present: bool,
claim_limit: &'static str,
bluetooth_transport_state: ProbeState,
bluetooth_control_state: ProbeState,
@@ -812,6 +830,18 @@ fn collect_network(runtime: &Runtime) -> NetworkReport {
})
.unwrap_or_default();
let (
wifi_journal_event_count,
wifi_journal_last_serial,
wifi_journal_last_kind,
wifi_journal_last_data,
wifi_journal_last_interface,
wifi_journal_last_timestamp_ns,
wifi_journal_recent_events,
wifi_journal_error_kind,
wifi_journal_present,
) = read_wifi_journal(runtime, wifi_primary.as_deref());
let bluetooth_adapters = runtime
.read_dir_names("/scheme/btctl/adapters")
.or_else(|| {
@@ -967,6 +997,15 @@ fn collect_network(runtime: &Runtime) -> NetworkReport {
wifi_connect_result,
wifi_disconnect_result,
wifi_scan_results,
wifi_journal_event_count,
wifi_journal_last_serial,
wifi_journal_last_kind,
wifi_journal_last_data,
wifi_journal_last_interface,
wifi_journal_last_timestamp_ns,
wifi_journal_recent_events,
wifi_journal_error_kind,
wifi_journal_present,
claim_limit: "Connected means the local stack exposes a configured address; this does not prove external reachability.",
bluetooth_transport_state,
bluetooth_control_state,
@@ -984,6 +1023,239 @@ fn collect_network(runtime: &Runtime) -> NetworkReport {
}
}
fn read_wifi_journal(
runtime: &Runtime,
interface: Option<&str>,
) -> (
usize,
Option<u64>,
Option<String>,
Option<String>,
Option<String>,
Option<u64>,
Vec<WifiJournalEventView>,
Option<String>,
bool,
) {
let raw = match runtime.read_to_string("/scheme/wifictl/events.log") {
Some(value) => value,
None => {
return (
0, None, None, None, None, None, Vec::new(),
Some("journal-not-found".to_string()),
false,
);
}
};
let mut events: Vec<WifiJournalEventView> = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(ev) = parse_wifi_journal_line(line) {
if let Some(iface) = interface {
if ev.interface != iface {
continue;
}
}
events.push(WifiJournalEventView {
serial: ev.serial,
timestamp_ns: ev.timestamp_ns,
interface: ev.interface,
kind: ev.kind,
data: ev.data,
});
}
}
let count = events.len();
let last = events.last().cloned();
let recent_start = count.saturating_sub(10);
let recent = events[recent_start..].to_vec();
let error_kind = if count == 0 && raw.lines().any(|line| !line.trim().is_empty()) {
Some("journal-unparseable".to_string())
} else {
None
};
let (last_serial, last_kind, last_data, last_interface, last_timestamp_ns) = match last {
Some(ev) => (
Some(ev.serial),
Some(ev.kind),
Some(ev.data),
Some(ev.interface),
Some(ev.timestamp_ns),
),
None => (None, None, None, None, None),
};
(
count,
last_serial,
last_kind,
last_data,
last_interface,
last_timestamp_ns,
recent,
error_kind,
true,
)
}
struct ParsedWifiJournalLine {
serial: u64,
timestamp_ns: u64,
interface: String,
kind: String,
data: String,
}
fn parse_wifi_journal_line(line: &str) -> Option<ParsedWifiJournalLine> {
if !line.starts_with('{') || !line.ends_with('}') {
return None;
}
let body = &line[1..line.len() - 1];
let mut serial: Option<u64> = None;
let mut timestamp_ns: Option<u64> = None;
let mut interface: Option<String> = None;
let mut kind: Option<String> = None;
let mut data: Option<String> = None;
for field in split_jsonl_top_level_fields(body) {
let (key, raw_value) = field?;
match key.as_str() {
"serial" => serial = raw_value.parse().ok(),
"timestamp_ns" => timestamp_ns = raw_value.parse().ok(),
"interface" => interface = Some(unescape_jsonl_string(&raw_value)),
"kind" => kind = Some(unescape_jsonl_string(&raw_value)),
"data" => data = Some(unescape_jsonl_string(&raw_value)),
_ => {}
}
}
Some(ParsedWifiJournalLine {
serial: serial?,
timestamp_ns: timestamp_ns?,
interface: interface?,
kind: kind?,
data: data?,
})
}
fn split_jsonl_top_level_fields(body: &str) -> Vec<Option<(String, String)>> {
let mut fields = Vec::new();
let bytes = body.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if bytes[i] != b'"' {
return fields;
}
let key_start = i + 1;
let mut key_end = key_start;
while key_end < bytes.len() && bytes[key_end] != b'"' {
if bytes[key_end] == b'\\' && key_end + 1 < bytes.len() {
key_end += 2;
} else {
key_end += 1;
}
}
if key_end >= bytes.len() {
return fields;
}
let key = String::from_utf8_lossy(&bytes[key_start..key_end]).into_owned();
i = key_end + 1;
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b':' {
return fields;
}
i += 1;
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
if i >= bytes.len() {
return fields;
}
let value_start = i;
if bytes[i] == b'"' {
let mut value_end = i + 1;
while value_end < bytes.len() && bytes[value_end] != b'"' {
if bytes[value_end] == b'\\' && value_end + 1 < bytes.len() {
value_end += 2;
} else {
value_end += 1;
}
}
if value_end >= bytes.len() {
return fields;
}
i = value_end + 1;
let raw = String::from_utf8_lossy(&bytes[value_start..i]).into_owned();
fields.push(Some((key, raw)));
} else {
let mut value_end = i;
while value_end < bytes.len() && bytes[value_end] != b',' {
value_end += 1;
}
let raw = String::from_utf8_lossy(&bytes[value_start..value_end])
.trim()
.to_string();
i = value_end;
fields.push(Some((key, raw)));
}
if i < bytes.len() && bytes[i] == b',' {
i += 1;
}
}
fields
}
fn unescape_jsonl_string(raw: &str) -> String {
let trimmed = raw.trim();
if !trimmed.starts_with('"') || !trimmed.ends_with('"') {
return trimmed.to_string();
}
let inner = &trimmed[1..trimmed.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('/') => out.push('/'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('b') => out.push('\u{08}'),
Some('f') => out.push('\u{0C}'),
Some('u') => {
let hex: String = chars.by_ref().take(4).collect();
if let Ok(cp) = u32::from_str_radix(&hex, 16) {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
}
}
}
Some(other) => out.push(other),
None => {}
}
} else {
out.push(c);
}
}
out
}
fn active_profile_interface(runtime: &Runtime, profile: &str) -> Option<String> {
let content = runtime.read_to_string(&format!("/etc/netctl/{profile}"))?;
content.lines().find_map(|line| {
@@ -1704,6 +1976,43 @@ fn print_table(report: &Report<'_>, verbose: bool) {
report.network.wifi_scan_results.join(", ")
}
);
if report.network.wifi_journal_present {
println!(
" Wi-Fi event journal: present, events={}, last_serial={}",
report.network.wifi_journal_event_count,
report
.network
.wifi_journal_last_serial
.map(|s| s.to_string())
.unwrap_or_else(|| "none".to_string())
);
if let Some(kind) = &report.network.wifi_journal_last_kind {
let iface = report
.network
.wifi_journal_last_interface
.as_deref()
.unwrap_or("?");
let data = report
.network
.wifi_journal_last_data
.as_deref()
.unwrap_or("");
println!(" last event: kind={kind} iface={iface} data={data}");
}
for ev in &report.network.wifi_journal_recent_events {
println!(
" #{} iface={} kind={} data={}",
ev.serial, ev.interface, ev.kind, ev.data
);
}
} else {
let err = report
.network
.wifi_journal_error_kind
.as_deref()
.unwrap_or("journal-unavailable");
println!(" Wi-Fi event journal: absent ({err})");
}
println!(
" Bluetooth transport: {}{}{}",
state_marker(report.network.bluetooth_transport_state),
@@ -2109,6 +2418,87 @@ fn print_json(report: &Report<'_>) {
true,
4,
);
push_json_bool_field(
&mut out,
"wifi_journal_present",
report.network.wifi_journal_present,
true,
4,
);
push_json_number_field(
&mut out,
"wifi_journal_event_count",
report.network.wifi_journal_event_count,
true,
4,
);
push_json_number_field(
&mut out,
"wifi_journal_last_serial",
report
.network
.wifi_journal_last_serial
.map(|v| v as usize)
.unwrap_or(0),
true,
4,
);
push_json_field(
&mut out,
"wifi_journal_last_kind",
report.network.wifi_journal_last_kind.as_deref(),
true,
4,
);
push_json_field(
&mut out,
"wifi_journal_last_data",
report.network.wifi_journal_last_data.as_deref(),
true,
4,
);
push_json_field(
&mut out,
"wifi_journal_last_interface",
report.network.wifi_journal_last_interface.as_deref(),
true,
4,
);
push_json_number_field(
&mut out,
"wifi_journal_last_timestamp_ns",
report
.network
.wifi_journal_last_timestamp_ns
.map(|v| v as usize)
.unwrap_or(0),
true,
4,
);
push_json_field(
&mut out,
"wifi_journal_error_kind",
report.network.wifi_journal_error_kind.as_deref(),
true,
4,
);
let mut events_out = String::new();
events_out.push('[');
for (i, ev) in report.network.wifi_journal_recent_events.iter().enumerate() {
if i > 0 {
events_out.push(',');
}
events_out.push_str(&format!(
r#"{{"serial":{},"timestamp_ns":{},"interface":"{}","kind":"{}","data":"{}"}}"#,
ev.serial,
ev.timestamp_ns,
ev.interface.replace('\\', "\\\\").replace('"', "\\\""),
ev.kind.replace('\\', "\\\\").replace('"', "\\\""),
ev.data.replace('\\', "\\\\").replace('"', "\\\""),
));
}
events_out.push(']');
push_json_raw(&mut out, "wifi_journal_recent_events", &events_out, true, 4);
push_json_string_field(&mut out, "claim_limit", report.network.claim_limit, true, 4);
push_json_string_field(
&mut out,
@@ -3537,6 +3927,17 @@ fn push_json_string_array_field(
output.push('\n');
}
fn push_json_raw(output: &mut String, key: &str, raw: &str, trailing_comma: bool, indent: usize) {
push_json_indent(output, indent);
push_json_string(output, key);
output.push_str(": ");
output.push_str(raw);
if trailing_comma {
output.push(',');
}
output.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4701,4 +5102,89 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn wifi_journal_absent_marks_present_false_with_error_kind() {
let root = temp_root();
let runtime = Runtime::from_root(root.clone());
let (count, last_serial, last_kind, _, _, _, recent, error_kind, present) =
read_wifi_journal(&runtime, Some("wlan0"));
assert!(!present);
assert_eq!(count, 0);
assert!(last_serial.is_none());
assert!(last_kind.is_none());
assert!(recent.is_empty());
assert_eq!(error_kind.as_deref(), Some("journal-not-found"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn wifi_journal_parses_persisted_events_and_filters_by_interface() {
let root = temp_root();
let journal = r#"{"serial":1,"timestamp_ns":1000,"interface":"wlan0","kind":"connect-attempt","data":"ssid=foo"}
{"serial":2,"timestamp_ns":2000,"interface":"wlan0","kind":"connect-succeeded","data":"ssid=foo"}
{"serial":3,"timestamp_ns":3000,"interface":"wlan1","kind":"connect-attempt","data":"ssid=bar"}
this is not json
{"serial":4,"timestamp_ns":4000,"interface":"wlan0","kind":"disconnect","data":"ok"}
"#;
write_file(
&root,
"/scheme/wifictl/events.log",
journal,
);
let runtime = Runtime::from_root(root.clone());
let (count_w0, last_serial_w0, last_kind_w0, last_data_w0, last_iface_w0, last_ts_w0, recent_w0, err_w0, present_w0) =
read_wifi_journal(&runtime, Some("wlan0"));
assert!(present_w0);
assert_eq!(count_w0, 3);
assert_eq!(last_serial_w0, Some(4));
assert_eq!(last_kind_w0.as_deref(), Some("disconnect"));
assert_eq!(last_data_w0.as_deref(), Some("ok"));
assert_eq!(last_iface_w0.as_deref(), Some("wlan0"));
assert_eq!(last_ts_w0, Some(4000));
assert_eq!(recent_w0.len(), 3);
assert!(err_w0.is_none());
let (count_all, _, _, _, _, _, _, _, _) = read_wifi_journal(&runtime, None);
assert_eq!(count_all, 4);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn wifi_journal_recent_is_capped_to_ten_events() {
let root = temp_root();
let mut body = String::new();
for i in 1..=20 {
body.push_str(&format!(
r#"{{"serial":{i},"timestamp_ns":{ts},"interface":"wlan0","kind":"state-transition","data":"x"}}"#,
i = i,
ts = i * 1000
));
body.push('\n');
}
write_file(&root, "/scheme/wifictl/events.log", &body);
let runtime = Runtime::from_root(root.clone());
let (count, last_serial, _, _, _, _, recent, _, _) =
read_wifi_journal(&runtime, Some("wlan0"));
assert_eq!(count, 20);
assert_eq!(last_serial, Some(20));
assert_eq!(recent.len(), 10);
assert_eq!(recent.first().unwrap().serial, 11);
assert_eq!(recent.last().unwrap().serial, 20);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn parse_wifi_journal_line_handles_special_characters() {
let line = r#"{"serial":7,"timestamp_ns":42,"interface":"wlan0","kind":"connect-failed","data":"ssid=\"my secret\" newline=\n tab=\t"}"#;
let ev = parse_wifi_journal_line(line).expect("parse line");
assert_eq!(ev.serial, 7);
assert_eq!(ev.interface, "wlan0");
assert_eq!(ev.kind, "connect-failed");
assert!(ev.data.contains("my secret"));
assert!(ev.data.contains('\n'));
assert!(ev.data.contains('\t'));
}
}
@@ -0,0 +1,261 @@
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WifiError {
NoDevice,
NoFirmware { vendor: String, device: u16 },
FirmwareLoadFailed { path: String, reason: String },
TransportTimeout { command: String, waited_ms: u64 },
TransportInitFailed { stage: String, reason: String },
AuthRejected { ssid: String, reason: String },
AssociationTimeout { ssid: String, waited_ms: u64 },
DhcpFailed { interface: String, reason: String },
SignalLost { rssi_dbm: i32, threshold_dbm: i32 },
ProfileNotFound { name: String },
InternalError(String),
}
impl WifiError {
pub fn reason_code(&self) -> &'static str {
match self {
WifiError::NoDevice => "E_NO_DEVICE",
WifiError::NoFirmware { .. } => "E_NO_FIRMWARE",
WifiError::FirmwareLoadFailed { .. } => "E_FIRMWARE_LOAD",
WifiError::TransportTimeout { .. } => "E_TRANSPORT_TIMEOUT",
WifiError::TransportInitFailed { .. } => "E_TRANSPORT_INIT",
WifiError::AuthRejected { .. } => "E_AUTH_REJECTED",
WifiError::AssociationTimeout { .. } => "E_ASSOC_TIMEOUT",
WifiError::DhcpFailed { .. } => "E_DHCP_FAILED",
WifiError::SignalLost { .. } => "E_SIGNAL_LOST",
WifiError::ProfileNotFound { .. } => "E_PROFILE_NOT_FOUND",
WifiError::InternalError(_) => "E_INTERNAL",
}
}
pub fn human_message(&self) -> String {
match self {
WifiError::NoDevice => "no Wi-Fi device detected on PCI bus".to_string(),
WifiError::NoFirmware { vendor, device } => {
format!("no firmware blob for vendor={vendor} device=0x{device:04x}")
}
WifiError::FirmwareLoadFailed { path, reason } => {
format!("firmware load failed for {path}: {reason}")
}
WifiError::TransportTimeout { command, waited_ms } => {
format!("transport command {command} timed out after {waited_ms}ms")
}
WifiError::TransportInitFailed { stage, reason } => {
format!("transport init failed at stage {stage}: {reason}")
}
WifiError::AuthRejected { ssid, reason } => {
format!("authentication rejected for ssid={ssid}: {reason}")
}
WifiError::AssociationTimeout { ssid, waited_ms } => {
format!("association with ssid={ssid} timed out after {waited_ms}ms")
}
WifiError::DhcpFailed { interface, reason } => {
format!("DHCP failed on {interface}: {reason}")
}
WifiError::SignalLost { rssi_dbm, threshold_dbm } => {
format!("signal lost: rssi={rssi_dbm}dBm below threshold {threshold_dbm}dBm")
}
WifiError::ProfileNotFound { name } => format!("Wi-Fi profile not found: {name}"),
WifiError::InternalError(detail) => format!("internal Wi-Fi error: {detail}"),
}
}
pub fn is_recoverable(&self) -> bool {
matches!(
self,
WifiError::TransportTimeout { .. }
| WifiError::AssociationTimeout { .. }
| WifiError::SignalLost { .. }
| WifiError::DhcpFailed { .. }
)
}
pub fn is_auth_failure(&self) -> bool {
matches!(self, WifiError::AuthRejected { .. })
}
pub fn is_fatal(&self) -> bool {
matches!(
self,
WifiError::NoDevice
| WifiError::NoFirmware { .. }
| WifiError::FirmwareLoadFailed { .. }
| WifiError::ProfileNotFound { .. }
)
}
}
impl fmt::Display for WifiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}", self.reason_code(), self.human_message())
}
}
impl std::error::Error for WifiError {}
impl From<std::io::Error> for WifiError {
fn from(err: std::io::Error) -> Self {
WifiError::InternalError(format!("io error: {err}"))
}
}
impl From<String> for WifiError {
fn from(err: String) -> Self {
WifiError::InternalError(err)
}
}
impl From<&str> for WifiError {
fn from(err: &str) -> Self {
WifiError::InternalError(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reason_codes_are_stable_wire_format() {
assert_eq!(WifiError::NoDevice.reason_code(), "E_NO_DEVICE");
assert_eq!(
WifiError::NoFirmware {
vendor: "intel".to_string(),
device: 0x2725
}
.reason_code(),
"E_NO_FIRMWARE"
);
assert_eq!(
WifiError::TransportTimeout {
command: "ALIVE".to_string(),
waited_ms: 5000
}
.reason_code(),
"E_TRANSPORT_TIMEOUT"
);
assert_eq!(
WifiError::ProfileNotFound {
name: "home".to_string()
}
.reason_code(),
"E_PROFILE_NOT_FOUND"
);
}
#[test]
fn display_includes_reason_code_and_human_message() {
let err = WifiError::SignalLost {
rssi_dbm: -90,
threshold_dbm: -75,
};
let formatted = format!("{err}");
assert!(formatted.starts_with("[E_SIGNAL_LOST]"));
assert!(formatted.contains("-90dBm"));
assert!(formatted.contains("-75dBm"));
}
#[test]
fn human_message_is_descriptive() {
let err = WifiError::TransportInitFailed {
stage: "firmware-load".to_string(),
reason: "bad ucode magic".to_string(),
};
let msg = err.human_message();
assert!(msg.contains("firmware-load"));
assert!(msg.contains("bad ucode magic"));
}
#[test]
fn recoverable_classification_correct() {
assert!(WifiError::TransportTimeout {
command: "X".to_string(),
waited_ms: 1
}
.is_recoverable());
assert!(WifiError::AssociationTimeout {
ssid: "x".to_string(),
waited_ms: 1
}
.is_recoverable());
assert!(WifiError::SignalLost {
rssi_dbm: -90,
threshold_dbm: -80
}
.is_recoverable());
assert!(!WifiError::NoDevice.is_recoverable());
assert!(!WifiError::AuthRejected {
ssid: "x".to_string(),
reason: "x".to_string()
}
.is_recoverable());
}
#[test]
fn fatal_classification_correct() {
assert!(WifiError::NoDevice.is_fatal());
assert!(WifiError::NoFirmware {
vendor: "intel".to_string(),
device: 0x2725
}
.is_fatal());
assert!(WifiError::FirmwareLoadFailed {
path: "x".to_string(),
reason: "x".to_string()
}
.is_fatal());
assert!(WifiError::ProfileNotFound {
name: "x".to_string()
}
.is_fatal());
assert!(!WifiError::TransportTimeout {
command: "X".to_string(),
waited_ms: 1
}
.is_fatal());
assert!(!WifiError::AuthRejected {
ssid: "x".to_string(),
reason: "x".to_string()
}
.is_fatal());
}
#[test]
fn auth_failure_classification_correct() {
assert!(WifiError::AuthRejected {
ssid: "x".to_string(),
reason: "x".to_string()
}
.is_auth_failure());
assert!(!WifiError::TransportTimeout {
command: "X".to_string(),
waited_ms: 1
}
.is_auth_failure());
}
#[test]
fn from_string_impls() {
let from_io: WifiError = std::io::Error::new(std::io::ErrorKind::NotFound, "x").into();
assert_eq!(from_io.reason_code(), "E_INTERNAL");
assert!(from_io.human_message().contains("io error"));
let from_str: WifiError = "test".into();
assert_eq!(from_str.reason_code(), "E_INTERNAL");
assert!(from_str.human_message().contains("test"));
let from_string: WifiError = String::from("detail").into();
assert_eq!(from_string.reason_code(), "E_INTERNAL");
}
#[test]
fn impls_std_error_trait() {
let err: Box<dyn std::error::Error> = Box::new(WifiError::NoDevice);
let _displayed = format!("{err}");
let _downcast: Box<WifiError> = err.downcast().unwrap();
}
}
@@ -0,0 +1,411 @@
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const ENV_JOURNAL_PATH: &str = "REDBEAR_WIFICTL_JOURNAL";
const DEFAULT_SCHEME_JOURNAL: &str = "/scheme/wifictl/events.log";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EventKind {
StateTransition,
ScanCompleted,
ConnectAttempt,
ConnectSucceeded,
ConnectFailed,
Disconnect,
TransportInit,
FirmwareLoad,
RetryScheduled,
RetryExhausted,
ProfileChanged,
Unknown,
}
impl EventKind {
pub fn as_str(&self) -> &'static str {
match self {
EventKind::StateTransition => "state-transition",
EventKind::ScanCompleted => "scan-completed",
EventKind::ConnectAttempt => "connect-attempt",
EventKind::ConnectSucceeded => "connect-succeeded",
EventKind::ConnectFailed => "connect-failed",
EventKind::Disconnect => "disconnect",
EventKind::TransportInit => "transport-init",
EventKind::FirmwareLoad => "firmware-load",
EventKind::RetryScheduled => "retry-scheduled",
EventKind::RetryExhausted => "retry-exhausted",
EventKind::ProfileChanged => "profile-changed",
EventKind::Unknown => "unknown",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"state-transition" => EventKind::StateTransition,
"scan-completed" => EventKind::ScanCompleted,
"connect-attempt" => EventKind::ConnectAttempt,
"connect-succeeded" => EventKind::ConnectSucceeded,
"connect-failed" => EventKind::ConnectFailed,
"disconnect" => EventKind::Disconnect,
"transport-init" => EventKind::TransportInit,
"firmware-load" => EventKind::FirmwareLoad,
"retry-scheduled" => EventKind::RetryScheduled,
"retry-exhausted" => EventKind::RetryExhausted,
"profile-changed" => EventKind::ProfileChanged,
_ => EventKind::Unknown,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JournalEvent {
pub serial: u64,
pub timestamp_ns: u64,
pub interface: String,
pub kind: EventKind,
pub data: String,
}
impl JournalEvent {
pub fn encode_jsonl(&self) -> String {
format!(
r#"{{"serial":{},"timestamp_ns":{},"interface":"{}","kind":"{}","data":"{}"}}"#,
self.serial,
self.timestamp_ns,
json_escape(&self.interface),
self.kind.as_str(),
json_escape(&self.data),
)
}
pub fn decode_jsonl(line: &str) -> Option<Self> {
let line = line.trim();
if !line.starts_with('{') || !line.ends_with('}') {
return None;
}
let serial = extract_u64_field(line, "serial")?;
let timestamp_ns = extract_u64_field(line, "timestamp_ns")?;
let interface = extract_string_field(line, "interface")?;
let kind_str = extract_string_field(line, "kind")?;
let data = extract_string_field(line, "data")?;
Some(JournalEvent {
serial,
timestamp_ns,
interface,
kind: EventKind::from_str(&kind_str),
data,
})
}
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn extract_u64_field(json: &str, field: &str) -> Option<u64> {
let needle = format!("\"{}\":", field);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn extract_string_field(json: &str, field: &str) -> Option<String> {
let needle = format!("\"{}\":", field);
let start = json.find(&needle)? + needle.len();
let rest = json[start..].trim_start();
if !rest.starts_with('"') {
return None;
}
let rest = &rest[1..];
let mut out = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'u' => {
let hex: String = chars.by_ref().take(4).collect();
if let Ok(cp) = u32::from_str_radix(&hex, 16) {
if let Some(ch) = char::from_u32(cp) {
out.push(ch);
}
}
}
_ => return None,
},
other => out.push(other),
}
}
None
}
pub struct Journal {
path: PathBuf,
next_serial: AtomicU64,
}
impl Journal {
pub fn open_default() -> Result<Self, String> {
let path = default_journal_path();
Self::open(&path)
}
pub fn open(path: &Path) -> Result<Self, String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("journal: create_dir_all({}) failed: {}", parent.display(), e))?;
}
}
let next_serial = match File::open(path) {
Ok(f) => {
let reader = BufReader::new(f);
let mut max_serial = 0u64;
for line in reader.lines().map_while(Result::ok) {
if let Some(ev) = JournalEvent::decode_jsonl(&line) {
if ev.serial > max_serial {
max_serial = ev.serial;
}
}
}
max_serial + 1
}
Err(_) => 1,
};
Ok(Self {
path: path.to_path_buf(),
next_serial: AtomicU64::new(next_serial),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, kind: EventKind, interface: &str, data: &str) -> Result<JournalEvent, String> {
let serial = self.next_serial.fetch_add(1, Ordering::SeqCst);
let timestamp_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let event = JournalEvent {
serial,
timestamp_ns,
interface: interface.to_string(),
kind,
data: data.to_string(),
};
let line = event.encode_jsonl();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|e| format!("journal: open({}) failed: {}", self.path.display(), e))?;
file.write_all(line.as_bytes())
.and_then(|_| file.write_all(b"\n"))
.map_err(|e| format!("journal: write to {} failed: {}", self.path.display(), e))?;
Ok(event)
}
pub fn read_all(&self) -> Result<Vec<JournalEvent>, String> {
let file = File::open(&self.path)
.map_err(|e| format!("journal: read open({}) failed: {}", self.path.display(), e))?;
let reader = BufReader::new(file);
let mut out = Vec::new();
for line in reader.lines().map_while(Result::ok) {
if let Some(ev) = JournalEvent::decode_jsonl(&line) {
out.push(ev);
}
}
Ok(out)
}
pub fn read_for_interface(&self, interface: &str) -> Result<Vec<JournalEvent>, String> {
Ok(self
.read_all()?
.into_iter()
.filter(|ev| ev.interface == interface)
.collect())
}
pub fn last_for_interface(&self, interface: &str) -> Result<Option<JournalEvent>, String> {
Ok(self.read_for_interface(interface)?.into_iter().last())
}
}
fn default_journal_path() -> PathBuf {
if let Ok(custom) = std::env::var(ENV_JOURNAL_PATH) {
if !custom.is_empty() {
return PathBuf::from(custom);
}
}
PathBuf::from(DEFAULT_SCHEME_JOURNAL)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_journal_path() -> PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("rbos-journal-{stamp}-{n}"));
std::fs::create_dir_all(&dir).unwrap();
dir.join("events.log")
}
#[test]
fn round_trip_encodes_and_decodes_event() {
let ev = JournalEvent {
serial: 42,
timestamp_ns: 1_700_000_000_000_000_000,
interface: "wlan0".to_string(),
kind: EventKind::ConnectSucceeded,
data: "ssid=demo".to_string(),
};
let line = ev.encode_jsonl();
let decoded = JournalEvent::decode_jsonl(&line).expect("decode");
assert_eq!(decoded, ev);
}
#[test]
fn serial_is_monotonic_across_appends() {
let path = temp_journal_path();
let j = Journal::open(&path).unwrap();
let a = j.append(EventKind::ConnectAttempt, "wlan0", "ssid=a").unwrap();
let b = j.append(EventKind::ConnectSucceeded, "wlan0", "ssid=a").unwrap();
let c = j.append(EventKind::Disconnect, "wlan0", "reason=user").unwrap();
assert_eq!(a.serial, 1);
assert_eq!(b.serial, 2);
assert_eq!(c.serial, 3);
assert!(a.serial < b.serial && b.serial < c.serial);
}
#[test]
fn append_persists_to_disk_and_read_all_returns_events() {
let path = temp_journal_path();
let j = Journal::open(&path).unwrap();
j.append(EventKind::StateTransition, "wlan0", "down->scanning")
.unwrap();
j.append(EventKind::ScanCompleted, "wlan0", "aps=3").unwrap();
let events = j.read_all().unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, EventKind::StateTransition);
assert_eq!(events[1].kind, EventKind::ScanCompleted);
}
#[test]
fn read_for_interface_filters_correctly() {
let path = temp_journal_path();
let j = Journal::open(&path).unwrap();
j.append(EventKind::ConnectAttempt, "wlan0", "x").unwrap();
j.append(EventKind::ConnectAttempt, "wlan1", "y").unwrap();
j.append(EventKind::ConnectAttempt, "wlan0", "z").unwrap();
let w0 = j.read_for_interface("wlan0").unwrap();
let w1 = j.read_for_interface("wlan1").unwrap();
assert_eq!(w0.len(), 2);
assert_eq!(w1.len(), 1);
assert!(w0.iter().all(|e| e.interface == "wlan0"));
}
#[test]
fn reopen_resumes_serial_above_highest_existing() {
let path = temp_journal_path();
{
let j = Journal::open(&path).unwrap();
j.append(EventKind::ConnectAttempt, "wlan0", "1").unwrap();
j.append(EventKind::ConnectAttempt, "wlan0", "2").unwrap();
j.append(EventKind::ConnectAttempt, "wlan0", "3").unwrap();
}
let j2 = Journal::open(&path).unwrap();
let next = j2
.append(EventKind::ConnectAttempt, "wlan0", "4")
.unwrap();
assert!(next.serial >= 4, "serial must resume above existing, got {}", next.serial);
}
#[test]
fn malformed_lines_are_skipped_during_decode() {
let path = temp_journal_path();
std::fs::write(
&path,
"this is not json\n{\"serial\":1,\"timestamp_ns\":2,\"interface\":\"wlan0\",\"kind\":\"disconnect\",\"data\":\"x\"}\n\n \n{\"broken\":\n",
)
.unwrap();
let j = Journal::open(&path).unwrap();
let events = j.read_all().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].serial, 1);
}
#[test]
fn special_characters_in_data_are_escaped() {
let ev = JournalEvent {
serial: 1,
timestamp_ns: 0,
interface: "wlan0".to_string(),
kind: EventKind::ConnectFailed,
data: "key=\"my secret\" with newline\nand\ttab".to_string(),
};
let line = ev.encode_jsonl();
let decoded = JournalEvent::decode_jsonl(&line).expect("decode");
assert_eq!(decoded.data, ev.data);
}
#[test]
fn event_kind_str_round_trip() {
for k in [
EventKind::StateTransition,
EventKind::ScanCompleted,
EventKind::ConnectAttempt,
EventKind::ConnectSucceeded,
EventKind::ConnectFailed,
EventKind::Disconnect,
EventKind::TransportInit,
EventKind::FirmwareLoad,
EventKind::RetryScheduled,
EventKind::RetryExhausted,
EventKind::ProfileChanged,
EventKind::Unknown,
] {
assert_eq!(EventKind::from_str(k.as_str()), k);
}
}
}
@@ -1,6 +1,9 @@
mod backend;
#[cfg(target_os = "redox")]
mod dbus_nm;
mod error;
mod journal;
mod reconnect;
mod scheme;
use std::env;
@@ -345,8 +348,18 @@ fn main() {
let ssid = args.next().unwrap_or_default();
let security = args.next().unwrap_or_else(|| "open".to_string());
let key = args.next().unwrap_or_default();
if let Ok(j) = journal::Journal::open_default() {
let _ = j.append(
journal::EventKind::ConnectAttempt,
&iface,
&format!("ssid={} security={}", ssid, security),
);
}
let mut backend = build_backend();
if let Err(err) = backend.prepare(&iface) {
if let Ok(j) = journal::Journal::open_default() {
let _ = j.append(journal::EventKind::FirmwareLoad, &iface, "failed");
}
eprintln!("redbear-wifictl: prepare failed for {}: {}", iface, err);
process::exit(1);
}
@@ -372,6 +385,13 @@ fn main() {
};
match backend.connect(&iface, &state) {
Ok(status) => {
if let Ok(j) = journal::Journal::open_default() {
let _ = j.append(
journal::EventKind::ConnectSucceeded,
&iface,
&format!("ssid={}", state.ssid),
);
}
println!("interface={}", iface);
println!("status={}", status.as_str());
println!("firmware_status={}", backend.firmware_status(&iface));
@@ -380,6 +400,13 @@ fn main() {
return;
}
Err(err) => {
if let Ok(j) = journal::Journal::open_default() {
let _ = j.append(
journal::EventKind::ConnectFailed,
&iface,
&format!("ssid={}", state.ssid),
);
}
eprintln!("redbear-wifictl: connect failed for {}: {}", iface, err);
process::exit(1);
}
@@ -388,6 +415,9 @@ fn main() {
Some("--disconnect") => {
let iface = args.next().unwrap_or_else(|| "wlan0".to_string());
let mut backend = build_backend();
if let Ok(j) = journal::Journal::open_default() {
let _ = j.append(journal::EventKind::Disconnect, &iface, "started");
}
if let Err(err) = backend.prepare(&iface) {
eprintln!("redbear-wifictl: prepare failed for {}: {}", iface, err);
process::exit(1);
@@ -421,6 +451,35 @@ fn main() {
}
}
}
Some("--journal-tail") => {
let iface = args.next();
let limit: usize = args
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let path = std::env::var("REDBEAR_WIFICTL_JOURNAL")
.unwrap_or_else(|_| "/scheme/wifictl/events.log".to_string());
match journal::Journal::open(std::path::Path::new(&path)) {
Ok(j) => {
let events = match &iface {
Some(i) => j.read_for_interface(i).unwrap_or_default(),
None => j.read_all().unwrap_or_default(),
};
let start = events.len().saturating_sub(limit);
for ev in &events[start..] {
println!("{}", ev.encode_jsonl());
}
return;
}
Err(err) => {
eprintln!(
"redbear-wifictl: journal open failed for {}: {}",
path, err
);
process::exit(1);
}
}
}
_ => {}
}
@@ -444,7 +503,22 @@ fn main() {
process::exit(1);
}
};
let mut scheme = WifiCtlScheme::new(build_backend());
let journal = match journal::Journal::open_default() {
Ok(j) => {
info!(
"redbear-wifictl: event journal enabled at {}",
j.path().display()
);
Some(j)
}
Err(err) => {
log::warn!(
"redbear-wifictl: journal open failed ({err}); continuing without journal"
);
None
}
};
let mut scheme = WifiCtlScheme::with_journal(build_backend(), journal);
let mut state = redox_scheme::scheme::SchemeState::new();
notify_scheme_ready(notify_fd, &socket, &mut scheme);
@@ -0,0 +1,532 @@
use std::time::{Duration, Instant};
use crate::backend::{Backend, WifiStatus};
use crate::error::WifiError;
use crate::journal::{EventKind, Journal};
const DEFAULT_BASE_MS: u64 = 1000;
const DEFAULT_CAP_MS: u64 = 60_000;
const DEFAULT_MAX_ATTEMPTS: u32 = 5;
fn base_ms() -> u64 {
std::env::var("REDBEAR_WIFICTL_BACKOFF_BASE_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_BASE_MS)
}
fn cap_ms() -> u64 {
std::env::var("REDBEAR_WIFICTL_BACKOFF_CAP_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CAP_MS)
}
fn max_attempts() -> u32 {
std::env::var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_ATTEMPTS)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReconnectState {
Idle,
Waiting,
Attempting,
Backoff,
Exhausted,
Disabled,
}
impl ReconnectState {
pub fn as_str(&self) -> &'static str {
match self {
ReconnectState::Idle => "idle",
ReconnectState::Waiting => "waiting",
ReconnectState::Attempting => "attempting",
ReconnectState::Backoff => "backoff",
ReconnectState::Exhausted => "exhausted",
ReconnectState::Disabled => "disabled",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReconnectEvent {
AttemptScheduled { attempt: u32, delay: Duration },
AttemptFired { attempt: u32 },
AttemptSucceeded { attempt: u32 },
AttemptFailed { attempt: u32, reason: String },
Exhausted { total_attempts: u32 },
Disabled,
}
pub fn compute_slot(attempt: u32) -> Duration {
if attempt == 0 {
return Duration::from_millis(base_ms());
}
let shift = attempt.min(31);
let doubled = base_ms().saturating_mul(1u64 << shift);
let capped = doubled.min(cap_ms());
Duration::from_millis(capped)
}
#[derive(Debug, Clone)]
pub struct ReconnectController {
pub auto_reconnect: bool,
pub state: ReconnectState,
pub attempts: u32,
pub next_attempt_at: Option<Instant>,
pub last_error: String,
pub last_success_at: Option<Instant>,
}
impl ReconnectController {
pub fn new(auto_reconnect: bool) -> Self {
Self {
auto_reconnect,
state: if auto_reconnect {
ReconnectState::Idle
} else {
ReconnectState::Disabled
},
attempts: 0,
next_attempt_at: None,
last_error: String::new(),
last_success_at: None,
}
}
pub fn set_auto_reconnect(&mut self, enabled: bool, now: Instant) {
self.auto_reconnect = enabled;
if !enabled {
self.state = ReconnectState::Disabled;
self.next_attempt_at = None;
} else if matches!(
self.state,
ReconnectState::Exhausted | ReconnectState::Disabled | ReconnectState::Idle
) {
self.state = ReconnectState::Idle;
self.attempts = 0;
let _ = now;
}
}
pub fn record_success(&mut self, now: Instant) {
self.attempts = 0;
self.next_attempt_at = None;
self.last_error.clear();
self.last_success_at = Some(now);
self.state = ReconnectState::Idle;
}
pub fn record_disconnect(&mut self, now: Instant) -> Option<ReconnectEvent> {
if !self.auto_reconnect {
self.state = ReconnectState::Disabled;
self.next_attempt_at = None;
return None;
}
if matches!(
self.state,
ReconnectState::Idle
| ReconnectState::Exhausted
| ReconnectState::Disabled
) {
self.attempts = 0;
}
self.schedule_next_attempt(now)
}
pub fn record_failure(
&mut self,
now: Instant,
error: WifiError,
) -> Option<ReconnectEvent> {
self.last_error = error.human_message();
if !self.auto_reconnect {
self.state = ReconnectState::Disabled;
return None;
}
if error.is_fatal() {
self.state = ReconnectState::Exhausted;
self.next_attempt_at = None;
return Some(ReconnectEvent::Exhausted {
total_attempts: self.attempts,
});
}
self.schedule_next_attempt(now)
}
fn schedule_next_attempt(&mut self, now: Instant) -> Option<ReconnectEvent> {
let cap = max_attempts();
if cap > 0 && self.attempts >= cap {
self.state = ReconnectState::Exhausted;
self.next_attempt_at = None;
return Some(ReconnectEvent::Exhausted {
total_attempts: self.attempts,
});
}
self.attempts = self.attempts.saturating_add(1);
let delay = compute_slot(self.attempts);
self.next_attempt_at = Some(now + delay);
self.state = ReconnectState::Waiting;
Some(ReconnectEvent::AttemptScheduled {
attempt: self.attempts,
delay,
})
}
pub fn tick(
&mut self,
backend: &mut dyn Backend,
iface: &str,
snapshot: &crate::backend::InterfaceState,
now: Instant,
) -> Option<ReconnectEvent> {
if !self.auto_reconnect {
return None;
}
if self.state == ReconnectState::Disabled || self.state == ReconnectState::Exhausted {
return None;
}
let due = match self.next_attempt_at {
Some(t) => now >= t,
None => false,
};
if !due {
return None;
}
self.state = ReconnectState::Attempting;
let attempt = self.attempts;
match backend.connect(iface, snapshot) {
Ok(WifiStatus::Connected) => {
self.record_success(now);
Some(ReconnectEvent::AttemptSucceeded { attempt })
}
Ok(other) => {
let err = WifiError::InternalError(format!(
"reconnect attempt {} returned {:?}",
attempt, other
));
self.last_error = err.human_message();
self.schedule_next_attempt(now);
Some(ReconnectEvent::AttemptFailed {
attempt,
reason: err.human_message(),
})
}
Err(e) => {
let err = WifiError::InternalError(format!("reconnect attempt {attempt}: {e}"));
self.last_error = err.human_message();
self.schedule_next_attempt(now);
Some(ReconnectEvent::AttemptFailed {
attempt,
reason: err.human_message(),
})
}
}
}
}
pub fn record_event(journal: Option<&Journal>, iface: &str, ev: &ReconnectEvent) {
let Some(j) = journal else { return };
let (kind, data) = match ev {
ReconnectEvent::AttemptScheduled { attempt, delay } => (
EventKind::RetryScheduled,
format!("attempt={attempt} delay_ms={}", delay.as_millis()),
),
ReconnectEvent::AttemptFired { attempt } => {
(EventKind::RetryScheduled, format!("fired attempt={attempt}"))
}
ReconnectEvent::AttemptSucceeded { attempt } => (
EventKind::ConnectSucceeded,
format!("reconnect attempt={attempt}"),
),
ReconnectEvent::AttemptFailed { attempt, reason } => (
EventKind::ConnectFailed,
format!("reconnect attempt={attempt} reason={reason}"),
),
ReconnectEvent::Exhausted { total_attempts } => (
EventKind::RetryExhausted,
format!("total_attempts={total_attempts}"),
),
ReconnectEvent::Disabled => (EventKind::StateTransition, "disabled".to_string()),
};
let _ = j.append(kind, iface, &data);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::NoDeviceBackend;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn temp_path(suffix: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("rbos-reconnect-{stamp}-{suffix}"));
fs::create_dir_all(&dir).unwrap();
dir
}
fn with_short_backoff<F: FnOnce() + std::panic::UnwindSafe>(f: F) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_base = std::env::var("REDBEAR_WIFICTL_BACKOFF_BASE_MS").ok();
let prev_cap = std::env::var("REDBEAR_WIFICTL_BACKOFF_CAP_MS").ok();
let prev_max = std::env::var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS").ok();
unsafe {
std::env::set_var("REDBEAR_WIFICTL_BACKOFF_BASE_MS", "10");
std::env::set_var("REDBEAR_WIFICTL_BACKOFF_CAP_MS", "320");
std::env::set_var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS", "5");
}
let result = std::panic::catch_unwind(f);
if let Some(v) = prev_base {
unsafe {
std::env::set_var("REDBEAR_WIFICTL_BACKOFF_BASE_MS", v);
}
} else {
unsafe {
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_BASE_MS");
}
}
if let Some(v) = prev_cap {
unsafe {
std::env::set_var("REDBEAR_WIFICTL_BACKOFF_CAP_MS", v);
}
} else {
unsafe {
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_CAP_MS");
}
}
if let Some(v) = prev_max {
unsafe {
std::env::set_var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS", v);
}
} else {
unsafe {
std::env::remove_var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS");
}
}
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
#[test]
fn compute_slot_returns_expected_exponential_sequence() {
with_short_backoff(|| {
assert_eq!(compute_slot(0), Duration::from_millis(10));
assert_eq!(compute_slot(1), Duration::from_millis(20));
assert_eq!(compute_slot(2), Duration::from_millis(40));
assert_eq!(compute_slot(3), Duration::from_millis(80));
assert_eq!(compute_slot(4), Duration::from_millis(160));
assert_eq!(compute_slot(5), Duration::from_millis(320));
assert_eq!(compute_slot(6), Duration::from_millis(320));
assert_eq!(compute_slot(100), Duration::from_millis(320));
});
}
#[test]
fn compute_slot_uses_default_production_values() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_BASE_MS");
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_CAP_MS");
std::env::remove_var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS");
}
let slot1 = compute_slot(1);
assert_eq!(slot1, Duration::from_millis(2_000));
let slot5 = compute_slot(5);
assert_eq!(slot5, Duration::from_millis(32_000));
let slot6 = compute_slot(6);
assert_eq!(slot6, Duration::from_millis(60_000));
let slot100 = compute_slot(100);
assert_eq!(slot100, Duration::from_millis(60_000));
unsafe {
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_BASE_MS");
std::env::remove_var("REDBEAR_WIFICTL_BACKOFF_CAP_MS");
std::env::remove_var("REDBEAR_WIFICTL_RECONNECT_MAX_ATTEMPTS");
}
}
#[test]
fn new_controller_respects_auto_reconnect_flag() {
let c = ReconnectController::new(true);
assert_eq!(c.state, ReconnectState::Idle);
assert_eq!(c.attempts, 0);
assert!(c.auto_reconnect);
let c = ReconnectController::new(false);
assert_eq!(c.state, ReconnectState::Disabled);
assert!(!c.auto_reconnect);
}
#[test]
fn record_disconnect_schedules_first_attempt() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
let ev = c.record_disconnect(now);
assert!(matches!(
ev,
Some(ReconnectEvent::AttemptScheduled { attempt: 1, .. })
));
assert_eq!(c.state, ReconnectState::Waiting);
assert_eq!(c.attempts, 1);
assert!(c.next_attempt_at.is_some());
});
}
#[test]
fn record_disconnect_with_auto_reconnect_off_returns_none() {
let mut c = ReconnectController::new(false);
let now = Instant::now();
let ev = c.record_disconnect(now);
assert!(ev.is_none());
assert_eq!(c.state, ReconnectState::Disabled);
}
#[test]
fn record_success_resets_controller() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
c.record_disconnect(now);
c.record_success(now);
assert_eq!(c.state, ReconnectState::Idle);
assert_eq!(c.attempts, 0);
assert!(c.next_attempt_at.is_none());
assert!(c.last_success_at.is_some());
});
}
#[test]
fn max_attempts_cap_exhausts_controller() {
with_short_backoff(|| {
let cap = max_attempts();
assert_eq!(cap, 5, "test setup: cap env var must be 5");
let mut c = ReconnectController::new(true);
let now = Instant::now();
for n in 0..(cap + 1) {
let ev = c.record_disconnect(now);
if n < cap {
assert!(
matches!(ev, Some(ReconnectEvent::AttemptScheduled { .. })),
"call {n}: expected AttemptScheduled, got {ev:?}, attempts={}",
c.attempts
);
} else {
assert!(
matches!(ev, Some(ReconnectEvent::Exhausted { .. })),
"call {n}: expected Exhausted, got {ev:?}, attempts={}",
c.attempts
);
}
}
assert_eq!(c.state, ReconnectState::Exhausted);
assert!(c.next_attempt_at.is_none());
});
}
#[test]
fn fatal_failure_marks_exhausted_without_retry() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
let ev = c.record_failure(now, WifiError::NoDevice);
assert!(matches!(ev, Some(ReconnectEvent::Exhausted { .. })));
assert_eq!(c.state, ReconnectState::Exhausted);
});
}
#[test]
fn recoverable_failure_schedules_next_attempt() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
c.record_disconnect(now);
c.record_failure(now, WifiError::TransportTimeout {
command: "ALIVE".to_string(),
waited_ms: 5000,
});
assert!(c.attempts >= 1);
assert!(c.next_attempt_at.is_some());
assert!(!c.last_error.is_empty());
});
}
#[test]
fn tick_does_nothing_when_not_due() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
c.record_disconnect(now);
let future = now + Duration::from_millis(5);
let mut backend = NoDeviceBackend::new();
let snapshot = crate::backend::InterfaceState::default();
let ev = c.tick(&mut backend, "wlan0", &snapshot, future);
assert!(ev.is_none());
assert_eq!(c.state, ReconnectState::Waiting);
});
}
#[test]
fn tick_fires_due_attempt_and_records_failure() {
with_short_backoff(|| {
let mut c = ReconnectController::new(true);
let now = Instant::now();
c.record_disconnect(now);
let mut backend = NoDeviceBackend::new();
let snapshot = crate::backend::InterfaceState::default();
let later = now + Duration::from_secs(1);
let ev = c.tick(&mut backend, "wlan0", &snapshot, later);
assert!(matches!(ev, Some(ReconnectEvent::AttemptFailed { .. })));
assert!(c.attempts >= 1);
});
}
#[test]
fn set_auto_reconnect_enables_when_disabled() {
let mut c = ReconnectController::new(false);
assert_eq!(c.state, ReconnectState::Disabled);
c.set_auto_reconnect(true, Instant::now());
assert_eq!(c.state, ReconnectState::Idle);
assert!(c.auto_reconnect);
}
#[test]
fn set_auto_reconnect_disables_when_enabled() {
let mut c = ReconnectController::new(true);
let now = Instant::now();
c.record_disconnect(now);
c.set_auto_reconnect(false, now);
assert!(!c.auto_reconnect);
assert_eq!(c.state, ReconnectState::Disabled);
assert!(c.next_attempt_at.is_none());
}
#[test]
fn record_event_writes_to_journal_when_present() {
let dir = temp_path("journal");
let path = dir.join("events.log");
let j = Journal::open(&path).unwrap();
let ev = ReconnectEvent::AttemptScheduled {
attempt: 1,
delay: Duration::from_millis(2000),
};
record_event(Some(&j), "wlan0", &ev);
let events = j.read_for_interface("wlan0").unwrap();
assert_eq!(events.len(), 1);
assert!(events[0].data.contains("attempt=1"));
assert!(events[0].data.contains("delay_ms=2000"));
}
}
@@ -8,6 +8,8 @@ use syscall::schemev2::NewFdFlags;
use syscall::Stat;
use crate::backend::{Backend, InterfaceState, WifiStatus};
use crate::journal::{EventKind, Journal};
use crate::reconnect::{record_event, ReconnectController, ReconnectEvent};
const SCHEME_ROOT_ID: usize = 1;
@@ -38,6 +40,8 @@ enum HandleKind {
Connect(String),
Disconnect(String),
Retry(String),
AutoReconnect(String),
ReconnectState(String),
}
pub struct WifiCtlScheme {
@@ -45,11 +49,18 @@ pub struct WifiCtlScheme {
next_id: usize,
handles: BTreeMap<usize, HandleKind>,
states: BTreeMap<String, InterfaceState>,
journal: Option<Journal>,
reconnect_controllers: BTreeMap<String, ReconnectController>,
}
impl WifiCtlScheme {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self::with_journal(backend, None)
}
pub fn with_journal(backend: Box<dyn Backend>, journal: Option<Journal>) -> Self {
let mut states = BTreeMap::new();
let mut reconnect_controllers = BTreeMap::new();
for iface in backend.interfaces() {
states.insert(
iface.clone(),
@@ -66,6 +77,7 @@ impl WifiCtlScheme {
..Default::default()
},
);
reconnect_controllers.insert(iface, ReconnectController::new(true));
}
Self {
@@ -73,6 +85,49 @@ impl WifiCtlScheme {
next_id: SCHEME_ROOT_ID + 1,
handles: BTreeMap::new(),
states,
journal,
reconnect_controllers,
}
}
pub fn reconnect_controller(&self, iface: &str) -> Option<&ReconnectController> {
self.reconnect_controllers.get(iface)
}
pub fn reconnect_controller_mut(&mut self, iface: &str) -> Option<&mut ReconnectController> {
self.reconnect_controllers.get_mut(iface)
}
pub fn tick_reconnect(
&mut self,
iface: &str,
now: std::time::Instant,
) -> Option<ReconnectEvent> {
let snapshot = match self.states.get(iface) {
Some(s) => s.clone(),
None => return None,
};
let event = {
let controller = self.reconnect_controllers.get_mut(iface)?;
controller.tick(self.backend.as_mut(), iface, &snapshot, now)
};
if let Some(ev) = &event {
record_event(self.journal.as_ref(), iface, ev);
}
event
}
pub fn journal(&self) -> Option<&Journal> {
self.journal.as_ref()
}
pub fn set_journal(&mut self, journal: Option<Journal>) {
self.journal = journal;
}
fn record(&self, kind: EventKind, iface: &str, data: &str) {
if let Some(j) = &self.journal {
let _ = j.append(kind, iface, data);
}
}
@@ -100,14 +155,24 @@ impl WifiCtlScheme {
HandleKind::Root => "ifaces\ncapabilities\n".to_string(),
HandleKind::Ifaces => self.states.keys().cloned().collect::<Vec<_>>().join("\n") + "\n",
HandleKind::Interface(_) => {
"status\nlink-state\nfirmware-status\ntransport-status\ntransport-init-status\nactivation-status\nconnect-result\ndisconnect-result\nscan-results\nlast-error\nssid\nsecurity\nkey\nscan\nprepare\ntransport-probe\ninit-transport\nactivate-nic\nconnect\ndisconnect\nretry\n"
"status\nlink-state\nfirmware-status\ntransport-status\ntransport-init-status\nactivation-status\nconnect-result\ndisconnect-result\nscan-results\nlast-error\nssid\nsecurity\nkey\nscan\nprepare\ntransport-probe\ninit-transport\nactivate-nic\nconnect\ndisconnect\nretry\nauto-reconnect\nreconnect-state\n"
.to_string()
}
HandleKind::Capabilities => self.backend.capabilities().join("\n") + "\n",
HandleKind::Status(iface) => {
let state = self.state(iface)?;
let reconnect_state = self
.reconnect_controllers
.get(iface)
.map(|c| c.state.as_str())
.unwrap_or("idle");
let reconnect_attempts = self
.reconnect_controllers
.get(iface)
.map(|c| c.attempts)
.unwrap_or(0);
format!(
"status={}\nlink_state={}\nfirmware_status={}\ntransport_status={}\ntransport_init_status={}\nactivation_status={}\nconnect_result={}\ndisconnect_result={}\nssid={}\nsecurity={}\n",
"status={}\nlink_state={}\nfirmware_status={}\ntransport_status={}\ntransport_init_status={}\nactivation_status={}\nconnect_result={}\ndisconnect_result={}\nssid={}\nsecurity={}\nreconnect_state={}\nreconnect_attempts={}\n",
state.status,
state.link_state,
state.firmware_status,
@@ -117,7 +182,9 @@ impl WifiCtlScheme {
state.connect_result,
state.disconnect_result,
state.ssid,
state.security
state.security,
reconnect_state,
reconnect_attempts
)
}
HandleKind::LinkState(iface) => format!("{}\n", self.state(iface)?.link_state),
@@ -140,6 +207,22 @@ impl WifiCtlScheme {
HandleKind::Ssid(iface) => format!("{}\n", self.state(iface)?.ssid),
HandleKind::Security(iface) => format!("{}\n", self.state(iface)?.security),
HandleKind::Key(_iface) => "[redacted]\n".to_string(),
HandleKind::AutoReconnect(iface) => {
let on = self
.reconnect_controllers
.get(iface)
.map(|c| c.auto_reconnect)
.unwrap_or(true);
format!("{}\n", if on { "yes" } else { "no" })
}
HandleKind::ReconnectState(iface) => {
let state = self
.reconnect_controllers
.get(iface)
.map(|c| c.state.as_str())
.unwrap_or("idle");
format!("reconnect_state={state}\n")
}
HandleKind::Scan(_)
| HandleKind::TransportProbe(_)
| HandleKind::InitTransport(_)
@@ -171,6 +254,7 @@ impl WifiCtlScheme {
connect_result: String,
disconnect_result: String,
) -> Result<()> {
let now = std::time::Instant::now();
let state = self.state_mut(iface)?;
state.status = status.as_str().to_string();
state.link_state = Self::link_state_for_status(&status).to_string();
@@ -178,8 +262,26 @@ impl WifiCtlScheme {
state.transport_status = transport_status;
state.connect_result = connect_result;
state.disconnect_result = disconnect_result;
drop(state);
if status == WifiStatus::Connected {
if let Some(controller) = self.reconnect_controllers.get_mut(iface) {
controller.record_success(now);
}
}
Ok(())
}
fn on_disconnect_event(&mut self, iface: &str) {
let now = std::time::Instant::now();
let event = if let Some(controller) = self.reconnect_controllers.get_mut(iface) {
controller.record_disconnect(now)
} else {
None
};
if let Some(ev) = event {
record_event(self.journal.as_ref(), iface, &ev);
}
}
}
impl SchemeSync for WifiCtlScheme {
@@ -231,6 +333,8 @@ impl SchemeSync for WifiCtlScheme {
"connect" => HandleKind::Connect(iface.clone()),
"disconnect" => HandleKind::Disconnect(iface.clone()),
"retry" => HandleKind::Retry(iface.clone()),
"auto-reconnect" => HandleKind::AutoReconnect(iface.clone()),
"reconnect-state" => HandleKind::ReconnectState(iface.clone()),
_ => return Err(Error::new(ENOENT)),
},
_ => return Err(Error::new(EACCES)),
@@ -280,6 +384,7 @@ impl SchemeSync for WifiCtlScheme {
HandleKind::Security(iface) => self.state_mut(&iface)?.security = value,
HandleKind::Key(iface) => self.state_mut(&iface)?.key = value,
HandleKind::Scan(iface) => {
self.record(EventKind::StateTransition, &iface, "down->scanning");
let results = match self.backend.scan(&iface) {
Ok(results) => results,
Err(err) => {
@@ -291,11 +396,17 @@ impl SchemeSync for WifiCtlScheme {
state.link_state = "link=down".to_string();
state.firmware_status = firmware_status;
state.transport_status = transport_status;
self.record(
EventKind::StateTransition,
&iface,
"scanning->failed",
);
return Ok(buf.len());
}
};
let firmware_status = self.backend.firmware_status(&iface);
let transport_status = self.backend.transport_status(&iface);
let ap_count = results.len();
let state = self.state_mut(&iface)?;
state.status = WifiStatus::Scanning.as_str().to_string();
state.link_state = "link=scanning".to_string();
@@ -303,8 +414,14 @@ impl SchemeSync for WifiCtlScheme {
state.transport_status = transport_status;
state.scan_results = results;
state.last_error.clear();
self.record(
EventKind::ScanCompleted,
&iface,
&format!("aps={ap_count}"),
);
}
HandleKind::Prepare(iface) => {
self.record(EventKind::StateTransition, &iface, "down->prepare");
let status = match self.backend.prepare(&iface) {
Ok(status) => status,
Err(err) => {
@@ -316,6 +433,12 @@ impl SchemeSync for WifiCtlScheme {
state.link_state = "link=down".to_string();
state.firmware_status = firmware_status;
state.transport_status = transport_status;
self.record(EventKind::FirmwareLoad, &iface, "failed");
self.record(
EventKind::StateTransition,
&iface,
"prepare->failed",
);
return Ok(buf.len());
}
};
@@ -327,6 +450,12 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.transport_init_status = "transport_init=not-run".to_string();
self.record(EventKind::FirmwareLoad, &iface, "ok");
self.record(
EventKind::StateTransition,
&iface,
"prepare->firmware-ready",
);
}
HandleKind::TransportProbe(iface) => {
let transport_status = match self.backend.transport_probe(&iface) {
@@ -338,6 +467,11 @@ impl SchemeSync for WifiCtlScheme {
state.status = WifiStatus::Failed.as_str().to_string();
state.link_state = "link=down".to_string();
state.firmware_status = firmware_status;
self.record(
EventKind::StateTransition,
&iface,
"probe->failed",
);
return Ok(buf.len());
}
};
@@ -345,6 +479,7 @@ impl SchemeSync for WifiCtlScheme {
state.transport_status = transport_status;
}
HandleKind::InitTransport(iface) => {
self.record(EventKind::TransportInit, &iface, "started");
let transport_init_status = match self.backend.init_transport(&iface) {
Ok(status) => status,
Err(err) => {
@@ -357,6 +492,7 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.transport_init_status = "transport_init=failed".to_string();
self.record(EventKind::TransportInit, &iface, "failed");
return Ok(buf.len());
}
};
@@ -364,6 +500,7 @@ impl SchemeSync for WifiCtlScheme {
state.transport_init_status = transport_init_status;
state.link_state = "link=transport-initialized".to_string();
state.activation_status = "activation=not-run".to_string();
self.record(EventKind::TransportInit, &iface, "ok");
}
HandleKind::ActivateNic(iface) => {
let activation_status = match self.backend.activate(&iface) {
@@ -378,6 +515,11 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.activation_status = "activation=failed".to_string();
self.record(
EventKind::StateTransition,
&iface,
"activate->failed",
);
return Ok(buf.len());
}
};
@@ -388,9 +530,19 @@ impl SchemeSync for WifiCtlScheme {
state.link_state = "link=nic-active".to_string();
state.connect_result = connect_result;
state.disconnect_result = disconnect_result;
self.record(
EventKind::StateTransition,
&iface,
"transport-init->nic-active",
);
}
HandleKind::Connect(iface) => {
let snapshot = self.state(&iface)?.clone();
self.record(
EventKind::ConnectAttempt,
&iface,
&format!("ssid={} security={}", snapshot.ssid, snapshot.security),
);
let new_status = match self.backend.connect(&iface, &snapshot) {
Ok(status) => status,
Err(err) => {
@@ -404,6 +556,11 @@ impl SchemeSync for WifiCtlScheme {
state.transport_status = transport_status;
state.transport_init_status = "transport_init=failed".to_string();
state.activation_status = "activation=failed".to_string();
self.record(
EventKind::ConnectFailed,
&iface,
&format!("ssid={}", snapshot.ssid),
);
return Ok(buf.len());
}
};
@@ -411,6 +568,7 @@ impl SchemeSync for WifiCtlScheme {
let transport_status = self.backend.transport_status(&iface);
let connect_result = self.backend.connect_result(&iface);
let disconnect_result = self.backend.disconnect_result(&iface);
let succeeded = new_status == WifiStatus::Connected;
self.apply_connect_outcome(
&iface,
new_status,
@@ -419,6 +577,13 @@ impl SchemeSync for WifiCtlScheme {
connect_result,
disconnect_result,
)?;
if succeeded {
self.record(
EventKind::ConnectSucceeded,
&iface,
&format!("ssid={}", snapshot.ssid),
);
}
}
HandleKind::Disconnect(iface) => {
let status = match self.backend.disconnect(&iface) {
@@ -433,6 +598,11 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.activation_status = "activation=failed".to_string();
self.record(
EventKind::StateTransition,
&iface,
"disconnect->failed",
);
return Ok(buf.len());
}
};
@@ -445,8 +615,11 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.disconnect_result = disconnect_result;
self.record(EventKind::Disconnect, &iface, "ok");
self.on_disconnect_event(&iface);
}
HandleKind::Retry(iface) => {
self.record(EventKind::RetryScheduled, &iface, "started");
let status = match self.backend.retry(&iface) {
Ok(status) => status,
Err(err) => {
@@ -459,12 +632,26 @@ impl SchemeSync for WifiCtlScheme {
state.firmware_status = firmware_status;
state.transport_status = transport_status;
state.activation_status = "activation=failed".to_string();
self.record(EventKind::RetryExhausted, &iface, "failed");
return Ok(buf.len());
}
};
let state = self.state_mut(&iface)?;
state.status = status.as_str().to_string();
state.link_state = "link=retrying".to_string();
self.record(EventKind::StateTransition, &iface, "retry->in-progress");
}
HandleKind::AutoReconnect(iface) => {
let enabled = matches!(value.as_str(), "yes" | "true" | "1" | "on");
let now = std::time::Instant::now();
if let Some(controller) = self.reconnect_controllers.get_mut(&iface) {
controller.set_auto_reconnect(enabled, now);
self.record(
EventKind::ProfileChanged,
&iface,
&format!("auto-reconnect={}", if enabled { "yes" } else { "no" }),
);
}
}
_ => return Err(Error::new(EROFS)),
}
@@ -485,7 +672,8 @@ impl SchemeSync for WifiCtlScheme {
| HandleKind::Prepare(_)
| HandleKind::Ssid(_)
| HandleKind::Security(_)
| HandleKind::Key(_) => MODE_FILE | 0o644,
| HandleKind::Key(_)
| HandleKind::AutoReconnect(_) => MODE_FILE | 0o644,
_ => MODE_FILE | 0o444,
};
Ok(())
@@ -531,6 +719,12 @@ impl SchemeSync for WifiCtlScheme {
HandleKind::Connect(iface) => format!("wifictl:/ifaces/{iface}/connect"),
HandleKind::Disconnect(iface) => format!("wifictl:/ifaces/{iface}/disconnect"),
HandleKind::Retry(iface) => format!("wifictl:/ifaces/{iface}/retry"),
HandleKind::AutoReconnect(iface) => {
format!("wifictl:/ifaces/{iface}/auto-reconnect")
}
HandleKind::ReconnectState(iface) => {
format!("wifictl:/ifaces/{iface}/reconnect-state")
}
};
let bytes = path.as_bytes();
let count = bytes.len().min(buf.len());
@@ -806,4 +1000,74 @@ esac
}
}
}
#[test]
fn auto_reconnect_handle_exposes_flag_and_toggle() {
let _guard = crate::backend::TEST_ENV_LOCK.lock().unwrap();
let mut scheme = WifiCtlScheme::new(Box::new(NoDeviceBackend::new()));
let iface = "wlan0".to_string();
scheme
.states
.insert(iface.clone(), InterfaceState::default());
scheme
.reconnect_controllers
.insert(iface.clone(), ReconnectController::new(true));
let state = scheme.reconnect_controller(&iface).unwrap();
assert!(state.auto_reconnect);
let controller = scheme.reconnect_controller_mut(&iface).unwrap();
controller.set_auto_reconnect(false, std::time::Instant::now());
assert!(!scheme.reconnect_controller(&iface).unwrap().auto_reconnect);
}
#[test]
fn on_disconnect_event_advances_controller_state() {
let _guard = crate::backend::TEST_ENV_LOCK.lock().unwrap();
let mut scheme = WifiCtlScheme::new(Box::new(NoDeviceBackend::new()));
let iface = "wlan0".to_string();
scheme
.states
.insert(iface.clone(), InterfaceState::default());
scheme
.reconnect_controllers
.insert(iface.clone(), ReconnectController::new(true));
scheme.on_disconnect_event(&iface);
let controller = scheme.reconnect_controller(&iface).unwrap();
assert_eq!(controller.attempts, 1);
assert_eq!(controller.state, crate::reconnect::ReconnectState::Waiting);
}
#[test]
fn apply_connect_outcome_resets_controller_on_success() {
let _guard = crate::backend::TEST_ENV_LOCK.lock().unwrap();
let mut scheme = WifiCtlScheme::new(Box::new(NoDeviceBackend::new()));
let iface = "wlan0".to_string();
scheme
.states
.insert(iface.clone(), InterfaceState::default());
scheme
.reconnect_controllers
.insert(iface.clone(), ReconnectController::new(true));
scheme.on_disconnect_event(&iface);
assert!(scheme.reconnect_controller(&iface).unwrap().attempts >= 1);
scheme
.apply_connect_outcome(
&iface,
WifiStatus::Connected,
"ok".to_string(),
"ok".to_string(),
"ok".to_string(),
"ok".to_string(),
)
.unwrap();
assert_eq!(scheme.reconnect_controller(&iface).unwrap().attempts, 0);
assert_eq!(
scheme.reconnect_controller(&iface).unwrap().state,
crate::reconnect::ReconnectState::Idle
);
}
}