feat(pine): wyoming satellite keeper — re-point speaker entries when DHCP strands them

HA stores a voice satellite as a pinned LAN IP and never re-resolves; when
the LAN renumbers (framework-pt: entry at 192.168.1.241, LAN now
192.168.63.0/24) the speaker silently drops forever. A periodic keeper
probes IP-pinned wyoming entries, sweeps the node's local /24s for the
satellite's port when one dies, rewrites core.config_entries (HA stopped
around the edit so its shutdown flush can't clobber it) and starts HA again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 15:28:07 -04:00
parent db6003aef6
commit 852ffc5b18
3 changed files with 185 additions and 0 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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<std::net::Ipv4Addr> {
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<String> {
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::<std::net::Ipv4Addr>().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
// ---------------------------------------------------------------------------