From 14965b769db446a51cc99a6b76d76ddc89cdfef7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 21 Jul 2026 22:37:21 +0100 Subject: [PATCH] feat(pine): seed Home Assistant voice defaults when Pine + HA are installed Out of the box, HA needed manual clicks for each Wyoming engine, a hand-built Assist pipeline, and knew nothing about the node. Now installing the Pine stack (or HA, whichever comes second) seeds: - wyoming config entries for pine-whisper (:10300) and pine-piper (:10200) - an Assist pipeline wired to stt.faster_whisper + tts.piper, and a repair for the legacy 'homeassistant' conversation-agent id that modern HA rejects (intent-not-supported on every wake word otherwise) - 'ask Archy' voice intents (custom_sentences + intent_script), starting with block height via a mempool REST sensor when that app is present Seeding is best-effort, idempotent, and never overwrites an existing store it can't parse; HA restarts only when something was written. Co-Authored-By: Claude Fable 5 --- .../src/api/rpc/package/install.rs | 9 + core/archipelago/src/api/rpc/package/mod.rs | 1 + .../src/api/rpc/package/pine_ha.rs | 370 ++++++++++++++++++ .../archipelago/src/api/rpc/package/stacks.rs | 13 + 4 files changed, 393 insertions(+) create mode 100644 core/archipelago/src/api/rpc/package/pine_ha.rs diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index bb860f2c..984e0132 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -1563,6 +1563,15 @@ autopilot.active=false\n", /// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container). /// Critical hooks (credential setup, config) are awaited; UI container builds are background. async fn run_post_install_hooks(&self, package_id: &str) { + if matches!(package_id, "homeassistant" | "home-assistant") + && super::pine_ha::pine_engines_installed().await + { + // Pine was installed first: wire HA to its Wyoming engines now, + // then restart so the seeded storage is what HA loads. + if super::pine_ha::seed_home_assistant_pine_defaults().await { + super::pine_ha::restart_home_assistant_if_running().await; + } + } if package_id == "filebrowser" { // Generate a random password (32 bytes, hex-encoded) let mut buf = [0u8; 32]; diff --git a/core/archipelago/src/api/rpc/package/mod.rs b/core/archipelago/src/api/rpc/package/mod.rs index 4c165026..cda27ea9 100644 --- a/core/archipelago/src/api/rpc/package/mod.rs +++ b/core/archipelago/src/api/rpc/package/mod.rs @@ -3,6 +3,7 @@ mod config; mod dependencies; mod install; mod lifecycle; +mod pine_ha; 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 new file mode 100644 index 00000000..3ee6ffe5 --- /dev/null +++ b/core/archipelago/src/api/rpc/package/pine_ha.rs @@ -0,0 +1,370 @@ +//! Home Assistant <-> Pine voice-stack provisioning. +//! +//! The Pine app ships the two Wyoming engines (pine-whisper :10300, +//! pine-piper :10200) as plain containers. Home Assistant only uses them once +//! a `wyoming` config entry exists for each, and once an Assist pipeline +//! points at the resulting `stt.faster_whisper` / `tts.piper` entities. Out of +//! the box that meant clicking through HA's integration UI twice and building +//! a pipeline by hand — so seed all of it directly into HA's `.storage` when +//! both apps are present. +//! +//! Everything here is best-effort (warn, never fail an install): HA migrates +//! the minimal store shapes we write to its current schema on boot, and skips +//! entries that already exist, so re-running is idempotent. + +use serde_json::{json, Value}; +use tracing::{info, warn}; + +const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage"; + +/// The two Wyoming engines the Pine stack publishes on the host. +/// HA runs under pasta, so the host is reachable as host.containers.internal. +const PINE_ENGINES: [(&str, &str, u16); 2] = [ + ("faster-whisper", "host.containers.internal", 10300), + ("piper", "host.containers.internal", 10200), +]; + +/// Seed Home Assistant with the Pine voice defaults: a `wyoming` config entry +/// per engine and an Assist pipeline wired to them. Called after the Pine +/// stack installs and after Home Assistant installs (each side no-ops when +/// the other is missing). HA picks the new entries up on its next start; the +/// caller restarts the container when it is already running. +/// +/// Returns true when anything was written (caller should restart HA). +pub(super) async fn seed_home_assistant_pine_defaults() -> bool { + let storage = std::path::Path::new(HA_STORAGE_DIR); + if let Err(e) = tokio::fs::create_dir_all(storage).await { + warn!("pine/HA seed: cannot create {}: {}", HA_STORAGE_DIR, e); + return false; + } + + let entries_changed = seed_wyoming_config_entries(storage).await; + let pipeline_changed = seed_assist_pipeline(storage).await; + let intents_changed = seed_voice_intents().await; + entries_changed || pipeline_changed || intents_changed +} + +/// Marker guarding the seeded block in configuration.yaml (idempotence). +const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---"; + +/// Seed "ask Archy" node-info voice intents: custom sentences + intent_script +/// + a REST sensor for the block height (via the mempool backend when that +/// app is installed). Skips cleanly when the user already defines +/// intent_script themselves. +async fn seed_voice_intents() -> bool { + let config_dir = std::path::Path::new("/var/lib/archipelago/home-assistant"); + let config_yaml = config_dir.join("configuration.yaml"); + + let existing = tokio::fs::read_to_string(&config_yaml) + .await + .unwrap_or_default(); + if existing.contains(VOICE_MARKER) { + return false; + } + if existing.contains("intent_script:") || existing.contains("\nrest:") { + warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents"); + return false; + } + + // Sentences file (its own dir, no key-collision risk). + let sentences_dir = config_dir.join("custom_sentences/en"); + if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await { + warn!( + "pine/HA seed: cannot create {}: {}", + sentences_dir.display(), + e + ); + return false; + } + let sentences = r#"language: "en" +intents: + ArchyBlockHeight: + data: + - sentences: + - "what's the [current] block height" + - "what is the [current] block height" + - "[current] block height" + - "how many blocks [are there]" +"#; + if let Err(e) = tokio::fs::write(sentences_dir.join("archy.yaml"), sentences).await { + warn!("pine/HA seed: writing custom sentences failed: {e}"); + return false; + } + + let mempool_installed = tokio::fs::metadata("/var/lib/archipelago/mempool") + .await + .is_ok() + || tokio::fs::metadata("/var/lib/archipelago/data/mempool") + .await + .is_ok(); + let mut block = format!("\n{VOICE_MARKER}\n"); + if mempool_installed { + block.push_str( + r#"rest: + - resource: http://host.containers.internal:8999/api/blocks/tip/height + scan_interval: 60 + sensor: + - name: "Archy Block Height" + unique_id: archy_block_height + value_template: "{{ value }}" +"#, + ); + } + block.push_str( + r#"intent_script: + ArchyBlockHeight: + speech: + text: >- + {% if states('sensor.archy_block_height') not in ['unknown', 'unavailable'] %} + The block height is {{ states('sensor.archy_block_height') }}. + {% else %} + I can't read the block height right now. + {% endif %} +"#, + ); + + let mut merged = existing; + merged.push_str(&block); + let tmp = config_yaml.with_extension("yaml.tmp-seed"); + if let Err(e) = tokio::fs::write(&tmp, &merged).await { + warn!("pine/HA seed: writing configuration.yaml failed: {e}"); + return false; + } + if let Err(e) = tokio::fs::rename(&tmp, &config_yaml).await { + warn!("pine/HA seed: replacing configuration.yaml failed: {e}"); + return false; + } + info!( + "pine/HA seed: voice intents seeded (block height{})", + if mempool_installed { + " + mempool REST sensor" + } else { + ", sensor pending mempool install" + } + ); + true +} + +/// Ensure `core.config_entries` has a `wyoming` entry per Pine engine. +async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool { + let path = storage.join("core.config_entries"); + let mut store = if tokio::fs::metadata(&path).await.is_ok() { + match read_store(&path).await { + Some(v) => v, + // Present but unreadable/corrupt: never overwrite it with a + // fresh store — that would throw away every integration. + None => return false, + } + } else { + json!({ + "version": 1, + "minor_version": 1, + "key": "core.config_entries", + "data": { "entries": [] } + }) + }; + + let Some(entries) = store + .get_mut("data") + .and_then(|d| d.get_mut("entries")) + .and_then(|e| e.as_array_mut()) + else { + warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched"); + return false; + }; + + let mut added = false; + for (title, host, port) in PINE_ENGINES { + let exists = entries.iter().any(|e| { + e.get("domain").and_then(Value::as_str) == Some("wyoming") + && e.get("data") + .and_then(|d| d.get("host")) + .and_then(Value::as_str) + == Some(host) + && e.get("data") + .and_then(|d| d.get("port")) + .and_then(Value::as_u64) + == Some(port as u64) + }); + if exists { + continue; + } + let entry_id: [u8; 16] = rand::random(); + entries.push(json!({ + "entry_id": hex::encode(entry_id), + "version": 1, + "minor_version": 1, + "domain": "wyoming", + "title": title, + "data": { "host": host, "port": port }, + "options": {}, + "pref_disable_new_entities": false, + "pref_disable_polling": false, + "source": "user", + "unique_id": null, + "disabled_by": null + })); + info!("pine/HA seed: added wyoming config entry {title} ({host}:{port})"); + added = true; + } + + if added && !write_store(&path, &store).await { + return false; + } + added +} + +/// Ensure an Assist pipeline uses the Pine engines. Creates the store when +/// missing; when it exists, only repairs the pre-2024.6 conversation-agent id +/// (`homeassistant` -> `conversation.home_assistant`), which modern HA no +/// longer resolves (pipelines error with `intent-not-supported` otherwise). +async fn seed_assist_pipeline(storage: &std::path::Path) -> bool { + let path = storage.join("assist_pipeline.pipelines"); + + if let Some(mut store) = read_store(&path).await { + let Some(items) = store + .get_mut("data") + .and_then(|d| d.get_mut("items")) + .and_then(|i| i.as_array_mut()) + else { + return false; + }; + let mut repaired = false; + for item in items.iter_mut() { + if item.get("conversation_engine").and_then(Value::as_str) == Some("homeassistant") { + item["conversation_engine"] = json!("conversation.home_assistant"); + repaired = true; + } + } + if repaired { + info!("pine/HA seed: repaired legacy conversation agent id in Assist pipelines"); + return write_store(&path, &store).await; + } + return false; + } + + // ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness. + let id: String = { + const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; + let raw: [u8; 26] = rand::random(); + raw.iter() + .map(|b| ALPHABET[(*b % 32) as usize] as char) + .collect() + }; + let store = json!({ + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": { + "items": [{ + "conversation_engine": "conversation.home_assistant", + "conversation_language": "en", + "id": id, + "language": "en", + "name": "Pine (local)", + "stt_engine": "stt.faster_whisper", + "stt_language": "en", + "tts_engine": "tts.piper", + "tts_language": "en_GB", + "tts_voice": null, + "wake_word_entity": null, + "wake_word_id": null + }], + "preferred_item": id + } + }); + if write_store(&path, &store).await { + info!("pine/HA seed: created default Assist pipeline (whisper + piper)"); + return true; + } + false +} + +/// True when the Pine Wyoming engines are installed on this node (their shared +/// data dir is created by the stack install). +pub(super) async fn pine_engines_installed() -> bool { + tokio::fs::metadata("/var/lib/archipelago/pine") + .await + .is_ok() +} + +/// True when Home Assistant has a config dir on this node (installed at some +/// point; .storage may not exist until first boot, which seeding handles). +pub(super) async fn home_assistant_installed() -> bool { + tokio::fs::metadata("/var/lib/archipelago/home-assistant") + .await + .is_ok() +} + +/// Restart the HA container so it loads the seeded storage. Best-effort: when +/// the container doesn't exist (not yet created) the next start picks the +/// seeds up anyway. +pub(super) async fn restart_home_assistant_if_running() { + let running = tokio::process::Command::new("podman") + .args([ + "ps", + "--format", + "{{.Names}}", + "--filter", + "name=homeassistant", + ]) + .output() + .await + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .any(|l| l.trim() == "homeassistant") + }) + .unwrap_or(false); + if !running { + return; + } + match tokio::process::Command::new("podman") + .args(["restart", "homeassistant"]) + .output() + .await + { + Ok(o) if o.status.success() => info!("pine/HA seed: restarted homeassistant"), + Ok(o) => warn!( + "pine/HA seed: podman restart homeassistant failed: {}", + String::from_utf8_lossy(&o.stderr) + ), + Err(e) => warn!("pine/HA seed: podman restart homeassistant failed: {e}"), + } +} + +async fn read_store(path: &std::path::Path) -> Option { + let raw = tokio::fs::read_to_string(path).await.ok()?; + match serde_json::from_str(&raw) { + Ok(v) => Some(v), + Err(e) => { + warn!( + "pine/HA seed: {} is not valid JSON ({}), leaving untouched", + path.display(), + e + ); + None + } + } +} + +async fn write_store(path: &std::path::Path, store: &Value) -> bool { + let pretty = match serde_json::to_string_pretty(store) { + Ok(s) => s, + Err(e) => { + warn!("pine/HA seed: serialize {} failed: {}", path.display(), e); + return false; + } + }; + // Write via temp + rename so HA never reads a half-written store. + let tmp = path.with_extension("tmp-seed"); + if let Err(e) = tokio::fs::write(&tmp, pretty).await { + warn!("pine/HA seed: write {} failed: {}", tmp.display(), e); + return false; + } + if let Err(e) = tokio::fs::rename(&tmp, path).await { + warn!("pine/HA seed: rename into {} failed: {}", path.display(), e); + return false; + } + true +} diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index f3ef4e8a..034b96ca 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -1934,12 +1934,14 @@ impl RpcHandler { if let Some(orchestrated) = install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await? { + Self::seed_pine_ha_defaults().await; return Ok(orchestrated); } if let Some(adopted) = adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await? { + Self::seed_pine_ha_defaults().await; return Ok(adopted); } @@ -1947,6 +1949,17 @@ impl RpcHandler { "pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml" ) } + + /// After a Pine install, wire Home Assistant to the new engines when HA is + /// on this node (the HA post-install hook covers the reverse order). + async fn seed_pine_ha_defaults() { + if !super::pine_ha::home_assistant_installed().await { + return; + } + if super::pine_ha::seed_home_assistant_pine_defaults().await { + super::pine_ha::restart_home_assistant_if_running().await; + } + } } #[cfg(test)]