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:
Submodule
+1
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user