Compare commits

...
Author SHA1 Message Date
archipelago 9823e76e8f chore: release v1.7.109-alpha 2026-07-21 07:50:10 -04:00
archipelagoandClaude Fable 5 8d4f6cb75e docs(release): third v1.7.109 bullet (Pine voice assistant) + version-bump prep
Demo images / Build & push demo images (push) Successful in 2m44s
The release gate requires ≥3 curated changelog bullets; the Pine app is
user-facing and deserved one anyway. Carries the version bumps and manifest
from the aborted cut so the tree is clean for the re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:34:26 -04:00
archipelagoandClaude Fable 5 4bac839fb3 test(ui): did-wallet launch port follows its manifest (8088)
Demo images / Build & push demo images (push) Successful in 2m49s
The pine catalog regeneration (c1dfc667) refreshed generatedAppSessionConfig
from the manifests, where did-wallet publishes host port 8088 — the test's
8083 expectation was stale. The test asserts manifest-generated ports are
used, so it must track the manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:59:47 -04:00
archipelagoandClaude Fable 5 ea80815c98 style: cargo fmt over the pine container list
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:55:19 -04:00
archipelagoandClaude Fable 5 4348581681 docs(release): curated v1.7.109-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m47s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:54:05 -04:00
archipelagoandClaude Fable 5 999a100531 feat(mesh): RF-params settings UI + expose lora_radio_params in mesh.status
Demo images / Build & push demo images (push) Has been cancelled
Device panel gets MeshCore RF fields (frequency MHz, bandwidth kHz, SF, CR)
wired to mesh.configure's validated lora_radio_params; shown only for
MeshCore radios, all-empty leaves the radio's flashed settings untouched.
Human units in the UI, firmware field units on the wire. Per operator
direction the panel's static stat rows duplicating editable fields (name,
region, channel) are dropped — the editable control is the display. Stale
'adjust via a MeshCore client' hints updated: the node programs the radio now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:53:10 -04:00
archipelagoandClaude Opus 4.8 a5810d4e51 release: publish signed app-catalog with the Pine voice-assistant app
Regenerate + sign releases/app-catalog.json (65 apps) with the pine,
pine-whisper and pine-piper manifests embedded, so nodes can install the Pine
stack straight from the signed registry (raw URL on main). Signed by the
release-root key did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 06:29:48 -04:00
archipelagoandClaude Fable 5 4c3cd4b6ad feat(mesh): operator-configurable Meshcore LoRa PHY params (freq/bw/sf/cr)
Archy set no radio params on Meshcore devices — SF/CR/BW/freq lived only in
device firmware, so a radio on the wrong preset could not be fixed from the
node (it hears RF energy but demodulates nothing; the fleet hit exactly this
on an RNode: docs/RETICULUM-TRANSPORT-PROGRESS.md item 4).

- protocol.rs: build_set_radio_params — wire format verified against the
  MeshCore companion firmware handler (CMD_SET_RADIO_PARAMS=11:
  [11][freq:u32 LE MHz*1000][bw:u32 LE kHz*1000][sf][cr]); byte-layout test
  pinned to the Portugal preset 869.618 MHz / 62.5 kHz / SF8 / CR8.
- serial.rs: MeshcoreDevice::set_radio_params (device reboots on OK).
- MeshConfig.lora_radio_params: Option — None (default) leaves the radio
  untouched, so only explicitly-configured deployments are affected.
- session.rs: provision on connect, gated on a persisted last-applied marker
  (SELF_INFO offsets shift across firmware versions) + the same attempt cap
  as region/channel so a refusing radio never reboot-loops.
- mesh.configure RPC: lora_radio_params arm with firmware-range validation.

mesh tests: 114/114 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:10:45 -04:00
archipelagoandClaude Opus 4.8 c1dfc6672c feat(pine): surface Pine in the app catalog + UI
Demo images / Build & push demo images (push) Successful in 2m48s
- catalog.json (app-catalog + neode-ui/public): one "pine" entry, home
  category; engines stay out of the catalog
- appsConfig.ts: pine → home category, pine-whisper/pine-piper → SERVICE_NAMES
  (render in Services, not as extra cards), pine-* icon fallback
- generatedAppSessionConfig.ts: regenerated (pine port 10380 + titles +
  open-in-new-tab), via scripts/generate-app-catalog.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelagoandClaude Opus 4.8 8791ef60f5 feat(pine): wire the Pine 3-container stack into the orchestrator
Register "pine" as a manifest-driven stack (like netbird) across every
lifecycle site so install/stop/start/restart/uninstall/crash-recovery treat
the launcher + two Wyoming engines as one app:

- stacks.rs: pine_stack_app_ids() + install_pine_stack() (orchestrator-first,
  adopt existing, else bail — no hardcoded fallback)
- install.rs: dispatch package_id == "pine" to the stack installer
- app_ops.rs: canonical stack-member table + owning_package STACKS
- dependencies.rs: startup_order + needs_archy_net
- config.rs: all_container_names
- crash_recovery.rs: auto-start members + a StackRecoverySpec (anchor
  pine-whisper) so a genuinely-installed stack self-heals but orphan debris
  does not crash-loop
- docker_packages.rs: exclude the engines from the apps list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelagoandClaude Opus 4.8 1730d02003 feat(pine): add Pine voice-assistant app package (manifests + icon)
Package the PineVoice/Wyoming local voice assistant as ONE store app "Pine"
that runs three rootless containers:

- pine-whisper — Wyoming faster-whisper STT (docker.io/rhasspy/wyoming-whisper
  3.4.1, model base-int8/en), publishes :10300
- pine-piper   — Wyoming Piper TTS (2.2.2, voice en_GB-alba-medium),
  publishes :10200
- pine         — nginx launcher serving a self-contained setup/status page
  (WiFi provisioning + Home Assistant wiring instructions), the Open target

The two engines publish their Wyoming ports so Home Assistant reaches them via
host.containers.internal. All rootless (cap-drop=ALL, no_new_privileges),
manifest-driven — no hardcoded podman/installer. The engines are internal stack
members (added to the drift-check INTERNAL_MANIFEST_IDS allowlist); only "pine"
carries a catalog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 05:14:32 -04:00
archipelago 146a3bc738 chore: release v1.7.108-alpha
Demo images / Build & push demo images (push) Successful in 2m48s
2026-07-21 03:42:21 -04:00
archipelagoandClaude Fable 5 302f880d99 docs(release): fold everything into v1.7.108-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m46s
Supersedes the unpublished v1.7.107. Covers the multi-anchor FIPS fix, WiFi
self-heal, mesh fast-rejoin, kiosk TV-scaling, kiosk dark theme / VT hints /
UTF-8 logo / power-button hardening, and the ISO build fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:59:23 -04:00
archipelagoandClaude Fable 5 217eedc093 fix(kiosk): correct VT hints, UTF-8 logo, ignore accidental power-off
Three fresh-install kiosk issues found on a Framework node:
- MOTD said 'Ctrl+Alt+F7 for kiosk' / 'F1 for terminal', but the kiosk's
  Xorg runs on vt1 — so F7 was an empty black VT (the reported black screen
  and the failed 'return to kiosk'). Corrected: kiosk=F1, terminal=F2.
