fix(pine): HA 2026.7.3 satellite keepalive + out-of-box voice seeding + one-click display modes #101

Merged
lfg2025 merged 3 commits from fix/pine-ha-satellite-keepalive into main 2026-07-21 21:39:05 +00:00
10 changed files with 3021 additions and 2696 deletions

View File

@ -1,5 +1,5 @@
# Home Assistant - uses official image
FROM homeassistant/home-assistant:2024.1
FROM homeassistant/home-assistant:2026.7.3
# Default configuration is in the image
# No additional setup needed

View File

@ -1,11 +1,11 @@
app:
id: homeassistant
name: Home Assistant
version: 2024.1.0
version: 2026.7.3
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present
network: pasta

View File

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

View File

@ -3,6 +3,7 @@ mod config;
mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
mod progress;
mod runtime;
mod set_config;

View 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
}

View File

@ -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)]

View File

@ -546,35 +546,14 @@ onBeforeUnmount(() => {
opacity: 0.5;
}
/* Mode dropdown */
.mode-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
text-align: left;
}
.mode-option:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
}
.mode-option-active {
/* Active display-mode button in the header bar */
.app-session-btn-active {
color: #fb923c;
background: rgba(251, 146, 60, 0.08);
background: rgba(251, 146, 60, 0.12);
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
.app-session-btn-active:hover {
color: #fb923c;
background: rgba(251, 146, 60, 0.18);
}
.content-fade-enter-active,

View File

@ -22,63 +22,41 @@
</svg>
</button>
<!-- Display mode selector -->
<div class="relative" ref="modeMenuRef">
<!-- Display mode: one-click switch -->
<div class="flex items-center gap-0.5">
<button
class="app-session-btn"
aria-label="Display mode"
title="Display mode"
@click="showModeMenu = !showModeMenu"
:class="{ 'app-session-btn-active': displayMode === 'panel' }"
aria-label="Right panel"
title="Right panel"
@click="$emit('setMode', 'panel')"
>
<!-- Panel icon -->
<svg v-if="displayMode === 'panel'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<!-- Overlay icon -->
<svg v-else-if="displayMode === 'overlay'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'overlay' }"
aria-label="Over whole app"
title="Over whole app"
@click="$emit('setMode', 'overlay')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<!-- Fullscreen icon -->
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</button>
<button
class="app-session-btn"
:class="{ 'app-session-btn-active': displayMode === 'fullscreen' }"
aria-label="Open fullscreen"
title="Open fullscreen"
@click="$emit('setMode', 'fullscreen')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="menu-fade">
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'panel' }"
@click="selectMode('panel')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<span>Right panel</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'overlay' }"
@click="selectMode('overlay')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<span>Over whole app</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
@click="selectMode('fullscreen')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
<span>Open fullscreen</span>
</button>
</div>
</Transition>
</div>
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="$emit('openNewTab')">
@ -98,7 +76,6 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { DisplayMode } from './appSessionConfig'
defineProps<{
@ -107,7 +84,7 @@ defineProps<{
displayMode: DisplayMode
}>()
const emit = defineEmits<{
defineEmits<{
goBack: []
goForward: []
refresh: []
@ -115,26 +92,4 @@ const emit = defineEmits<{
close: []
setMode: [mode: DisplayMode]
}>()
const showModeMenu = ref(false)
const modeMenuRef = ref<HTMLElement | null>(null)
function selectMode(mode: DisplayMode) {
showModeMenu.value = false
emit('setMode', mode)
}
function onClickOutside(e: MouseEvent) {
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
showModeMenu.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
</script>

File diff suppressed because one or more lines are too long

View File

@ -31,7 +31,7 @@ POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
# Apps
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2024.1"
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.7.3"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"