From e074a117d2e6eef61ba07b9c934704d67cdaf3fa Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 02:59:34 -0400 Subject: [PATCH] feat(pine): node-status endpoint + Claude voice brain + mesh announcements + wake-word groundwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/pine/status: public tier (version/uptime/bitcoin/mesh facts) for the Pine page; bearer-token tier (Lightning balances, latest mesh message) for the seeded Home Assistant sensors. Token minted 0600 at secrets/pine-status-token; nginx location ships via the bootstrap self-heal + canonical ISO conf. Replaces the per-node socat forwarder and bitcoind RPC credentials living in HA's configuration.yaml. - pine_ha seeder: REST sensors + four ask-Archy intents (block height, peers, sync %, Lightning balance) with LLM tool descriptions; Claude conversation agent (anthropic entry from the node's claude-api-key) set as pipeline default with prefer_local_intents so exact phrases stay local and fuzzy ones tool-route through Claude; mesh-message announce automation on every Assist satellite; bounded config markers with legacy-block migration. - pine-openwakeword joins the pine stack (all lifecycle sites) — on-node wake-word engine groundwork for the custom "Yo Archy" model. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/handler/mod.rs | 20 + core/archipelago/src/api/rpc/mod.rs | 1 + .../archipelago/src/api/rpc/package/config.rs | 7 +- .../src/api/rpc/package/dependencies.rs | 3 +- .../src/api/rpc/package/pine_ha.rs | 605 +++++++++++++++--- .../archipelago/src/api/rpc/package/stacks.rs | 10 +- core/archipelago/src/api/rpc/pine_status.rs | 156 +++++ core/archipelago/src/app_ops.rs | 2 +- core/archipelago/src/bootstrap.rs | 25 + .../src/container/docker_packages.rs | 1 + core/archipelago/src/crash_recovery.rs | 4 +- image-recipe/configs/nginx-archipelago.conf | 32 + 12 files changed, 763 insertions(+), 103 deletions(-) create mode 100644 core/archipelago/src/api/rpc/pine_status.rs diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 42c8f5c6..3d5afcbb 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -580,6 +580,26 @@ impl ApiHandler { self.handle_app_catalog_proxy().await } + // Pine node status — public tier (version/uptime/height/sync/peer + // counts) is unauthenticated like /bitcoin-status; Lightning + // balances + latest mesh message additionally require the bearer + // token the pine/HA seeder minted (or a valid session). + (Method::GET, "/api/pine/status") => { + let bearer = headers + .get(hyper::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + let authorized = self.rpc_handler.pine_status_token_ok(bearer).await + || self.is_authenticated(&headers).await; + let body = self.rpc_handler.pine_status_json(authorized).await; + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + // LND connect info — nginx validates session cookie (presence check), // backend is bound to 127.0.0.1 so only nginx can reach it. // No backend auth check here because the LND UI iframe fetches this diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 0ec4b4b2..dd84ab80 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -27,6 +27,7 @@ mod nostr; mod openwrt; mod package; mod peers; +mod pine_status; mod response; mod router; mod security; diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 87785a21..aec9b4c1 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -497,7 +497,12 @@ pub(super) fn all_container_names(package_id: &str) -> Vec { "netbird-server".into(), ], // Pine voice-assistant stack: launcher + the two Wyoming engines. - "pine" => vec!["pine".into(), "pine-whisper".into(), "pine-piper".into()], + "pine" => vec![ + "pine".into(), + "pine-whisper".into(), + "pine-piper".into(), + "pine-openwakeword".into(), + ], "nostr-vpn" => vec![ "nostr-vpn".into(), "archy-nostr-vpn".into(), diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index dae8e601..f6f1a24f 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -598,6 +598,7 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool { | "pine" | "pine-whisper" | "pine-piper" + | "pine-openwakeword" ) } @@ -629,7 +630,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] { &["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"] } "netbird" => &["netbird-server", "netbird-dashboard", "netbird"], - "pine" => &["pine-whisper", "pine-piper", "pine"], + "pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"], "penpot" | "penpot-frontend" => &[ "penpot-postgres", "penpot-valkey", diff --git a/core/archipelago/src/api/rpc/package/pine_ha.rs b/core/archipelago/src/api/rpc/package/pine_ha.rs index 3ee6ffe5..0d2f4daf 100644 --- a/core/archipelago/src/api/rpc/package/pine_ha.rs +++ b/core/archipelago/src/api/rpc/package/pine_ha.rs @@ -1,12 +1,21 @@ //! 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. +//! The Pine app ships the Wyoming engines (pine-whisper :10300, pine-piper +//! :10200, pine-openwakeword :10400) 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 entities. Out of the box that +//! meant clicking through HA's integration UI and building a pipeline by +//! hand — so seed all of it directly into HA's `.storage` when both apps are +//! present: +//! +//! - a `wyoming` config entry per engine, +//! - an Assist pipeline wired to whisper + piper, +//! - "ask Archy" voice intents backed by REST sensors on the node's +//! `/api/pine/status` endpoint (block height, peers, sync, balances), +//! - a Claude conversation agent (HA's `anthropic` integration) using the +//! node's shared `secrets/claude-api-key`, set as the pipeline's brain with +//! `prefer_local_intents: true` so node questions stay local/free, +//! - an automation announcing new mesh messages on any Assist satellite. //! //! 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 @@ -15,17 +24,23 @@ use serde_json::{json, Value}; use tracing::{info, warn}; +const HA_CONFIG_DIR: &str = "/var/lib/archipelago/home-assistant"; const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage"; +const NODE_SECRETS_DIR: &str = "/var/lib/archipelago/secrets"; -/// The two Wyoming engines the Pine stack publishes on the host. +/// The 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] = [ +const PINE_ENGINES: [(&str, &str, u16); 3] = [ ("faster-whisper", "host.containers.internal", 10300), ("piper", "host.containers.internal", 10200), + ("openwakeword", "host.containers.internal", 10400), ]; -/// 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 +/// Entity id the Anthropic conversation subentry produces (device name +/// "Claude conversation" -> slug). Deterministic on a fresh registry. +const CLAUDE_CONVERSATION_ENTITY: &str = "conversation.claude_conversation"; + +/// Seed Home Assistant with the Pine voice defaults. 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. @@ -39,34 +54,202 @@ pub(super) async fn seed_home_assistant_pine_defaults() -> bool { } let entries_changed = seed_wyoming_config_entries(storage).await; - let pipeline_changed = seed_assist_pipeline(storage).await; + let claude = seed_claude_conversation(storage).await; + let pipeline_changed = seed_assist_pipeline( + storage, + claude.available.then_some(CLAUDE_CONVERSATION_ENTITY), + ) + .await; let intents_changed = seed_voice_intents().await; - entries_changed || pipeline_changed || intents_changed + let automation_changed = seed_mesh_announce_automation().await; + entries_changed || claude.changed || pipeline_changed || intents_changed || automation_changed } -/// Marker guarding the seeded block in configuration.yaml (idempotence). -const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---"; +// --------------------------------------------------------------------------- +// Voice intents + REST sensors (configuration.yaml + custom_sentences) +// --------------------------------------------------------------------------- -/// 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. +/// Markers bounding the seeded block in configuration.yaml. The begin marker +/// doubles as the legacy marker (early seeds had no end marker and always sat +/// at EOF, so legacy upgrade = replace from begin marker to end of file). +const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---"; +const VOICE_END_MARKER: &str = "# --- archipelago pine voice (end) ---"; + +/// Read the pine status token (minting it on first use, 0600). The same +/// token gates the sensitive tier of `/api/pine/status` (see +/// `api::rpc::pine_status`), so seeding it into HA's REST sensor config is +/// what lets HA — and only HA — read balances and mesh text. +async fn ensure_status_token() -> Option { + let dir = std::path::Path::new(NODE_SECRETS_DIR); + let path = dir.join(crate::api::rpc::pine_status::PINE_STATUS_TOKEN_FILE); + if let Ok(existing) = tokio::fs::read_to_string(&path).await { + let existing = existing.trim().to_string(); + if !existing.is_empty() { + return Some(existing); + } + } + if let Err(e) = tokio::fs::create_dir_all(dir).await { + warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e); + return None; + } + let raw: [u8; 32] = rand::random(); + let token = hex::encode(raw); + if let Err(e) = tokio::fs::write(&path, &token).await { + warn!("pine/HA seed: writing status token failed: {e}"); + return None; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + info!("pine/HA seed: minted pine status token"); + Some(token) +} + +/// The seeded configuration.yaml block: REST sensors polling the node's +/// status endpoint (through nginx on :80) + intent_script answers. +fn voice_config_block(token: &str) -> String { + format!( + r#"{VOICE_MARKER} +rest: + - resource: http://host.containers.internal/api/pine/status + headers: + Authorization: "Bearer {token}" + scan_interval: 30 + sensor: + - name: "Archy Block Height" + unique_id: archy_block_height + value_template: "{{{{ value_json.bitcoin.height }}}}" + - name: "Archy Bitcoin Sync" + unique_id: archy_bitcoin_sync + unit_of_measurement: "%" + value_template: "{{{{ value_json.bitcoin.sync_percent }}}}" + - name: "Archy Bitcoin Peers" + unique_id: archy_bitcoin_peers + value_template: "{{{{ value_json.bitcoin.peers }}}}" + - name: "Archy Mesh Peers" + unique_id: archy_mesh_peers + value_template: "{{{{ value_json.mesh.peers }}}}" + - name: "Archy Lightning Balance" + unique_id: archy_lightning_balance + unit_of_measurement: "sats" + value_template: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') }}}}" + - name: "Archy Onchain Balance" + unique_id: archy_onchain_balance + unit_of_measurement: "sats" + value_template: "{{{{ (value_json.lightning or {{}}).get('balance_sats') }}}}" + - name: "Archy Mesh Message" + unique_id: archy_mesh_message + value_template: "{{{{ (value_json.mesh_message or {{}}).get('id') }}}}" + json_attributes_path: "$.mesh_message" + json_attributes: + - from + - text + - timestamp +intent_script: + ArchyBlockHeight: + description: >- + Get the current Bitcoin block height of this node. Use for any question + about the block height, chain tip, or number of blocks. + speech: + text: >- + {{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable', 'None'] %}} + The block height is {{{{ states('sensor.archy_block_height') }}}}. + {{% else %}} + I can't read the block height right now. + {{% endif %}} + ArchyPeers: + description: >- + Get how many peers this node is connected to (bitcoin peers and mesh + radio peers). Use for any question about peer or connection counts. + speech: + text: >- + {{% set btc = states('sensor.archy_bitcoin_peers') %}} + {{% set mesh = states('sensor.archy_mesh_peers') %}} + {{% if btc not in ['unknown', 'unavailable', 'None'] %}} + The node has {{{{ btc }}}} bitcoin peers{{% if mesh not in ['unknown', 'unavailable', 'None'] and mesh | int(0) > 0 %}} and {{{{ mesh }}}} mesh peers{{% endif %}}. + {{% else %}} + I can't read the peer count right now. + {{% endif %}} + ArchySyncStatus: + description: >- + Get this node's Bitcoin sync status / progress percentage. Use for any + question about whether the node or bitcoin is synced, syncing, or up to + date. + speech: + text: >- + {{% set pct = states('sensor.archy_bitcoin_sync') %}} + {{% if pct in ['unknown', 'unavailable', 'None'] %}} + I can't read the sync status right now. + {{% elif pct | float(0) >= 99.99 %}} + The node is fully synced at block {{{{ states('sensor.archy_block_height') }}}}. + {{% else %}} + Bitcoin is {{{{ pct }}}} percent synced. + {{% endif %}} + ArchyLightningBalance: + description: >- + Get the Lightning wallet balance of this node in sats. Use for any + question about the lightning balance, wallet balance, or how many sats + are available. + speech: + text: >- + {{% set ln = states('sensor.archy_lightning_balance') %}} + {{% if ln not in ['unknown', 'unavailable', 'None'] %}} + Your Lightning balance is {{{{ ln }}}} sats. + {{% else %}} + I can't read the Lightning balance right now. Is Lightning set up on this node? + {{% endif %}} +{VOICE_END_MARKER} +"# + ) +} + +const VOICE_SENTENCES: &str = 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]" + ArchyPeers: + data: + - sentences: + - "how many peers [do I have]" + - "how many peers (is|are) [the node] connected to" + - "[node] peer count" + ArchySyncStatus: + data: + - sentences: + - "is (the node|bitcoin) [fully] synced" + - "[bitcoin] sync (status|progress|percent|percentage)" + - "how synced is (the node|bitcoin)" + ArchyLightningBalance: + data: + - sentences: + - "what's my lightning balance" + - "what is my lightning balance" + - "lightning balance" + - "how many sats (do I have|are in my wallet)" +"#; + +/// Seed "ask Archy" node-info voice intents: custom sentences + a bounded +/// intent_script/rest block in configuration.yaml. Upgrades earlier seeded +/// blocks in place (including the pre-endpoint legacy block that ended at +/// EOF); skips cleanly when the user defines intent_script/rest themselves +/// outside our markers. async fn seed_voice_intents() -> bool { - let config_dir = std::path::Path::new("/var/lib/archipelago/home-assistant"); + let config_dir = std::path::Path::new(HA_CONFIG_DIR); 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) { + let Some(token) = ensure_status_token().await else { 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; - } + }; + let desired_block = voice_config_block(&token); - // Sentences file (its own dir, no key-collision risk). + // Sentences file (its own dir, no key-collision risk) — keep in sync. let sentences_dir = config_dir.join("custom_sentences/en"); if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await { warn!( @@ -76,55 +259,52 @@ async fn seed_voice_intents() -> bool { ); 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") + let sentences_path = sentences_dir.join("archy.yaml"); + let sentences_changed = tokio::fs::read_to_string(&sentences_path) .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 }}" -"#, - ); + .map(|cur| cur != VOICE_SENTENCES) + .unwrap_or(true); + if sentences_changed { + if let Err(e) = tokio::fs::write(&sentences_path, VOICE_SENTENCES).await { + warn!("pine/HA seed: writing custom sentences failed: {e}"); + return false; + } } - 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 existing = tokio::fs::read_to_string(&config_yaml) + .await + .unwrap_or_default(); + + let merged = if let Some(begin) = existing.find(VOICE_MARKER) { + if let Some(end) = existing.find(VOICE_END_MARKER) { + // Bounded block — replace when stale. + let after = end + VOICE_END_MARKER.len(); + // Consume the trailing newline of the old block, if present. + let after = after + existing[after..].starts_with('\n') as usize; + let current = &existing[begin..after]; + if current == desired_block { + return sentences_changed; + } + format!( + "{}{}{}", + &existing[..begin], + desired_block, + &existing[after..] + ) + } else { + // Legacy block (no end marker) — it was always appended at EOF, + // possibly hand-edited on the node (e.g. the interim bitcoind + // socat sensor). Replace marker..EOF wholesale. + format!("{}{}", &existing[..begin], desired_block) + } + } else { + if existing.contains("intent_script:") || existing.contains("\nrest:") { + warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents"); + return sentences_changed; + } + format!("{existing}\n{desired_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}"); @@ -134,17 +314,190 @@ intents: 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" - } - ); + info!("pine/HA seed: voice intents + node-status sensors seeded"); true } +// --------------------------------------------------------------------------- +// Mesh-message announcements (automations.yaml) +// --------------------------------------------------------------------------- + +const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce"; + +/// Announce new mesh messages on every Assist satellite. Only seeded into an +/// empty/missing automations.yaml — merging into user-authored YAML risks +/// mangling it, and anyone with existing automations can paste this one from +/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id). +const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce + alias: Announce mesh messages on Pine + description: Speak new mesh messages on the Pine speaker (seeded by Archipelago) + triggers: + - trigger: state + entity_id: sensor.archy_mesh_message + conditions: + - condition: template + value_template: >- + {{ trigger.from_state is not none + and trigger.from_state.state not in ['unknown', 'unavailable', 'None'] + and trigger.to_state.state not in ['unknown', 'unavailable', 'None'] + and trigger.from_state.state != trigger.to_state.state }} + actions: + - variables: + satellites: "{{ states.assist_satellite | map(attribute='entity_id') | list }}" + - condition: template + value_template: "{{ satellites | count > 0 }}" + - action: assist_satellite.announce + target: + entity_id: "{{ satellites }}" + data: + message: >- + New mesh message from {{ state_attr('sensor.archy_mesh_message', 'from') or 'unknown' }}: + {{ state_attr('sensor.archy_mesh_message', 'text') or '' }} + mode: queued + max: 5 +"#; + +async fn seed_mesh_announce_automation() -> bool { + let path = std::path::Path::new(HA_CONFIG_DIR).join("automations.yaml"); + let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default(); + if existing.contains(MESH_ANNOUNCE_AUTOMATION_ID) { + return false; + } + let trimmed = existing.trim(); + if !(trimmed.is_empty() || trimmed == "[]") { + warn!("pine/HA seed: automations.yaml has user content — skipping mesh announce seed"); + return false; + } + if let Err(e) = tokio::fs::write(&path, MESH_ANNOUNCE_AUTOMATION).await { + warn!("pine/HA seed: writing automations.yaml failed: {e}"); + return false; + } + info!("pine/HA seed: mesh-message announce automation seeded"); + true +} + +// --------------------------------------------------------------------------- +// Claude conversation agent (anthropic config entry) +// --------------------------------------------------------------------------- + +struct ClaudeSeed { + /// An anthropic entry exists (seeded now or already there) — the + /// pipeline may point at the Claude conversation entity. + available: bool, + /// This call wrote the store. + changed: bool, +} + +/// Seed HA's `anthropic` integration from the node's shared Claude API key +/// (`secrets/claude-api-key`, the same key the mesh `!ai` assistant uses). +/// Skips when the key is absent or an anthropic entry already exists. +async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed { + let none = ClaudeSeed { + available: false, + changed: false, + }; + let key = match tokio::fs::read_to_string( + std::path::Path::new(NODE_SECRETS_DIR).join("claude-api-key"), + ) + .await + { + Ok(k) if !k.trim().is_empty() => k.trim().to_string(), + _ => { + info!("pine/HA seed: no claude-api-key on node — skipping Claude conversation agent"); + return none; + } + }; + + 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, + None => return none, + } + } 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 none; + }; + + if entries + .iter() + .any(|e| e.get("domain").and_then(Value::as_str) == Some("anthropic")) + { + return ClaudeSeed { + available: true, + changed: false, + }; + } + + let id = |raw: [u8; 16]| hex::encode(raw); + // Shape mirrors what HA 2026.7's anthropic config flow creates (entry + // version 2.4 with conversation + ai_task subentries); HA fills any + // missing bookkeeping fields on load. + entries.push(json!({ + "entry_id": id(rand::random()), + "version": 2, + "minor_version": 4, + "domain": "anthropic", + "title": "Claude", + "data": { "api_key": key }, + "options": {}, + "pref_disable_new_entities": false, + "pref_disable_polling": false, + "source": "user", + "unique_id": null, + "disabled_by": null, + "subentries": [ + { + "subentry_id": id(rand::random()), + "subentry_type": "conversation", + "title": "Claude conversation", + "unique_id": null, + "data": { + "recommended": true, + "llm_hass_api": ["assist"], + // Steers fuzzy phrasings onto the local Archy* intent + // tools (cheap + exact) instead of free-form answers, + // and keeps replies speaker-length. + "prompt": "You are Archy, the voice of this Archipelago Bitcoin node, speaking through a smart speaker. Answers are spoken aloud: keep them to one or two short sentences, no markdown, no lists. When the user asks about the node — block height, sync status, peers, balances — call the matching Archy tool rather than answering from memory, even if the phrasing is loose. Only answer directly when no tool fits." + } + }, + { + "subentry_id": id(rand::random()), + "subentry_type": "ai_task_data", + "title": "Claude AI Task", + "unique_id": null, + "data": { "recommended": true } + } + ] + })); + + if !write_store(&path, &store).await { + return none; + } + info!("pine/HA seed: added Claude conversation agent (anthropic config entry)"); + ClaudeSeed { + available: true, + changed: true, + } +} + +// --------------------------------------------------------------------------- +// Wyoming config entries +// --------------------------------------------------------------------------- + /// 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"); @@ -214,11 +567,17 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool { added } +// --------------------------------------------------------------------------- +// Assist pipeline +// --------------------------------------------------------------------------- + /// 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 { +/// missing; when it exists, repairs the pre-2024.6 conversation-agent id +/// (`homeassistant` -> `conversation.home_assistant`) and — when a Claude +/// agent is available — upgrades pipelines still on the default local agent +/// to Claude with `prefer_local_intents: true` (node intents stay local/free, +/// everything else goes to Claude). +async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&str>) -> bool { let path = storage.join("assist_pipeline.pipelines"); if let Some(mut store) = read_store(&path).await { @@ -229,15 +588,25 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool { else { return false; }; - let mut repaired = false; + let mut changed = 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; + info!("pine/HA seed: repaired legacy conversation agent id in Assist pipeline"); + changed = true; + } + if let Some(engine) = claude_entity { + if item.get("conversation_engine").and_then(Value::as_str) + == Some("conversation.home_assistant") + { + item["conversation_engine"] = json!(engine); + item["prefer_local_intents"] = json!(true); + info!("pine/HA seed: pipeline upgraded to Claude (local intents preferred)"); + changed = true; + } } } - if repaired { - info!("pine/HA seed: repaired legacy conversation agent id in Assist pipelines"); + if changed { return write_store(&path, &store).await; } return false; @@ -257,11 +626,12 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool { "key": "assist_pipeline.pipelines", "data": { "items": [{ - "conversation_engine": "conversation.home_assistant", + "conversation_engine": claude_entity.unwrap_or("conversation.home_assistant"), "conversation_language": "en", "id": id, "language": "en", "name": "Pine (local)", + "prefer_local_intents": claude_entity.is_some(), "stt_engine": "stt.faster_whisper", "stt_language": "en", "tts_engine": "tts.piper", @@ -280,6 +650,10 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool { false } +// --------------------------------------------------------------------------- +// Presence probes + HA restart +// --------------------------------------------------------------------------- + /// 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 { @@ -291,9 +665,7 @@ pub(super) async fn pine_engines_installed() -> bool { /// 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() + tokio::fs::metadata(HA_CONFIG_DIR).await.is_ok() } /// Restart the HA container so it loads the seeded storage. Best-effort: when @@ -368,3 +740,44 @@ async fn write_store(path: &std::path::Path, store: &Value) -> bool { } true } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voice_block_is_bounded_and_carries_token() { + let block = voice_config_block("deadbeef"); + assert!(block.starts_with(VOICE_MARKER)); + assert!(block.trim_end().ends_with(VOICE_END_MARKER)); + assert!(block.contains("Bearer deadbeef")); + // Every intent the sentences file declares has a script answer. + for intent in [ + "ArchyBlockHeight", + "ArchyPeers", + "ArchySyncStatus", + "ArchyLightningBalance", + ] { + assert!(block.contains(intent), "missing intent {intent}"); + assert!( + VOICE_SENTENCES.contains(intent), + "missing sentences {intent}" + ); + } + } + + #[test] + fn legacy_block_replacement_drops_old_content() { + // Simulates the pre-endpoint node state: legacy marker + hand-edited + // socat/bitcoind sensor block at EOF. + let existing = format!( + "default_config:\n\n{VOICE_MARKER}\nrest:\n - resource: http://host.containers.internal:18332/\n username: archipelago\n" + ); + let begin = existing.find(VOICE_MARKER).unwrap(); + let desired = voice_config_block("tok"); + let merged = format!("{}{}", &existing[..begin], desired); + assert!(!merged.contains("18332")); + assert!(merged.contains("Bearer tok")); + assert!(merged.starts_with("default_config:")); + } +} diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index 034b96ca..14995069 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -750,7 +750,7 @@ fn pine_stack_app_ids() -> &'static [&'static str] { // then the user-facing launcher ("pine", the nginx that serves the setup / // status page + is the Open target). Mirrors the pine startup_order in // dependencies.rs + the stack member table in app_ops.rs. - &["pine-whisper", "pine-piper", "pine"] + &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"] } fn indeedhub_stack_app_ids() -> &'static [&'static str] { @@ -1938,8 +1938,12 @@ impl RpcHandler { return Ok(orchestrated); } - if let Some(adopted) = - adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await? + if let Some(adopted) = adopt_stack_if_exists( + "pine", + "pine", + &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"], + ) + .await? { Self::seed_pine_ha_defaults().await; return Ok(adopted); diff --git a/core/archipelago/src/api/rpc/pine_status.rs b/core/archipelago/src/api/rpc/pine_status.rs new file mode 100644 index 00000000..5e923a93 --- /dev/null +++ b/core/archipelago/src/api/rpc/pine_status.rs @@ -0,0 +1,156 @@ +//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`). +//! +//! Two tiers in one endpoint: +//! - **Public** (no credentials): version, uptime, bitcoin height / sync / +//! peer count, mesh peer count. Feeds the Pine launcher page's live status +//! card — nothing here a LAN visitor couldn't already infer from the +//! existing unauthenticated `/bitcoin-status`. +//! - **Token** (`Authorization: Bearer `): adds Lightning +//! balances and the most recent received mesh text message. Feeds the +//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder +//! mints the token into `data_dir/secrets/pine-status-token` (0600) and +//! embeds it in HA's configuration, so only HA (and the node owner) can +//! read balances or message text. +//! +//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials- +//! in-configuration.yaml stopgap used before this endpoint existed. + +use super::RpcHandler; +use crate::mesh::types::MessageDirection; +use serde_json::{json, Value}; + +/// File under `data_dir/secrets/` holding the bearer token that unlocks the +/// sensitive tier. Written by the pine/HA seeder, read per-request here. +pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token"; + +/// Constant-time-ish equality — avoids early-exit timing on the token compare. +fn token_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +impl RpcHandler { + /// True when `presented` matches the on-disk pine status token. Missing or + /// empty token file means the sensitive tier is locked (seeder not run). + pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool { + let path = self + .config + .data_dir + .join("secrets") + .join(PINE_STATUS_TOKEN_FILE); + match tokio::fs::read_to_string(&path).await { + Ok(tok) => { + let tok = tok.trim(); + !tok.is_empty() && token_eq(tok, presented.trim()) + } + Err(_) => false, + } + } + + /// Assemble the status document. `authorized` selects the token tier. + /// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns + /// the endpoint into an error — the field just reports what it can. + pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value { + let bs = crate::bitcoin_status::get_bitcoin_status().await; + let bitcoin = { + let info = bs.blockchain_info.as_ref(); + let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64); + let progress = info + .and_then(|i| i.get("verificationprogress")) + .and_then(Value::as_f64); + let ibd = info + .and_then(|i| i.get("initialblockdownload")) + .and_then(Value::as_bool); + let peers = bs + .network_info + .as_ref() + .and_then(|n| n.get("connections")) + .and_then(Value::as_u64); + json!({ + "ok": bs.ok, + "height": height, + "sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0), + "ibd": ibd, + "peers": peers, + }) + }; + + let (mesh, mesh_message) = { + let guard = self.mesh_service.read().await; + match guard.as_ref() { + Some(svc) => { + let status = svc.status().await; + let latest = if authorized { + svc.messages(None) + .await + .iter() + .rev() + .find(|m| { + m.direction == MessageDirection::Received + && m.message_type == "text" + }) + .map(|m| { + json!({ + "id": m.id, + "from": m.peer_name.clone() + .unwrap_or_else(|| format!("contact {}", m.peer_contact_id)), + "text": m.plaintext, + "timestamp": m.timestamp, + }) + }) + } else { + None + }; + ( + json!({ "enabled": status.enabled, "peers": status.peer_count }), + latest, + ) + } + None => (json!({ "enabled": false, "peers": 0 }), None), + } + }; + + let lightning = if authorized { + match self.handle_lnd_getinfo().await { + Ok(info) => json!({ + "balance_sats": info.get("balance_sats"), + "channel_balance_sats": info.get("channel_balance_sats"), + "active_channels": info.get("num_active_channels"), + "synced_to_chain": info.get("synced_to_chain"), + }), + Err(_) => Value::Null, + } + } else { + Value::Null + }; + + json!({ + "ok": true, + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": crate::crash_recovery::uptime_seconds(), + "bitcoin": bitcoin, + "mesh": mesh, + "lightning": lightning, + "mesh_message": mesh_message, + }) + } +} + +#[cfg(test)] +mod tests { + use super::token_eq; + + #[test] + fn token_eq_matches_only_exact() { + assert!(token_eq("abc123", "abc123")); + assert!(!token_eq("abc123", "abc124")); + assert!(!token_eq("abc123", "abc12")); + assert!(!token_eq("", "x")); + assert!(token_eq("", "")); + } +} diff --git a/core/archipelago/src/app_ops.rs b/core/archipelago/src/app_ops.rs index 311fce8b..c036d863 100644 --- a/core/archipelago/src/app_ops.rs +++ b/core/archipelago/src/app_ops.rs @@ -50,7 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] { &["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"] } "netbird" => &["netbird-server", "netbird-dashboard", "netbird"], - "pine" => &["pine-whisper", "pine-piper", "pine"], + "pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"], // The legacy umbrella id maps to the split stack (the orchestrator's // umbrella alias handles this too; listing it here keeps the RPC // layer's fan-out explicit). diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 9c05e7c4..a6124991 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -80,6 +80,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles /// block in image-recipe/configs/nginx-archipelago.conf. const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n"; +/// Inserted into every server block lacking the Pine node-status proxy. +/// `/api/pine/status` serves the Pine launcher page's live status card and +/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a +/// bearer token at the backend, so nginx just forwards). Kept in sync with +/// the canonical block in image-recipe/configs/nginx-archipelago.conf. +const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n"; + /// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only /// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted /// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell @@ -854,6 +861,7 @@ async fn patch_nginx_conf(path: &str) -> Result { && !content.contains("location /bitcoin-status"); let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/"); let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content"); + let missing_pine_status = has_lnd_anchor && !content.contains("location /api/pine/status"); let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS); // B13: fedimint block present but lacking the asset-rewrite sub_filters. let needs_fedimint_css = content.contains("location /app/fedimint/") @@ -862,6 +870,7 @@ async fn patch_nginx_conf(path: &str) -> Result { && !missing_bitcoin_status && !missing_lnd_proxy && !missing_peer_content + && !missing_pine_status && !has_lnd_dup_cors && !needs_fedimint_css { @@ -907,6 +916,22 @@ async fn patch_nginx_conf(path: &str) -> Result { } } + if missing_pine_status { + // Same anchoring as the LND proxy: prepend to every server block that + // proxies to the backend. + let anchor = if patched.contains(" location /lnd-connect-info {") { + " location /lnd-connect-info {" + } else { + " location /electrs-status {" + }; + if patched.contains(anchor) { + let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor); + patched = patched.replace(anchor, &replacement); + } else { + warn!("nginx conf missing anchor — skipping /api/pine/status patch"); + } + } + if missing_peer_content { // Same anchoring as the LND proxy: prepend the block to every server // block so /api/peer-content/* reaches the backend instead of the SPA. diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 976d37eb..12bbf90c 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -59,6 +59,7 @@ impl DockerPackageScanner { "netbird-dashboard", "pine-whisper", "pine-piper", + "pine-openwakeword", "buildx_buildkit_default", ]; diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index 5f925dc8..0aaa9e7e 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -768,6 +768,7 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) | "netbird" | "pine-whisper" | "pine-piper" + | "pine-openwakeword" | "pine" ) } @@ -836,9 +837,10 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] { aliases: &[ ("pine-whisper", "pine-whisper"), ("pine-piper", "pine-piper"), + ("pine-openwakeword", "pine-openwakeword"), ("pine", "pine"), ], - containers: &["pine-whisper", "pine-piper", "pine"], + containers: &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"], // The launcher only depends on the two engines; whisper is the // heaviest/first member, so treat it as the stack anchor (its // presence means the stack was really installed, not orphan debris). diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 2471e875..ecc7b154 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -236,6 +236,22 @@ server { error_page 504 = @backend_timeout; } + # Pine node status — live node facts for the Pine launcher page and the + # seeded Home Assistant sensors. Sensitive fields are token-gated at the + # backend; nginx only forwards. + location /api/pine/status { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Cookie $http_cookie; + proxy_connect_timeout 10s; + proxy_read_timeout 15s; + proxy_send_timeout 5s; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + location /lnd-connect-info { proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1; @@ -1022,6 +1038,22 @@ server { error_page 504 = @backend_timeout; } + # Pine node status — live node facts for the Pine launcher page and the + # seeded Home Assistant sensors. Sensitive fields are token-gated at the + # backend; nginx only forwards. + location /api/pine/status { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Cookie $http_cookie; + proxy_connect_timeout 10s; + proxy_read_timeout 15s; + proxy_send_timeout 5s; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + location /lnd-connect-info { proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1;