diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index fba353c3..0455d132 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -26,6 +26,7 @@ mod node; mod nostr; mod openwrt; mod package; +pub(crate) use package::wyoming_satellite_keeper; mod peers; mod pine_status; mod response; diff --git a/core/archipelago/src/api/rpc/package/mod.rs b/core/archipelago/src/api/rpc/package/mod.rs index cda27ea9..bb7c11bc 100644 --- a/core/archipelago/src/api/rpc/package/mod.rs +++ b/core/archipelago/src/api/rpc/package/mod.rs @@ -4,6 +4,7 @@ mod dependencies; mod install; mod lifecycle; mod pine_ha; +pub(crate) use pine_ha::wyoming_satellite_keeper; mod progress; mod runtime; mod set_config; diff --git a/core/archipelago/src/api/rpc/package/pine_ha.rs b/core/archipelago/src/api/rpc/package/pine_ha.rs index 2e045ec3..7c8bf11e 100644 --- a/core/archipelago/src/api/rpc/package/pine_ha.rs +++ b/core/archipelago/src/api/rpc/package/pine_ha.rs @@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<& false } +// --------------------------------------------------------------------------- +// Wyoming satellite keeper +// --------------------------------------------------------------------------- + +/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable. +/// +/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP +/// renumbering — or the whole node moving to a different network — strands +/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry +/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA +/// never re-resolves on its own. This keeper probes each satellite entry and, +/// when one stops answering, sweeps the node's local /24s for the same +/// Wyoming port and rewrites the entry to the address that answers. +pub(crate) async fn wyoming_satellite_keeper() { + loop { + if home_assistant_installed().await { + heal_wyoming_satellites().await; + } + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + } +} + +async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool { + matches!( + tokio::time::timeout( + std::time::Duration::from_millis(ms), + tokio::net::TcpStream::connect((host, port)), + ) + .await, + Ok(Ok(_)) + ) +} + +/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are +/// excluded — satellites live on real LANs. +async fn local_ipv4_addresses() -> Vec { + let Ok(out) = tokio::process::Command::new("ip") + .args(["-4", "-o", "addr", "show", "scope", "global"]) + .output() + .await + else { + return Vec::new(); + }; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| { + let cidr = l.split_whitespace().nth(3)?; + let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?; + let o = ip.octets(); + // 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN. + if o[0] == 100 && (64..128).contains(&o[1]) { + return None; + } + Some(ip) + }) + .collect() +} + +/// Sweep the /24 of every local interface for something answering `port`. +/// First responder wins; the node's own addresses are skipped. +async fn find_satellite(port: u16) -> Option { + let self_ips = local_ipv4_addresses().await; + for base in self_ips.iter().map(|ip| ip.octets()) { + let mut set = tokio::task::JoinSet::new(); + for i in 1..255u8 { + let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i); + if self_ips.contains(&ip) { + continue; + } + set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) }); + } + while let Some(res) = set.join_next().await { + if let Ok(Some(ip)) = res { + return Some(ip); + } + } + } + None +} + +/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their +/// port now answers. Stops HA before editing the store (HA flushes its own +/// in-memory copy on shutdown, which would clobber a live edit) and starts it +/// again after. +async fn heal_wyoming_satellites() { + let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries"); + let Some(store) = read_store(&path).await else { + return; + }; + let Some(entries) = store + .get("data") + .and_then(|d| d.get("entries")) + .and_then(|e| e.as_array()) + else { + return; + }; + + // (host, port, new_host) for every stranded satellite we can re-resolve. + let mut moves: Vec<(String, u16, String)> = Vec::new(); + for e in entries { + if e.get("domain").and_then(Value::as_str) != Some("wyoming") { + continue; + } + let Some(host) = e + .get("data") + .and_then(|d| d.get("host")) + .and_then(Value::as_str) + else { + continue; + }; + // Engines use host.containers.internal — only IP-pinned entries drift. + if host.parse::().is_err() { + continue; + } + let port = e + .get("data") + .and_then(|d| d.get("port")) + .and_then(Value::as_u64) + .unwrap_or(0) as u16; + if port == 0 || tcp_alive(host, port, 1500).await { + continue; + } + let Some(new_host) = find_satellite(port).await else { + info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet"); + continue; + }; + if new_host != host { + moves.push((host.to_string(), port, new_host)); + } + } + if moves.is_empty() { + return; + } + + // Stop HA, re-read + rewrite the store, start HA. + let _ = tokio::process::Command::new("podman") + .args(["stop", "homeassistant"]) + .output() + .await; + if let Some(mut store) = read_store(&path).await { + let mut changed = false; + if let Some(entries) = store + .get_mut("data") + .and_then(|d| d.get_mut("entries")) + .and_then(|e| e.as_array_mut()) + { + for e in entries.iter_mut() { + if e.get("domain").and_then(Value::as_str) != Some("wyoming") { + continue; + } + let host = e + .get("data") + .and_then(|d| d.get("host")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let port = e + .get("data") + .and_then(|d| d.get("port")) + .and_then(Value::as_u64) + .unwrap_or(0) as u16; + if let Some((_, _, new_host)) = + moves.iter().find(|(h, p, _)| *h == host && *p == port) + { + if let Some(data) = e.get_mut("data") { + data["host"] = json!(new_host); + } + e["modified_at"] = json!(ha_now()); + info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}"); + changed = true; + } + } + } + if changed { + write_store(&path, &store).await; + } + } + let _ = tokio::process::Command::new("podman") + .args(["start", "homeassistant"]) + .output() + .await; +} + // --------------------------------------------------------------------------- // Presence probes + HA restart // ---------------------------------------------------------------------------