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 <noreply@anthropic.com>
This commit is contained in:
parent
9d5e96af19
commit
14965b769d
@ -1563,6 +1563,15 @@ autopilot.active=false\n",
|
|||||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||||
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
|
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
|
||||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
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" {
|
if package_id == "filebrowser" {
|
||||||
// Generate a random password (32 bytes, hex-encoded)
|
// Generate a random password (32 bytes, hex-encoded)
|
||||||
let mut buf = [0u8; 32];
|
let mut buf = [0u8; 32];
|
||||||
|
|||||||
@ -3,6 +3,7 @@ mod config;
|
|||||||
mod dependencies;
|
mod dependencies;
|
||||||
mod install;
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod pine_ha;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod set_config;
|
mod set_config;
|
||||||
|
|||||||
370
core/archipelago/src/api/rpc/package/pine_ha.rs
Normal file
370
core/archipelago/src/api/rpc/package/pine_ha.rs
Normal file
@ -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<Value> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@ -1934,12 +1934,14 @@ impl RpcHandler {
|
|||||||
if let Some(orchestrated) =
|
if let Some(orchestrated) =
|
||||||
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
||||||
{
|
{
|
||||||
|
Self::seed_pine_ha_defaults().await;
|
||||||
return Ok(orchestrated);
|
return Ok(orchestrated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(adopted) =
|
if let Some(adopted) =
|
||||||
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
||||||
{
|
{
|
||||||
|
Self::seed_pine_ha_defaults().await;
|
||||||
return Ok(adopted);
|
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"
|
"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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user