From 836715a9addacc27e89d901b4369bb591cb583cf Mon Sep 17 00:00:00 2001 From: Admin Pupkin Date: Thu, 11 Jun 2026 10:57:44 +0300 Subject: [PATCH] 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 --- local/recipes/drivers/redbear-hid-core | 1 + .../system/redbear-info/source/src/main.rs | 486 ++++++++++++++++ .../redbear-wifictl/source/src/error.rs | 261 +++++++++ .../redbear-wifictl/source/src/journal.rs | 411 ++++++++++++++ .../system/redbear-wifictl/source/src/main.rs | 76 ++- .../redbear-wifictl/source/src/reconnect.rs | 532 ++++++++++++++++++ .../redbear-wifictl/source/src/scheme.rs | 272 ++++++++- 7 files changed, 2034 insertions(+), 5 deletions(-) create mode 160000 local/recipes/drivers/redbear-hid-core create mode 100644 local/recipes/system/redbear-wifictl/source/src/error.rs create mode 100644 local/recipes/system/redbear-wifictl/source/src/journal.rs create mode 100644 local/recipes/system/redbear-wifictl/source/src/reconnect.rs diff --git a/local/recipes/drivers/redbear-hid-core b/local/recipes/drivers/redbear-hid-core new file mode 160000 index 0000000000..7b82f4d396 --- /dev/null +++ b/local/recipes/drivers/redbear-hid-core @@ -0,0 +1 @@ +Subproject commit 7b82f4d39672a53918239ca681ddeba7558adff6 diff --git a/local/recipes/system/redbear-info/source/src/main.rs b/local/recipes/system/redbear-info/source/src/main.rs index ef6e6d1780..4049bb8dce 100644 --- a/local/recipes/system/redbear-info/source/src/main.rs +++ b/local/recipes/system/redbear-info/source/src/main.rs @@ -69,6 +69,15 @@ struct IdentityReport { hostname: Option, } +#[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, wifi_disconnect_result: Option, wifi_scan_results: Vec, + wifi_journal_event_count: usize, + wifi_journal_last_serial: Option, + wifi_journal_last_kind: Option, + wifi_journal_last_data: Option, + wifi_journal_last_interface: Option, + wifi_journal_last_timestamp_ns: Option, + wifi_journal_recent_events: Vec, + wifi_journal_error_kind: Option, + 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, + Option, + Option, + Option, + Option, + Vec, + Option, + 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 = 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 { + if !line.starts_with('{') || !line.ends_with('}') { + return None; + } + let body = &line[1..line.len() - 1]; + let mut serial: Option = None; + let mut timestamp_ns: Option = None; + let mut interface: Option = None; + let mut kind: Option = None; + let mut data: Option = 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> { + 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 { 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')); + } } diff --git a/local/recipes/system/redbear-wifictl/source/src/error.rs b/local/recipes/system/redbear-wifictl/source/src/error.rs new file mode 100644 index 0000000000..bc9e420956 --- /dev/null +++ b/local/recipes/system/redbear-wifictl/source/src/error.rs @@ -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 for WifiError { + fn from(err: std::io::Error) -> Self { + WifiError::InternalError(format!("io error: {err}")) + } +} + +impl From 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 = Box::new(WifiError::NoDevice); + let _displayed = format!("{err}"); + let _downcast: Box = err.downcast().unwrap(); + } +} diff --git a/local/recipes/system/redbear-wifictl/source/src/journal.rs b/local/recipes/system/redbear-wifictl/source/src/journal.rs new file mode 100644 index 0000000000..98b670c43c --- /dev/null +++ b/local/recipes/system/redbear-wifictl/source/src/journal.rs @@ -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 { + 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 { + 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 { + 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 { + let path = default_journal_path(); + Self::open(&path) + } + + pub fn open(path: &Path) -> Result { + 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 { + 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, 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, String> { + Ok(self + .read_all()? + .into_iter() + .filter(|ev| ev.interface == interface) + .collect()) + } + + pub fn last_for_interface(&self, interface: &str) -> Result, 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); + } + } +} diff --git a/local/recipes/system/redbear-wifictl/source/src/main.rs b/local/recipes/system/redbear-wifictl/source/src/main.rs index 5922174cce..0fbdd165e2 100644 --- a/local/recipes/system/redbear-wifictl/source/src/main.rs +++ b/local/recipes/system/redbear-wifictl/source/src/main.rs @@ -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); diff --git a/local/recipes/system/redbear-wifictl/source/src/reconnect.rs b/local/recipes/system/redbear-wifictl/source/src/reconnect.rs new file mode 100644 index 0000000000..5230aa0495 --- /dev/null +++ b/local/recipes/system/redbear-wifictl/source/src/reconnect.rs @@ -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, + pub last_error: String, + pub last_success_at: Option, +} + +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 { + 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 { + 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 { + 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 { + 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: 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")); + } +} diff --git a/local/recipes/system/redbear-wifictl/source/src/scheme.rs b/local/recipes/system/redbear-wifictl/source/src/scheme.rs index a19a300711..fcbc37463f 100644 --- a/local/recipes/system/redbear-wifictl/source/src/scheme.rs +++ b/local/recipes/system/redbear-wifictl/source/src/scheme.rs @@ -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, states: BTreeMap, + journal: Option, + reconnect_controllers: BTreeMap, } impl WifiCtlScheme { pub fn new(backend: Box) -> Self { + Self::with_journal(backend, None) + } + + pub fn with_journal(backend: Box, journal: Option) -> 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 { + 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) { + 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::>().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 + ); + } }