- The UTF-8 block-glyph logo corrupted intermittently because returning from
  the kiosk can leave the console VT out of UTF-8 mode; force it with ESC%G.
- A short power-button press shut the node down instantly. Ignore the short
  press, keep long-press poweroff, so accidental brushes don't kill the node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:48:14 -04:00
archipelagoandClaude Fable 5 d79edb3a5c fix(ui): censor 'Fuck IPs Mesh' -> 'F*ck IPs Mesh'
Demo images / Build & push demo images (push) Successful in 2m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:42:19 -04:00
archipelagoandClaude Fable 5 0f13545375 fix(fips): ship a second, reachable default anchor (once-and-for-all)
Nodes depended on a single public anchor (185.18.221.160) that is
unreachable from most real networks — confirmed on the thinkpad WiFi and a
fresh Framework node, both of which islanded (is_root=true, depth=0). Its
DNS even resolves IPv6-first while the daemon is IPv4-only.

Stand up an Archipelago-operated FIPS anchor on vps2 (146.59.87.168, the
OTA host every node already reaches) and ship it as an additional default
anchor. default_public_anchor() -> default_public_anchors() -> Vec; load()
returns the set; the apply loop already dials each, so whichever the node's
network can reach wins. Verified end-to-end: a fresh node joined the tree
via vps2 in 3s (depth 1). anchors tests: 7/7 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:42:18 -04:00
archipelago 7eb4d6a7bf chore: release v1.7.107-alpha 2026-07-20 17:00:20 -04:00
archipelagoandClaude Fable 5 db008abdfd fix(kiosk): force dark color-scheme so bundled AIUI renders its dark theme
AIUI themes via @media (prefers-color-scheme) and falls back to its light
variant (white panels) when the browser reports no preference — which the
minimal kiosk X session does, so AIUI came up light instead of its designed
dark. The main UI hardcodes dark and is unaffected. Push two independent
dark signals on the kiosk Chromium: GTK_THEME=Adwaita:dark (version-
independent, can never force light) and --blink-settings=preferredColorScheme=0
(0 = kDark in modern Chromium). Needs on-device kiosk verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:57:46 -04:00
archipelagoandClaude Fable 5 35e9c62441 docs(release): curated v1.7.107-alpha changelog + What's New
Demo images / Build & push demo images (push) Successful in 2m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:39:48 -04:00
archipelagoandClaude Fable 5 cb301b1aef fix(wifi,fips): self-heal polkit rule + fast-retry anchors after daemon restart
Two fleet fixes that OTA can deliver (unlike the ISO-only polkit rule):

- WiFi (issue #99): nodes drive NetworkManager from a system-level service
  with no logind seat, so the stock polkit rule denies them and Wi-Fi setup
  fails with 'Insufficient privileges'. Fresh ISOs since 2026-05 ship the
  scoped rule, but OTA'd nodes never got it (OTA replaces binary + web UI,
  not host system config). Add a startup self-heal that installs the rule if
  missing and best-effort ensures polkitd — both wrapped so an offline/locked
  apt can never fail startup; the rule is written regardless so it activates
  once polkitd lands. Idempotent on the rule's unique subject.user marker.

- FIPS: regenerating fips.yaml (this build does, once, on first boot after
  the OTA) restarts the fips daemon; for a few seconds /run/fips/control.sock
  is gone and every seed-anchor dial fails, islanding the node until the next
  5-min tick. Detect that exact failure (all dials fail on control.sock) and
  retry in 15s instead — bounded to 8 fast retries/episode so a node with no
  fips daemon falls back to the steady cadence rather than busy-looping.

