Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine
This commit is contained in:
commit
3422c12379
18 changed files with 3475 additions and 0 deletions
223
src/watch.rs
Normal file
223
src/watch.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::flow;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
use crate::state::State;
|
||||
use crate::status::{self};
|
||||
use crate::tailscale::TsHealth;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug)]
|
||||
enum Health {
|
||||
Up,
|
||||
DownNoNet,
|
||||
DownTailscaleManual,
|
||||
DownTailscaleOther,
|
||||
NoAdapter,
|
||||
}
|
||||
|
||||
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||
let s = status::gather(cfg, profile);
|
||||
if s.iface.is_none() {
|
||||
return (Health::NoAdapter, None);
|
||||
}
|
||||
let ssid = s.ssid.clone();
|
||||
if !s.internet {
|
||||
return (Health::DownNoNet, ssid);
|
||||
}
|
||||
if s.tailscale_required {
|
||||
match s.tailscale {
|
||||
Some(TsHealth::Ok) => (Health::Up, ssid),
|
||||
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
||||
(Health::DownTailscaleManual, ssid)
|
||||
}
|
||||
Some(_) => (Health::DownTailscaleOther, ssid),
|
||||
None => (Health::DownTailscaleManual, ssid),
|
||||
}
|
||||
} else {
|
||||
(Health::Up, ssid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||
/// to drops within a second instead of waiting out the poll interval.
|
||||
fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
||||
thread::spawn(move || loop {
|
||||
let child = Command::new("nmcli")
|
||||
.arg("monitor")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let reader = BufReader::new(out);
|
||||
let mut last = Instant::now() - Duration::from_secs(10);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let l = line.to_lowercase();
|
||||
let interesting = l.contains("disconnect")
|
||||
|| l.contains("unavailable")
|
||||
|| l.contains("connected")
|
||||
|| l.contains("connection")
|
||||
|| l.contains("now")
|
||||
|| l.contains("state");
|
||||
if interesting && last.elapsed() > Duration::from_millis(1500) {
|
||||
last = Instant::now();
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
// monitor died (NM restart?) — back off and respawn.
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
});
|
||||
}
|
||||
|
||||
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn.
|
||||
fn wait_for_tick(rx: &Receiver<()>, dur: Duration) {
|
||||
match rx.recv_timeout(dur) {
|
||||
Ok(()) => {
|
||||
// Drain any burst of events so we don't re-fire immediately.
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
// Monitor thread gone (shouldn't happen: we hold the sender) — fall
|
||||
// back to a plain sleep so we don't busy-spin.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||
let base = cfg.settings.watch_interval.max(4);
|
||||
notify(
|
||||
"breadcrumbs watcher started",
|
||||
"Monitoring Wi-Fi; will auto-recover drops.",
|
||||
Urgency::Low,
|
||||
);
|
||||
log("watch: started");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
spawn_nm_monitor(tx);
|
||||
|
||||
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||
if run_initial {
|
||||
// Don't churn an already-working connection on (re)start.
|
||||
let (h, _) = classify(&cfg, &profile);
|
||||
if h == Health::Up {
|
||||
log(&format!(
|
||||
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
||||
));
|
||||
} else {
|
||||
log(&format!("watch: initial flow for profile={profile}"));
|
||||
let _ = flow::run(&cfg, &profile);
|
||||
}
|
||||
}
|
||||
|
||||
let mut prev_health: Option<Health> = None;
|
||||
let mut prev_profile = profile.clone();
|
||||
let mut fail_streak: u32 = 0;
|
||||
|
||||
loop {
|
||||
// Reload config + state so edits and `profile set` take effect live.
|
||||
if let Ok(fresh) = Config::load() {
|
||||
cfg = fresh;
|
||||
}
|
||||
profile = State::load(&cfg.settings.default_profile).profile;
|
||||
|
||||
let profile_changed = profile != prev_profile;
|
||||
if profile_changed {
|
||||
log(&format!(
|
||||
"watch: profile changed {prev_profile} -> {profile}"
|
||||
));
|
||||
notify(
|
||||
"breadcrumbs: profile changed",
|
||||
&format!("{prev_profile} -> {profile}"),
|
||||
Urgency::Low,
|
||||
);
|
||||
prev_profile = profile.clone();
|
||||
prev_health = None; // force re-evaluation/recovery for new profile
|
||||
}
|
||||
|
||||
let (health, ssid) = classify(&cfg, &profile);
|
||||
let transition = prev_health.as_ref() != Some(&health);
|
||||
|
||||
match &health {
|
||||
Health::Up => {
|
||||
if transition && prev_health.is_some() {
|
||||
notify(
|
||||
"breadcrumbs: back online",
|
||||
&format!(
|
||||
"{} ({profile})",
|
||||
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
|
||||
),
|
||||
Urgency::Low,
|
||||
);
|
||||
}
|
||||
fail_streak = 0;
|
||||
}
|
||||
Health::NoAdapter => {
|
||||
if transition {
|
||||
notify(
|
||||
"breadcrumbs: no Wi-Fi adapter",
|
||||
"Hardware issue — manual check needed.",
|
||||
Urgency::Critical,
|
||||
);
|
||||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
Health::DownTailscaleManual => {
|
||||
// Can't be auto-fixed (login / not installed). Notify once.
|
||||
if transition {
|
||||
notify(
|
||||
"Tailscale Error",
|
||||
"Tailscale needs manual attention (login / install). \
|
||||
Other Wi-Fi automation paused until resolved.",
|
||||
Urgency::Critical,
|
||||
);
|
||||
}
|
||||
// Re-run flow only on transition so we land on the bootstrap net.
|
||||
if transition || profile_changed {
|
||||
let _ = flow::run(&cfg, &profile);
|
||||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
Health::DownNoNet | Health::DownTailscaleOther => {
|
||||
if transition {
|
||||
notify(
|
||||
"breadcrumbs: connection lost",
|
||||
&format!("Recovering ({profile})…"),
|
||||
Urgency::Normal,
|
||||
);
|
||||
}
|
||||
log(&format!(
|
||||
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||
health, ssid
|
||||
));
|
||||
let outcome = flow::run(&cfg, &profile);
|
||||
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||
fail_streak = if outcome.ok() {
|
||||
0
|
||||
} else {
|
||||
fail_streak.saturating_add(1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
prev_health = Some(health);
|
||||
|
||||
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
|
||||
let mult = 1 + fail_streak.min(5);
|
||||
let dur = Duration::from_secs(base * mult as u64);
|
||||
wait_for_tick(&rx, dur);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue