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).
|
||||
/// 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];
|
||||
|
||||
@ -3,6 +3,7 @@ mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
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) =
|
||||
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)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user