FIPS unit suite: 33/33 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:39:02 -04:00
archipelagoandClaude Fable 5 6a848c38cc fix(iso): extract nostr-rs-relay from its real path /usr/src/app
The relay image was rebuilt to the standard nostr-rs-relay layout — WORKDIR
/usr/src/app, execs ./nostr-rs-relay — but the build still cp'd from the old
/usr/local/bin path, so extraction silently produced nothing. Once the
missing-binary guard was added, that silent miss became a hard build
failure. Verified the binary is a valid amd64 ELF (GLIBC 2.34, under Debian
13's 2.40) at /usr/src/app/nostr-rs-relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:15:59 -04:00
archipelagoandClaude Fable 5 830811372b fix(iso): stop requiring the removed NostrVPN binary
The ISO build hard-failed at 'Required binaries not extracted: nvpn'
because it still pulled 146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7 — an
image that was deleted from the registry when NostrVPN was removed from
the product. The native nvpn daemon is already masked here (ln -sf
/dev/null nostr-vpn.service) and is never spawned at runtime (core vpn.rs
manages WireGuard/Tailscale, only reading a legacy nvpn config as
fallback), so extracting its binary was dead weight that bricked every
build once the image went away.

Drop the nvpn extraction and remove it from the missing-binary guard.
nostr-rs-relay stays required — its nostr-relay unit is still enabled and
the image still pulls. Change is in STEP 3, so the cached rootfs is
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:05:34 -04:00
42 changed files with 3672 additions and 2507 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## v1.7.109-alpha (2026-07-21)
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
## v1.7.108-alpha (2026-07-20)
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
+11
View File
@@ -370,6 +370,17 @@
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.0.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded voices under /data
# preserved).
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
# picks the voice (mirrors the pine ha-stack.yml compose command).
custom_args: ["--voice", "en_GB-alba-medium"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the voice into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10200 (the Wyoming integration endpoint).
- host: 10200
container: 10200
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-piper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10200
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the voice
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-piper
repo: https://github.com/rhasspy/wyoming-piper
license: MIT
tags:
- home
- voice
- text-to-speech
- wyoming
+70
View File
@@ -0,0 +1,70 @@
app:
id: pine-whisper
name: Pine Whisper (STT)
version: "3.4.1"
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
category: home
# Hyphen name matches the runtime references (stack member table / startup
# order) + the live container, so on an existing node the orchestrator ADOPTS
# the running engine rather than recreating it (downloaded models under /data
# preserved).
container_name: pine-whisper
container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-whisper]
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
# pick the model + language (mirrors the pine ha-stack.yml compose command).
custom_args: ["--model", "base-int8", "--language", "en"]
dependencies:
- storage: 2Gi
resources:
memory_limit: 2Gi
security:
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
# on an unprivileged port needs no added capabilities.
capabilities: []
readonly_root: false # downloads the whisper model into /data on first run
no_new_privileges: true
network_policy: isolated
ports:
# Published so Home Assistant (on the pasta net) can reach the engine via
# host.containers.internal:10300 (the Wyoming integration endpoint).
- host: 10300
container: 10300
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine-whisper
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:10300
interval: 30s
timeout: 5s
retries: 5
start_period: 60s # first start downloads the model
metadata:
author: Rhasspy / Home Assistant
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming-faster-whisper
repo: https://github.com/rhasspy/wyoming-faster-whisper
license: MIT
tags:
- home
- voice
- speech-to-text
- wyoming
+177
View File
@@ -0,0 +1,177 @@
app:
id: pine
name: Pine
version: "1.0.0"
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.
category: home
# The user-facing launcher (app_id + container both "pine", matching the
# runtime references + the live container so the orchestrator adopts it). A
# tiny nginx that serves the setup / status page for the voice stack. The two
# Wyoming engines (pine-whisper, pine-piper) are internal stack members.
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
dependencies:
- app_id: pine-whisper
- app_id: pine-piper
- storage: 128Mi
resources:
memory_limit: 64Mi
security:
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
# workers) binds :80 inside the container — needs the worker-drop caps +
# NET_BIND_SERVICE for the privileged port.
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 10380
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/pine/index.html
target: /usr/share/nginx/html/index.html
options: [ro]
environment: []
# The setup / status page, written to the host before create and served
# read-only. Web-Bluetooth WiFi provisioning of the speaker only works from a
# secure/localhost context (not this LAN page), so the page documents that
# flow rather than embedding it — the live provisioner lives in the `pine`
# Gitea repo (index.html), run from a laptop on localhost.
files:
- path: /var/lib/archipelago/pine/index.html
overwrite: true
content: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pine — private voice assistant</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; font: 16px/1.6 system-ui, sans-serif;
background: #0f1512; color: #e7efe9; }
.wrap { max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; }
header { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
header svg { width: 56px; height: 56px; flex: 0 0 auto; }
h1 { font-size: 28px; margin: 0; }
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
h2 { font-size: 18px; margin: 32px 0 8px; color: #b7e0c8; }
ol { padding-left: 22px; } li { margin: 6px 0; }
code { background: #1c2620; padding: 2px 6px; border-radius: 5px;
font-size: 14px; color: #cfe9d9; }
.card { background: #16201b; border: 1px solid #24332b;
border-radius: 12px; padding: 16px 20px; margin: 16px 0; }
.row { display: flex; gap: 12px; align-items: baseline; }
.row b { min-width: 96px; color: #9fd3b6; }
a { color: #7fd6a6; }
footer { margin-top: 40px; color: #6d8578; font-size: 13px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
<rect x="236" y="396" width="40" height="72" rx="6"/>
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g></svg>
<div>
<h1>Pine</h1>
<p class="tag">A private voice assistant that runs on your node.</p>
</div>
</header>
<p>Pine gives Home Assistant a local voice: your PineVoice speaker
hears you, <b>Whisper</b> turns speech into text on this node, and
<b>Piper</b> speaks the reply back — nothing leaves your home.</p>
<div class="card">
<div class="row"><b>Whisper (STT)</b><span>listening on
<code>host.containers.internal:10300</code></span></div>
<div class="row"><b>Piper (TTS)</b><span>listening on
<code>host.containers.internal:10200</code></span></div>
<div class="row"><b>Speaker</b><span>Wyoming satellite on
<code>&lt;speaker-ip&gt;:10700</code></span></div>
</div>
<h2>1 · Get the speaker on WiFi</h2>
<ol>
<li>Web-Bluetooth provisioning needs a secure/localhost page, so
run the setup tool from the <code>pine</code> repo on a laptop:
<code>python3 -m http.server 8377 --bind 127.0.0.1</code>, then
open <code>http://localhost:8377</code> in Chrome.</li>
<li>Put the speaker in provisioning mode (ring LED blinking
yellow), enter your WiFi, and press the centre button when the
page asks for authorization.</li>
<li>Success: the log shows <code>✓ PROVISIONED</code> and the
speaker chimes.</li>
</ol>
<h2>2 · Wire it into Home Assistant</h2>
<ol>
<li>In Home Assistant: <b>Settings → Devices &amp; services</b>.
Add the two <b>Wyoming Protocol</b> integrations —
<code>host.containers.internal:10300</code> (Whisper) and
<code>:10200</code> (Piper).</li>
<li>Add the speaker as a third Wyoming device at
<code>&lt;speaker-ip&gt;:10700</code>.</li>
<li>Build an <b>Assist</b> pipeline: STT = faster-whisper,
TTS = Piper, then set it as the speaker's pipeline.</li>
<li>Say the wake word (<b>“Hey Jarvis”</b>) or press the speaker's
centre button, and talk to your home.</li>
</ol>
<footer>Pine · Whisper + Piper run locally on this Archipelago node.
No cloud, no account.</footer>
</div>
</body>
</html>
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
interfaces:
main:
name: Pine
description: Set up and check your private voice assistant
type: ui
port: 10380
protocol: http
path: /
metadata:
author: Archipelago
icon: /assets/img/app-icons/pine.svg
website: https://github.com/rhasspy/wyoming
repo: https://github.com/rhasspy/wyoming
license: MIT
category: home
launch:
open_in_new_tab: true
tags:
- home
- voice
- assistant
- privacy
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.106-alpha"
version = "1.7.109-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.106-alpha"
version = "1.7.109-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -158,6 +158,29 @@ impl RpcHandler {
anyhow::bail!("Unknown LoRa region: {trimmed}");
}
}
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
// mesh::LoraRadioParams). Validated against the firmware's accepted
// ranges here so a bad value errors at the API instead of being sent
// to the radio and rejected on-device. `null` clears the setting.
if let Some(rp) = params.get("lora_radio_params") {
if rp.is_null() {
config.lora_radio_params = None;
} else {
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
anyhow::ensure!(
(150_000..=2_500_000).contains(&parsed.freq_khz),
"freq_khz out of range (150000..=2500000)"
);
anyhow::ensure!(
(7_000..=500_000).contains(&parsed.bw_hz),
"bw_hz out of range (7000..=500000)"
);
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
config.lora_radio_params = Some(parsed);
}
}
// Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
@@ -41,6 +41,10 @@ impl RpcHandler {
// live radio-reported `region`): the configured LoRa region and
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
obj.insert("lora_region".into(), config.lora_region.clone().into());
obj.insert(
"lora_radio_params".into(),
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
);
obj.insert(
"device_kind".into(),
config
@@ -496,6 +496,8 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"netbird-dashboard".into(),
"netbird-server".into(),
],
// Pine voice-assistant stack: launcher + the two Wyoming engines.
"pine" => vec!["pine".into(), "pine-whisper".into(), "pine-piper".into()],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -595,6 +595,9 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
| "nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "pine"
| "pine-whisper"
| "pine-piper"
)
}
@@ -626,6 +629,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"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -294,6 +294,9 @@ impl RpcHandler {
if package_id == "netbird" {
return self.install_netbird_stack().await;
}
if package_id == "pine" {
return self.install_pine_stack().await;
}
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
@@ -744,6 +744,15 @@ fn netbird_stack_app_ids() -> &'static [&'static str] {
&["netbird-server", "netbird-dashboard", "netbird"]
}
fn pine_stack_app_ids() -> &'static [&'static str] {
// Dependency/startup order: the two Wyoming engines (STT + TTS) first — they
// own their downloaded model/voice under /data and publish 10300/10200 —
// 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"]
}
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
// Dependency order: backends + their generated secrets first, then the api
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
@@ -1907,6 +1916,37 @@ impl RpcHandler {
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
)
}
/// Install the Pine voice-assistant stack (Whisper STT + Piper TTS + the
/// setup/status launcher). Manifest-driven only, like netbird: render the
/// 3-member stack from apps/pine-*/manifest.yml via the orchestrator
/// (archy-net + network_aliases, published Wyoming ports 10300/10200 so
/// Home Assistant reaches the engines via host.containers.internal, the
/// launcher's setup page written via `files:`). The manifests use the exact
/// live container names, so on an existing node this ADOPTS the running
/// stack rather than recreating it (downloaded models/voices preserved).
///
/// There is no in-Rust hardcoded fallback: the signed catalog always ships
/// apps/pine-*/manifest.yml. If the orchestrator doesn't know these app_ids
/// and no running stack exists to adopt, install errors rather than
/// silently diverging from the manifest contract.
pub(super) async fn install_pine_stack(&self) -> Result<serde_json::Value> {
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
{
return Ok(orchestrated);
}
if let Some(adopted) =
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
{
return Ok(adopted);
}
anyhow::bail!(
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
)
}
}
#[cfg(test)]
+9 -1
View File
@@ -50,6 +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"],
// 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).
@@ -75,7 +76,14 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
/// they drive archy-mempool-web), otherwise the app itself.
fn owning_package(app_id: &str) -> &str {
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
const STACKS: &[&str] = &[
"immich",
"indeedhub",
"btcpay-server",
"netbird",
"mempool",
"pine",
];
for stack in STACKS {
if stack_member_app_ids(stack).contains(&app_id) {
return stack;
+69
View File
@@ -137,6 +137,13 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
match run_polkit_networkmanager_repair().await {
Ok(true) => info!(
"Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled"
),
Ok(false) => debug!("NetworkManager polkit rule already present"),
Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e),
}
match run_journald_dropin().await {
Ok(true) => info!("Installed journald log-volume policy drop-in"),
Ok(false) => debug!("journald log-volume policy already in place"),
@@ -442,6 +449,68 @@ exit 2
}
}
/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99).
///
/// Archipelago drives NetworkManager from a system-level systemd service
/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which
/// only authorizes `subject.local && subject.active` sessions — denies it, and
/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs
/// since 2026-05 ship the rule below, but nodes that reached this build over
/// OTA never got it (OTA replaces the binary + web UI, not host system config).
///
/// Install the scoped rule if it is missing, and best-effort ensure `polkitd`
/// itself is present (without the daemon the rule is inert). Both are wrapped
/// so an offline/locked apt or a missing package can never fail startup — the
/// rule is still written so it takes effect once polkitd arrives (e.g. after an
/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker.
async fn run_polkit_networkmanager_repair() -> Result<bool> {
let script = r#"
set -u
RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
MARKER='subject.user == "archipelago"'
# Rule already installed nothing to do.
if grep -qF "$MARKER" "$RULE" 2>/dev/null; then
exit 0
fi
# The rule is inert without the polkit daemon. Older nodes (the ones that hit
# issue #99) shipped without it. Try to install it, but never let apt failure
# (offline node, locked dpkg, package unavailable) abort the heal the rule is
# written regardless so it activates whenever polkitd lands.
if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then
timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \
|| true
fi
mkdir -p /etc/polkit-1/rules.d
cat > "$RULE" <<'RULEEOF'
polkit.addRule(function(action, subject) {
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
return polkit.Result.YES;
}
});
RULEEOF
chmod 644 "$RULE"
# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a
# fallback. Non-fatal if the unit name differs or the daemon is absent.
systemctl reload polkit 2>/dev/null \
|| systemctl restart polkit 2>/dev/null \
|| systemctl restart polkit.service 2>/dev/null \
|| true
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("install NetworkManager polkit rule")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => Ok(true),
_ => {
warn!("polkit NetworkManager repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
@@ -57,6 +57,8 @@ impl DockerPackageScanner {
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"pine-whisper",
"pine-piper",
"buildx_buildkit_default",
];
+17
View File
@@ -766,6 +766,9 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
| "netbird-server"
| "netbird-dashboard"
| "netbird"
| "pine-whisper"
| "pine-piper"
| "pine"
)
}
@@ -827,6 +830,20 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
containers: &["netbird-server", "netbird-dashboard", "netbird"],
anchor: "netbird-server",
},
StackRecoverySpec {
name: "pine",
network: "archy-net",
aliases: &[
("pine-whisper", "pine-whisper"),
("pine-piper", "pine-piper"),
("pine", "pine"),
],
containers: &["pine-whisper", "pine-piper", "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).
anchor: "pine-whisper",
},
]
}
+63 -15
View File
@@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
/// The upstream public anchor as a ready-to-apply `SeedAnchor`.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
@@ -61,6 +59,38 @@ pub fn default_public_anchor() -> SeedAnchor {
}
}
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
// Every node already reaches this host for updates, so it is reachable from
// networks that the upstream anchor is not — which is most of them (the
// upstream anchor answers on one IPv4 that many home/office networks can't
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
// host is already taken by a container.
pub const ARCHY_ANCHOR_NPUB: &str =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
pub fn archy_anchor() -> SeedAnchor {
SeedAnchor {
npub: ARCHY_ANCHOR_NPUB.to_string(),
address: ARCHY_ANCHOR_ADDR.to_string(),
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
label: "Archipelago anchor (vps2)".to_string(),
}
}
/// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
/// node: `fipsctl connect` is attempted for each, and whichever the node's
/// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> {
vec![archy_anchor(), default_public_anchor()]
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
@@ -94,7 +124,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(vec![default_public_anchor()]);
return Ok(default_public_anchors());
}
let bytes = tokio::fs::read(&path)
.await
@@ -268,28 +298,46 @@ mod tests {
}
#[tokio::test]
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
async fn load_missing_seeds_default_public_anchors() {
// A node that has never edited its anchor list should still get the
// full default anchor set so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
assert_eq!(got, default_public_anchors());
// The Archipelago-operated anchor must come first (widest reachability)
// and the upstream anchor must remain present as a fallback.
assert_eq!(got[0], archy_anchor());
assert!(got.contains(&default_public_anchor()));
// Every default must be a TCP form (traverses NAT/firewalls), never the
// dead udp:8668 the upstream anchor never answers on.
assert!(got.iter().all(|a| a.transport == "tcp"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
async fn removing_one_default_persists_and_keeps_the_other() {
// Editing the anchor list (here removing one default) makes the file
// authoritative: the removed anchor must not be silently re-seeded on
// next load, and the remaining default must stay.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
assert!(list.contains(&default_public_anchor()));
let got = load(dir.path()).await.unwrap();
assert_eq!(got, list, "edited list is authoritative; no re-seed");
}
#[tokio::test]
async fn removing_all_defaults_persists_as_empty() {
// Removing every default leaves an empty authoritative list that must
// not be re-seeded on next load.
let dir = tempfile::tempdir().unwrap();
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
assert!(got.is_empty(), "defaults must stay removed once edited");
}
#[tokio::test]
@@ -421,6 +421,7 @@ pub fn spawn_mesh_listener(
our_x25519_pubkey_hex: String,
server_name: Option<String>,
lora_region: Option<String>,
lora_radio_params: Option<super::LoraRadioParams>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
@@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
&our_x25519_pubkey_hex,
server_name.as_deref(),
lora_region.as_deref(),
lora_radio_params,
channel_name.as_deref(),
device_kind,
reticulum_tcp.clone(),
@@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the Meshcore radio-params write.
static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
/// Same retry-cap idea as the region, for the shared-channel write.
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(0);
@@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex: &str,
server_name: Option<&str>,
lora_region: Option<&str>,
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>,
@@ -978,6 +983,62 @@ pub(super) async fn run_mesh_session(
);
}
// Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has
// configured them. Meshcore-only: Meshtastic radios get region+preset via
// ensure_lora_region above, and Reticulum carries its own RNode profile.
// Gated on a persisted marker of the last-applied params rather than the
// device's SELF_INFO readback (its field offsets shift across firmware
// versions), so we send the set-command once per configured value and never
// reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so
// a successful write restarts the session like the region path.
if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) {
let marker_path = data_dir.join("meshcore-radio-params.json");
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
.await
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
if applied != Some(params) {
let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
if attempts < MAX_REGION_PROVISION_ATTEMPTS {
match dev
.set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr)
.await
{
Ok(()) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
if let Ok(json) = serde_json::to_vec(&params) {
if let Err(e) = tokio::fs::write(&marker_path, json).await {
warn!("Failed to persist radio-params marker: {}", e);
}
}
info!(
freq_khz = params.freq_khz,
bw_hz = params.bw_hz,
sf = params.sf,
cr = params.cr,
"Provisioned Meshcore radio params — radio rebooting, \
restarting mesh session"
);
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(());
}
Err(e) => {
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
warn!("Failed to provision Meshcore radio params: {}", e);
}
}
} else {
warn!(
attempts = MAX_REGION_PROVISION_ATTEMPTS,
"Meshcore radio rejected the configured radio params after \
repeated attempts continuing with the device's own settings."
);
}
} else {
RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed);
}
}
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
// falling back to the DID fragment if no name is configured.
let advert_name = if let Some(name) = server_name {
+23
View File
@@ -322,6 +322,22 @@ pub(crate) async fn seed_federation_peers_into_mesh(
}
}
/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the
/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz),
/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are
/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and
/// MUST match every radio on the local mesh — a mismatched radio hears RF
/// energy but demodulates nothing. None (the default) leaves the device's own
/// settings untouched, so nodes outside the configured deployment are never
/// affected.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoraRadioParams {
pub freq_khz: u32,
pub bw_hz: u32,
pub sf: u8,
pub cr: u8,
}
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
@@ -340,6 +356,11 @@ pub struct MeshConfig {
/// unset/None.
#[serde(default)]
pub lora_region: Option<String>,
/// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the
/// radio on connect when set; None leaves the device untouched. Ignored
/// for Meshtastic (region/preset covers it) and Reticulum.
#[serde(default)]
pub lora_radio_params: Option<LoraRadioParams>,
/// Whether to periodically broadcast our identity.
#[serde(default)]
pub broadcast_identity: bool,
@@ -422,6 +443,7 @@ impl Default for MeshConfig {
device_path: None,
channel_name: Some("archipelago".to_string()),
lora_region: None,
lora_radio_params: None,
broadcast_identity: true,
advert_name: None,
mesh_only_mode: None,
@@ -722,6 +744,7 @@ impl MeshService {
self.our_x25519_pubkey_hex.clone(),
self.server_name.clone(),
self.config.lora_region.clone(),
self.config.lora_radio_params,
self.config.channel_name.clone(),
self.config.device_kind,
self.config.reticulum_tcp.clone(),
+41
View File
@@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
encode_frame(&data)
}
/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
/// read them to detect drift. Values outside the firmware's accepted ranges are
/// rejected by the device (it replies with an error frame), not clamped here.
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
let mut data = vec![CMD_SET_RADIO_PARAMS];
data.extend_from_slice(&freq_field.to_le_bytes());
data.extend_from_slice(&bw_field.to_le_bytes());
data.push(sf);
data.push(cr);
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
@@ -730,6 +748,29 @@ mod tests {
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_set_radio_params_wire_layout() {
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
assert_eq!(frame[0], OUTBOUND_MARKER);
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
let data = &frame[3..];
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
assert_eq!(
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
869_618
);
assert_eq!(
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
62_500
);
assert_eq!(data[9], 8); // sf
assert_eq!(data[10], 8); // cr
assert_eq!(data.len(), 11);
}
#[test]
fn test_decode_frame_complete() -> Result<()> {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
+23
View File
@@ -164,6 +164,29 @@ impl MeshcoreDevice {
Ok(())
}
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
/// firmware persists the params and reboots to apply them, so the caller
/// must treat the session as gone and reconnect.
pub async fn set_radio_params(
&mut self,
freq_khz: u32,
bw_hz: u32,
sf: u8,
cr: u8,
) -> Result<()> {
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
.await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Set radio params failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?;
+27 -3
View File
@@ -688,12 +688,27 @@ impl Server {
let fips_peer_registry = fips_peer_registry.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
// Steady cadence, but retry fast right after a daemon restart:
// regenerating fips.yaml (this build does, once, on first boot
// after the OTA) restarts the fips daemon, and for a few seconds
// `/run/fips/control.sock` is gone so every `fipsctl connect`
// fails and the node islands until the next tick. Detect that
// exact failure and retry in 15s instead of 5 min — bounded, so a
// node with no fips daemon falls back to the steady cadence
// rather than busy-looping.
const STEADY: Duration = Duration::from_secs(300);
const FAST: Duration = Duration::from_secs(15);
const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode
let mut fast_retries: u32 = 0;
loop {
interval.tick().await;
let mut daemon_restarting = false;
match crate::fips::anchors::load(&data_dir).await {
Ok(list) if !list.is_empty() => {
let _ = crate::fips::anchors::apply(&list).await;
let results = crate::fips::anchors::apply(&list).await;
daemon_restarting = !results.is_empty()
&& results
.iter()
.all(|r| !r.ok && r.message.contains("control.sock"));
}
Ok(_) => { /* no seed anchors configured yet */ }
Err(e) => {
@@ -717,6 +732,15 @@ impl Server {
let _ = crate::fips::anchors::apply(&direct).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
fast_retries += 1;
FAST
} else {
fast_retries = 0;
STEADY
};
tokio::time::sleep(next).await;
}
});
}
@@ -1183,39 +1183,13 @@ BACKENDFILE
fi
fi
# Extract NostrVPN binary from container image (native system service, not a container app)
# NOTE: The container image must be built against Debian 13's GLIBC (2.40).
# If built against a newer GLIBC, the binary will fail at runtime.
# Rebuild with: FROM debian:13 AS builder
echo " Extracting NostrVPN binary..."
_NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
if [ -z "$NVPN_IMAGE_ID" ]; then
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
fi
NVPN_CONTAINER=$($CONTAINER_CMD create "$_NVPN_IMG" 2>/dev/null) || true
if [ -n "$NVPN_CONTAINER" ]; then
$CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \
chmod +x "$ARCH_DIR/bin/nvpn" && \
echo " ✅ NostrVPN binary extracted ($(du -h "$ARCH_DIR/bin/nvpn" | cut -f1))"
$CONTAINER_CMD rm "$NVPN_CONTAINER" 2>/dev/null || true
# Check GLIBC compatibility — Debian 13 (Trixie) has GLIBC 2.40
if [ -f "$ARCH_DIR/bin/nvpn" ]; then
NVPN_GLIBC=$(objdump -T "$ARCH_DIR/bin/nvpn" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -V | tail -1)
if [ -n "$NVPN_GLIBC" ]; then
# Compare: if required GLIBC > 2.40, warn
if printf '%s\n' "2.40" "$NVPN_GLIBC" | sort -V | tail -1 | grep -qv "^2\.40$"; then
echo " ⚠ WARNING: nvpn binary requires GLIBC $NVPN_GLIBC but Debian 13 has 2.40"
echo " ⚠ The nvpn daemon will fail at runtime. Rebuild the container against Debian 13."
echo " ⚠ VPN invite/status will still work via Rust backend config.toml fallback."
else
echo " ✅ nvpn GLIBC compatibility OK (requires $NVPN_GLIBC, target has 2.40)"
fi
fi
fi
else
echo " ⚠ NostrVPN image not available — nvpn binary will be missing"
fi
# NostrVPN (the native `nvpn` mesh-VPN daemon) has been removed from the
# product — the active VPN path is WireGuard/Tailscale (see core vpn.rs). Its
# service is already masked below (ln -sf /dev/null nostr-vpn.service) and the
# binary is never spawned at runtime, so we no longer extract it. The
# nostr-vpn image was deleted from the registry, which is why hard-requiring
# it here bricked the build. Intentionally left out; do not re-add without
# restoring the daemon.
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
echo " Extracting nostr-rs-relay binary..."
@@ -1225,7 +1199,11 @@ if [ -z "$RELAY_IMAGE" ]; then
fi
RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
if [ -n "$RELAY_CONTAINER" ]; then
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
# The relay image builds to its WORKDIR /usr/src/app and execs
# ./nostr-rs-relay from there (not /usr/local/bin — that path was from an
# older image build and silently broke extraction once the image was
# rebuilt to the standard layout).
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/src/app/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
echo " ✅ nostr-rs-relay binary extracted ($(du -h "$ARCH_DIR/bin/nostr-rs-relay" | cut -f1))"
$CONTAINER_CMD rm "$RELAY_CONTAINER" 2>/dev/null || true
@@ -1233,11 +1211,11 @@ else
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
fi
# A missing nvpn/nostr-rs-relay used to be a warning, and the resulting ISO
# shipped units that crash-looped (or silently lacked VPN signaling) on every
# install. Refuse to produce that ISO unless explicitly overridden.
# A missing nostr-rs-relay used to be a warning, and the resulting ISO shipped
# an enabled nostr-relay unit that crash-looped on every install. Refuse to
# produce that ISO unless explicitly overridden. (nvpn is intentionally no
# longer required — NostrVPN was removed; see the note above.)
MISSING_VPN_BINARIES=""
[ -f "$ARCH_DIR/bin/nvpn" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nvpn"
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
if [ -n "$MISSING_VPN_BINARIES" ]; then
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
@@ -2512,6 +2490,17 @@ HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
LIDCONF
# Kiosk appliance: a short press or accidental brush of the power button used
# to shut the node down instantly (confirmed on a Framework node — "Power key
# pressed short -> Powering off"). Ignore the short press; keep a deliberate
# long press (hold ~2s) as the intentional power-off, so the node isn't taken
# down by accident but can still be shut down on purpose without a keyboard.
cat > /mnt/target/etc/systemd/logind.conf.d/power-key.conf <<'PWRCONF'
[Login]
HandlePowerKey=ignore
HandlePowerKeyLongPress=poweroff
PWRCONF
# Copy Archipelago binaries and files
if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
cp -r "$BOOT_MEDIA/archipelago/bin/"* /mnt/target/usr/local/bin/ 2>/dev/null || true
@@ -2671,6 +2660,11 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
W='\033[1;37m'
N='\033[0m'
# The logo uses UTF-8 block-drawing glyphs. Switching back from the kiosk
# (Xorg on vt1) can leave the console VT out of UTF-8 mode, which renders
# them as garbage bytes ("sometimes corrupt"). ESC % G forces the VT into
# UTF-8 mode so the logo always draws correctly.
printf '\033%%G' 2>/dev/null || true
clear
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
@@ -2686,10 +2680,14 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
if [ -b /dev/mapper/archipelago-data ] || [ -b /dev/mapper/archipelago_crypt ]; then
echo -e " ${OD}storage LUKS2 encrypted${N}"
fi
# The kiosk's Xorg runs on vt1 (see archipelago-kiosk-launcher: "Xorg :0
# vt1"), so Ctrl+Alt+F1 IS the kiosk and a terminal is on another VT (F2,
# where systemd auto-spawns a getty). The old hints had these backwards —
# they sent you to an empty black VT7 and there was no way back to the kiosk.
if systemctl is-active archipelago-kiosk.service >/dev/null 2>&1; then
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F1 for terminal)${N}"
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F2 for terminal)${N}"
else
echo -e " ${OD}display Console (Ctrl+Alt+F7 for kiosk)${N}"
echo -e " ${OD}display Console (Ctrl+Alt+F1 for kiosk)${N}"
fi
echo ""
fi
@@ -82,6 +82,24 @@ configure_display() {
configure_display
# --- Kiosk UI scaling for large / high-res displays -----------------------
# REVERT: set env ARCHIPELAGO_KIOSK_SCALE=1 (per-node, no rebuild), or restore
# the hardcoded --force-device-scale-factor=1 below to disable entirely.
#
# A big TV reports its full native resolution as the CSS viewport, so a 4K
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
# the detected panel width so the *effective* CSS viewport lands near a
# comfortable across-the-room target, scaling the whole UI up on big screens:
# 1920x1080 -> 1.50 | 2560x1440 -> 2.00 | 3840x2160 -> 3.00 (clamped 1.0-3.0)
KIOSK_TARGET_CSS_WIDTH=${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-1280}
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
else
KIOSK_SCALE=$(awk -v w="$KIOSK_MODE_W" -v t="$KIOSK_TARGET_CSS_WIDTH" \
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
fi
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
@@ -116,8 +134,17 @@ while true; do
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
# falls back to raw ALSA "default", fails to connect, and produces no audio
# at all with no visible error (--noerrdialogs suppresses it).
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
# Force a DARK color-scheme preference. The main UI hardcodes its dark
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
# and defaults to its LIGHT variant (white panels) when the browser reports
# no preference — which a minimal kiosk X session does. Two independent dark
# signals so this survives a Chromium enum change: GTK_THEME is version-
# independent and can never force light (worst case: no effect), and the
# blink-settings flag (0 = kDark in modern Chromium) reinforces it. Neither
# can regress the always-dark main UI.
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago GTK_THEME=Adwaita:dark XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--blink-settings=preferredColorScheme=0 \
--noerrdialogs \
--disable-infobars \
--disable-translate \
@@ -133,7 +160,7 @@ while true; do
--window-size=${KIOSK_MODE_W},${KIOSK_MODE_H} \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=1 \
--force-device-scale-factor=${KIOSK_SCALE} \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.106-alpha",
"version": "1.7.109-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.106-alpha",
"version": "1.7.109-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.106-alpha",
"version": "1.7.109-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Pine">
<rect width="512" height="512" fill="#ffffff"/>
<g fill="#000000">
<!-- trunk -->
<rect x="236" y="396" width="40" height="72" rx="6"/>
<!-- three tiers of the evergreen, top to bottom -->
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 508 B

+11
View File
@@ -370,6 +370,17 @@
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.0.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
+3
View File
@@ -48,6 +48,9 @@ export interface MeshConfigureParams {
receive_block_headers?: boolean
lora_region?: string
device_kind?: string
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
}
export interface MeshPeer {
+1 -1
View File
@@ -146,7 +146,7 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
<span class="text-white/80 text-sm">F*ck IPs Mesh</span>
</div>
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
</div>
@@ -33,7 +33,10 @@ describe('appSessionConfig', () => {
configurable: true,
})
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083')
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
// manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect.
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088')
})
it('does not treat service-only tcp ports as web launch surfaces', () => {
@@ -6,26 +6,29 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"archy-nbxplorer": 32838,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8083,
"did-wallet": 8088,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
"immich": 2283,
"indeedhub": 7778,
"jellyfin": 8096,
"lnd-ui": 18083,
"mempool": 4080,
"mempool-api": 8999,
"morphos-server": 8086,
"morphos-server": 8089,
"netbird": 8087,
"nextcloud": 8085,
"nostr-rs-relay": 18081,
"photoprism": 2342,
"pine": 10380,
"portainer": 9000,
"router": 8084,
"searxng": 8888,
"strfry": 8082,
"strfry": 8090,
"uptime-kuma": 3002,
"vaultwarden": 8082,
}
@@ -36,6 +39,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
"archy-nbxplorer": "NBXplorer",
"barkd": "Ark Wallet",
"bitcoin-core": "Bitcoin Core",
"bitcoin-knots": "Bitcoin Knots",
"bitcoin-ui": "Bitcoin UI",
@@ -45,24 +49,40 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"did-wallet": "Web5 DID Wallet",
"electrs-ui": "Electrs UI",
"electrumx": "ElectrumX",
"fedimint": "Fedimint",
"fedimint": "Fedimint Guardian",
"fedimint-clientd": "Fedimint Client",
"fedimint-gateway": "Fedimint Gateway",
"filebrowser": "File Browser",
"fips-ui": "FIPS Mesh",
"gitea": "Gitea",
"grafana": "Grafana",
"homeassistant": "Home Assistant",
"immich": "Immich",
"immich-postgres": "Immich Postgres",
"immich-redis": "Immich Redis",
"indeedhub": "IndeeHub",
"indeedhub-api": "IndeedHub API",
"indeedhub-ffmpeg": "IndeedHub FFmpeg Worker",
"indeedhub-minio": "IndeedHub MinIO",
"indeedhub-postgres": "IndeedHub Postgres",
"indeedhub-redis": "IndeedHub Redis",
"indeedhub-relay": "IndeedHub Nostr Relay",
"jellyfin": "Jellyfin",
"lightning-stack": "Lightning Stack",
"lnd": "LND",
"lnd-ui": "LND UI",
"mempool": "Mempool Explorer",
"mempool-api": "Mempool API",
"meshtastic": "Meshtastic",
"morphos-server": "MorphOS Server",
"netbird": "NetBird",
"netbird-dashboard": "NetBird Dashboard",
"netbird-server": "NetBird Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"photoprism": "PhotoPrism",
"pine": "Pine",
"pine-piper": "Pine Piper (TTS)",
"pine-whisper": "Pine Whisper (STT)",
"portainer": "Portainer",
"router": "Mesh Router",
"searxng": "SearXNG",
@@ -76,8 +96,10 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
"gitea",
"grafana",
"homeassistant",
"immich",
"nextcloud",
"photoprism",
"pine",
"portainer",
"uptime-kuma",
"vaultwarden",
+6 -1
View File
@@ -26,6 +26,10 @@ export const SERVICE_NAMES = new Set([
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
// reached by Home Assistant over host.containers.internal — the user-facing
// card is "pine" (the setup/status launcher), so the engines go to Services.
'pine-whisper', 'pine-piper',
])
const INTERNAL_TOOLING_NAMES = new Set([
@@ -61,7 +65,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home', 'pine': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
@@ -212,6 +216,7 @@ const SERVICE_ICON_PREFIXES: Array<[string, string]> = [
['indeedhub-', '/assets/img/app-icons/indeedhub.png'],
['immich-', '/assets/img/app-icons/immich.png'],
['immich_', '/assets/img/app-icons/immich.png'],
['pine-', '/assets/img/app-icons/pine.svg'],
]
function serviceParentIcon(id: string): string | undefined {
+74 -14
View File
@@ -27,6 +27,12 @@ const form = ref({
channel: 'archipelago',
name: '',
broadcastIdentity: true,
// MeshCore LoRa PHY params (human units; converted to firmware units on
// save). All four empty = leave the radio's flashed settings untouched.
rfFreqMhz: '',
rfBwKhz: '',
rfSf: '',
rfCr: '',
})
const saving = ref(false)
const saveError = ref<string | null>(null)
@@ -43,6 +49,16 @@ watch(
form.value.deviceKind = s.device_kind ?? 'auto'
form.value.channel = s.channel_name || 'archipelago'
form.value.name = s.self_advert_name ?? ''
const rp = (s as Record<string, unknown>).lora_radio_params as
| { freq_khz: number; bw_hz: number; sf: number; cr: number }
| null
| undefined
if (rp) {
form.value.rfFreqMhz = String(rp.freq_khz / 1000)
form.value.rfBwKhz = String(rp.bw_hz / 1000)
form.value.rfSf = String(rp.sf)
form.value.rfCr = String(rp.cr)
}
},
{ immediate: true },
)
@@ -62,12 +78,31 @@ async function saveSettings() {
saveError.value = null
saveDone.value = false
try {
// MeshCore RF params: send only when all four are filled (partial input
// is a user error surfaced by the API's range validation; all-empty
// means "leave the radio's flashed settings alone").
const rf = [form.value.rfFreqMhz, form.value.rfBwKhz, form.value.rfSf, form.value.rfCr]
const rfAll = rf.every((v) => String(v).trim() !== '')
const rfAny = rf.some((v) => String(v).trim() !== '')
if (rfAny && !rfAll) {
throw new Error('Fill in all four RF fields (frequency, bandwidth, SF, CR) or leave all empty')
}
await mesh.configure({
lora_region: form.value.region,
device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
broadcast_identity: form.value.broadcastIdentity,
...(rfAll
? {
lora_radio_params: {
freq_khz: Math.round(parseFloat(form.value.rfFreqMhz) * 1000),
bw_hz: Math.round(parseFloat(form.value.rfBwKhz) * 1000),
sf: parseInt(form.value.rfSf, 10),
cr: parseInt(form.value.rfCr, 10),
},
}
: {}),
})
saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000)
@@ -93,18 +128,6 @@ async function saveSettings() {
<span class="mesh-stat-label">Node ID</span>
<span class="mesh-stat-value">{{ mesh.status.self_node_id != null ? `!${mesh.status.self_node_id.toString(16).padStart(8, '0')}` : '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Name</span>
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Region (radio)</span>
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Channel</span>
<span class="mesh-stat-value">{{ mesh.status.channel_name }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Type</span>
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
@@ -128,10 +151,10 @@ async function saveSettings() {
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
</p>
<p v-else-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} the radio's flashed RF settings apply; adjust via a MeshCore client if they differ.
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} set the RF fields below to program the radio.
</p>
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings verify the radio matches your region's band{{ selectedRegion ? ` (${selectedRegion.band} MHz)` : '' }}.
Program the radio's RF settings with the fields below — every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
@@ -158,6 +181,43 @@ async function saveSettings() {
<input v-model="form.name" maxlength="24" placeholder="node name" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<!-- MeshCore LoRa PHY params every radio on the local mesh must match
or it hears RF energy but decodes nothing. Empty = leave the radio's
flashed settings untouched. -->
<div v-if="effectiveKind === 'meshcore'" class="mt-4">
<h5 class="text-xs font-semibold text-white/80 mb-2">MeshCore RF parameters</h5>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (MHz)</label>
<input v-model="form.rfFreqMhz" inputmode="decimal" placeholder="869.618" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (kHz)</label>
<select v-model="form.rfBwKhz" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value=""></option>
<option v-for="bw in ['62.5', '125', '250', '500']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="form.rfSf" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value=""></option>
<option v-for="sf in [5, 6, 7, 8, 9, 10, 11, 12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="form.rfCr" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value=""></option>
<option v-for="cr in [5, 6, 7, 8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
@@ -8,7 +8,7 @@
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
<h2 class="text-xl font-semibold text-white">F*ck IPs Mesh</h2>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
@@ -362,6 +362,33 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.109-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.109-alpha</span>
<span class="text-xs text-white/40">July 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.</p>
<p>Your node can now program its MeshCore radio's RF settings frequency, bandwidth, spreading factor, and coding rate from Mesh Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.</p>
<p>The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.</p>
</div>
</div>
<!-- v1.7.108-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.108-alpha</span>
<span class="text-xs text-white/40">July 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.</p>
<p>Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen it failed with a permissions error because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.</p>
<p>Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.</p>
<p>The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).</p>
<p>More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down hold it to power off on purpose.</p>
<p>Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.</p>
</div>
</div>
<!-- v1.7.106-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+17 -21
View File
@@ -1,31 +1,27 @@
{
"version": "1.7.109-alpha",
"release_date": "2026-07-21",
"changelog": [
"Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.",
"On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.",
"Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.",
"When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only \"Failed to connect to peer\" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.",
"Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical."
"Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice \u2014 speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.",
"Your node can now program its MeshCore radio's RF settings \u2014 frequency, bandwidth, spreading factor, and coding rate \u2014 from Mesh \u2192 Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.",
"The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows."
],
"components": [
{
"current_version": "1.7.106-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.106-alpha",
"sha256": "1b08fe3fdd96bdc6600f811a6c273675ed87529ee1a4a7cb35d0b94ce10912d9",
"size_bytes": 50392736
"current_version": "1.7.109-alpha",
"new_version": "1.7.109-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.109-alpha/archipelago",
"sha256": "c98322aeb1d7eb223d122052657a4dd67cd284bbb862a79ff9439b35cffd6607",
"size_bytes": 50201840
},
{
"current_version": "1.7.106-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago-frontend-1.7.106-alpha.tar.gz",
"name": "archipelago-frontend-1.7.106-alpha.tar.gz",
"new_version": "1.7.106-alpha",
"sha256": "c37dc72ee3b458b169911862da8ca3ec30454e63e0f3c7492e38889a9fbde89f",
"size_bytes": 174594058
"name": "archipelago-frontend-1.7.109-alpha.tar.gz",
"current_version": "1.7.109-alpha",
"new_version": "1.7.109-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.109-alpha/archipelago-frontend-1.7.109-alpha.tar.gz",
"sha256": "8cff430874eeed385ecc862e3b56c9e338587981684285a5b0ce22c97849a012",
"size_bytes": 174604264
}
],
"release_date": "2026-07-20",
"signature": "2059c74b748fb6e60c8e29b38774be0fef6f85e7cc7992feff03de128ab269d74345b2d0e6cb9d78d4b447abbecdbe8d0d9b60e46ffffbc0e75de4d52d290f05",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.106-alpha"
]
}
+2643 -2376
View File
File diff suppressed because it is too large Load Diff
+17 -21
View File
@@ -1,31 +1,27 @@
{
"version": "1.7.109-alpha",
"release_date": "2026-07-21",
"changelog": [
"Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.",
"On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.",
"Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.",
"When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only \"Failed to connect to peer\" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.",
"Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical."
"Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice \u2014 speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.",
"Your node can now program its MeshCore radio's RF settings \u2014 frequency, bandwidth, spreading factor, and coding rate \u2014 from Mesh \u2192 Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.",
"The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows."
],
"components": [
{
"current_version": "1.7.106-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.106-alpha",
"sha256": "1b08fe3fdd96bdc6600f811a6c273675ed87529ee1a4a7cb35d0b94ce10912d9",
"size_bytes": 50392736
"current_version": "1.7.109-alpha",
"new_version": "1.7.109-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.109-alpha/archipelago",
"sha256": "c98322aeb1d7eb223d122052657a4dd67cd284bbb862a79ff9439b35cffd6607",
"size_bytes": 50201840
},
{
"current_version": "1.7.106-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.106-alpha/archipelago-frontend-1.7.106-alpha.tar.gz",
"name": "archipelago-frontend-1.7.106-alpha.tar.gz",
"new_version": "1.7.106-alpha",
"sha256": "c37dc72ee3b458b169911862da8ca3ec30454e63e0f3c7492e38889a9fbde89f",
"size_bytes": 174594058
"name": "archipelago-frontend-1.7.109-alpha.tar.gz",
"current_version": "1.7.109-alpha",
"new_version": "1.7.109-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.109-alpha/archipelago-frontend-1.7.109-alpha.tar.gz",
"sha256": "8cff430874eeed385ecc862e3b56c9e338587981684285a5b0ce22c97849a012",
"size_bytes": 174604264
}
],
"release_date": "2026-07-20",
"signature": "2059c74b748fb6e60c8e29b38774be0fef6f85e7cc7992feff03de128ab269d74345b2d0e6cb9d78d4b447abbecdbe8d0d9b60e46ffffbc0e75de4d52d290f05",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.106-alpha"
]
}
+2
View File
@@ -39,6 +39,8 @@ INTERNAL_MANIFEST_IDS = {
"indeedhub-relay",
"netbird-dashboard",
"netbird-server",
"pine-whisper",
"pine-piper",
}
LEGACY_STACK_CATALOG_IDS = {