feat(pine): node-status endpoint + Claude voice brain + mesh announcements + wake-word groundwork

- 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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 02:59:34 -04:00
parent b288be314c
commit e074a117d2
12 changed files with 763 additions and 103 deletions

View File

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

View File

@ -27,6 +27,7 @@ mod nostr;
mod openwrt;
mod package;
mod peers;
mod pine_status;
mod response;
mod router;
mod security;

View File

@ -497,7 +497,12 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"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(),

View File

@ -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",

View File

@ -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<String> {
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 {
let sentences_path = sentences_dir.join("archy.yaml");
let sentences_changed = tokio::fs::read_to_string(&sentences_path)
.await
.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;
}
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 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:"));
}
}

View File

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

View File

@ -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 <pine-status-token>`): 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("", ""));
}
}

View File

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

View File

@ -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<bool> {
&& !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<bool> {
&& !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<bool> {
}
}
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.

View File

@ -59,6 +59,7 @@ impl DockerPackageScanner {
"netbird-dashboard",
"pine-whisper",
"pine-piper",
"pine-openwakeword",
"buildx_buildkit_default",
];

View File

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

View File

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