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:
parent
db6003aef6
commit
852ffc5b18
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user