First commit

This commit is contained in:
ssmithx 2026-07-23 00:33:55 +00:00
parent 8213b0aee3
commit 7d31ca5d65
14 changed files with 1233 additions and 4 deletions

50
core/Cargo.lock generated
View File

@ -84,6 +84,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
@ -160,6 +169,7 @@ dependencies = [
"uuid",
"zbase32",
"zeroize",
"zip",
]
[[package]]
@ -1174,6 +1184,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@ -6894,8 +6915,37 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@ -105,6 +105,10 @@ bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1"
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
# per-platform release zip (see mesh/flash.rs).
zip = { version = "2", default-features = false, features = ["deflate"] }
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
hkdf = "0.12.4"

View File

@ -387,6 +387,10 @@ impl RpcHandler {
// Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await,
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
"mesh.flash-status" => self.handle_mesh_flash_status().await,
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,

View File

@ -0,0 +1,132 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
use crate::mesh::types::DeviceType;
use anyhow::Result;
fn parse_family(s: &str) -> Result<DeviceType> {
match s.trim().to_lowercase().as_str() {
"meshcore" => Ok(DeviceType::Meshcore),
"meshtastic" => Ok(DeviceType::Meshtastic),
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
}
}
fn parse_board(s: &str) -> Result<FlashBoard> {
match s.trim().to_lowercase().as_str() {
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
}
}
impl RpcHandler {
/// mesh.flash-list-firmware — resolve the available firmware version(s)
/// for a given family. v1 only ever surfaces "latest".
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let versions = flash::list_firmware(family).await?;
Ok(serde_json::json!({ "versions": versions }))
}
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
/// latest firmware for the given family, defaulting to a full chip
/// erase before write. `board` is optional: if the port's USB vid:pid
/// unambiguously resolves to a known board, that's used; otherwise the
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
/// doc comment on why we refuse to guess).
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let path = params
.as_ref()
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
.to_string();
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
let board = match params
.as_ref()
.and_then(|p| p.get("board"))
.and_then(|v| v.as_str())
{
Some(explicit) => parse_board(explicit)?,
None => {
let info = mesh::detect_devices_info()
.await
.into_iter()
.find(|d| d.path == path);
info.as_ref()
.and_then(flash::resolve_flash_board)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not auto-detect the board on {path} — specify board explicitly"
)
})?
}
};
flash::start_flash_job(
&self.flash_job,
&self.mesh_service_arc(),
self.config.data_dir.clone(),
path,
board,
family,
)
.await?;
Ok(serde_json::json!({ "started": true }))
}
/// mesh.flash-status — poll the current (or most recent) flash job.
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
let status: FlashJobStatus = j.snapshot().await;
let mut value = serde_json::to_value(&status)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("active".into(), (!status.done).into());
}
Ok(value)
}
None => Ok(serde_json::json!({ "active": false })),
}
}
/// mesh.flash-cancel — best-effort; only honored before erase/write has
/// started (see `FlashJob::cancel`'s doc comment).
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
j.cancel().await?;
Ok(serde_json::json!({ "cancelled": true }))
}
None => anyhow::bail!("No flash job in progress"),
}
}
}

View File

@ -1,5 +1,6 @@
mod assistant;
mod bitcoin_ops;
mod flash;
mod messaging;
mod safety;
mod status;

View File

@ -88,6 +88,9 @@ pub struct RpcHandler {
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port.
flash_job: crate::mesh::flash::FlashJobHandle,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
@ -159,6 +162,7 @@ impl RpcHandler {
endpoint_rate_limiter,
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),

View File

@ -0,0 +1,645 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
//! three firmware families the mesh module already knows how to detect (see
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
//! flash time (never bundled/pinned in the repo), and every flash defaults
//! to a full chip erase before write.
//!
//! MeshCore and Meshtastic are flashed the same way: download a released
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
//! this family, so we deliberately don't resolve a firmware URL ourselves
//! for Reticulum; rnodeconf already knows how).
use super::serial::DetectedDeviceInfo;
use super::types::DeviceType;
use super::MeshService;
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
/// esptool target covers both), but ship different USB identities and
/// different per-board firmware assets upstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashBoard {
HeltecV3,
HeltecV4,
}
impl FlashBoard {
/// Meshtastic's board id (matches the release manifest's `board` field
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
fn meshtastic_id(self) -> &'static str {
match self {
Self::HeltecV3 => "heltec-v3",
Self::HeltecV4 => "heltec-v4",
}
}
}
/// Map a detected USB vid:pid to a known flashable board, using the same
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
/// auto-match since that vid:pid is bridge-chip-specific.
///
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
/// with product string "USB JTAG/serial debug unit" — that descriptor is
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
/// board's image. Callers (the RPC layer / frontend) must let the user pick
/// the board manually whenever this returns `None`.
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
match (info.vid.as_deref(), info.pid.as_deref()) {
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashStage {
Downloading,
Erasing,
Writing,
Autoinstalling,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct FlashJobStatus {
pub board: FlashBoard,
pub family: DeviceType,
pub path: String,
pub stage: FlashStage,
pub percent: Option<u8>,
pub log_tail: Vec<String>,
pub done: bool,
pub error: Option<String>,
}
const LOG_TAIL_MAX: usize = 200;
/// Live state for the one flash job that can run at a time. A single global
/// slot is sufficient because flashing needs exclusive serial access to the
/// one port being flashed — there is no meaningful concept of two concurrent
/// flash jobs on this node.
pub struct FlashJob {
status: RwLock<FlashJobStatus>,
/// Set once the background task is spawned. Only used while `stage` is
/// still `Downloading` — an interrupted erase/write can leave the chip
/// in a worse state than either finished or unstarted, so cancellation
/// is refused once erase begins (see `cancel()`).
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
}
impl FlashJob {
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
Arc::new(Self {
abort_handle: RwLock::new(None),
status: RwLock::new(FlashJobStatus {
board,
family,
path,
stage: FlashStage::Downloading,
percent: None,
log_tail: Vec::new(),
done: false,
error: None,
}),
})
}
pub async fn snapshot(&self) -> FlashJobStatus {
self.status.read().await.clone()
}
async fn set_stage(&self, stage: FlashStage) {
let mut s = self.status.write().await;
s.stage = stage;
s.percent = None;
}
async fn set_percent(&self, percent: u8) {
self.status.write().await.percent = Some(percent.min(100));
}
async fn push_log(&self, line: impl Into<String>) {
let mut s = self.status.write().await;
s.log_tail.push(line.into());
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
if overflow > 0 {
s.log_tail.drain(0..overflow);
}
}
async fn fail(&self, err: &anyhow::Error) {
let mut s = self.status.write().await;
s.stage = FlashStage::Failed;
s.error = Some(format!("{err:#}"));
s.done = true;
}
async fn finish(&self) {
let mut s = self.status.write().await;
s.stage = FlashStage::Done;
s.done = true;
}
/// Best-effort cancel: only honored before erase/write/autoinstall has
/// started (i.e. still in `Downloading`). Once a stage that touches the
/// chip begins, this refuses — interrupting an erase or write can leave
/// the flash in a state worse than either finished or unstarted.
pub async fn cancel(&self) -> Result<()> {
let mut s = self.status.write().await;
if s.done {
anyhow::bail!("Flash job already finished");
}
if s.stage != FlashStage::Downloading {
anyhow::bail!(
"Cannot cancel once {:?} has started — let it finish or fail on its own",
s.stage
);
}
if let Some(handle) = self.abort_handle.write().await.take() {
handle.abort();
}
s.stage = FlashStage::Failed;
s.error = Some("Cancelled by user".to_string());
s.done = true;
Ok(())
}
}
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
pub fn new_job_handle() -> FlashJobHandle {
Arc::new(RwLock::new(None))
}
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join("mesh").join("firmware-cache")
}
fn github_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("archipelago-mesh-flash")
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")
}
/// Resolve what firmware is available for a board+family. v1 only ever
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
/// resolves on its own" (it does its own version checking upstream).
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
match family {
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
DeviceType::Meshtastic => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Meshcore => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
}
}
#[derive(serde::Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
/// Start a flash job in the background. Returns as soon as the job has been
/// registered and the listener released — callers poll `FlashJobHandle` via
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
pub async fn start_flash_job(
handle: &FlashJobHandle,
mesh_service: &Arc<RwLock<Option<MeshService>>>,
data_dir: PathBuf,
path: String,
board: FlashBoard,
family: DeviceType,
) -> Result<()> {
{
let existing = handle.read().await;
if let Some(job) = existing.as_ref() {
if !job.snapshot().await.done {
anyhow::bail!("A firmware flash is already in progress on this node");
}
}
}
let job = FlashJob::new(board, family, path.clone());
*handle.write().await = Some(Arc::clone(&job));
// esptool/archy-rnodeconf need exclusive serial access — release the
// listener's hold on the port before touching it. MeshService::stop()
// already recreates the command channel so a later start() is safe.
{
let mut svc = mesh_service.write().await;
if let Some(s) = svc.as_mut() {
s.stop().await;
}
}
let bg_job = Arc::clone(&job);
let bg_service = Arc::clone(mesh_service);
let task = tokio::spawn(async move {
let result = run_flash(board, family, &data_dir, &path, &bg_job).await;
match &result {
Ok(()) => {
bg_job.push_log("Flash completed successfully".to_string()).await;
bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
}
Err(e) => {
warn!(path = %path, error = %e, "LoRa firmware flash failed");
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(e).await;
}
}
// Whatever happened, the board's firmware may now differ from
// whatever was pinned before — clear the pin so the listener's
// strict auto-detect order picks up reality instead of getting
// wedged trying the old protocol first, then resume the listener
// (probe_device is called separately by the frontend re-showing the
// hot-swap modal, reusing the existing non-destructive probe flow).
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
if let Ok(mut config) = super::load_config(&data_dir).await {
config.device_kind = None;
if let Err(e) = s.configure(config).await {
warn!(error = %e, "Failed to clear device_kind pin after flash");
}
}
if let Err(e) = s.start() {
warn!(error = %e, "Failed to restart mesh listener after flash");
}
}
});
*job.abort_handle.write().await = Some(task.abort_handle());
Ok(())
}
async fn run_flash(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
path: &str,
job: &Arc<FlashJob>,
) -> Result<()> {
match family {
DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?;
esptool_erase_and_write(path, &image, job).await
}
DeviceType::Reticulum => rnodeconf_autoinstall(path, job).await,
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
}
}
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
async fn fetch_esptool_image(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let cache = firmware_cache_dir(data_dir);
tokio::fs::create_dir_all(&cache)
.await
.context("Creating firmware cache dir")?;
let client = github_client()?;
match family {
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
_ => anyhow::bail!("{family} is not flashed via esptool"),
}
}
async fn fetch_meshtastic_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
// zip rather than shipping per-board top-level assets — both Heltec V3
// and V4 are esp32s3, so this is the right zip for both (confirmed live
// against v2.7.26.54e0d8d).
let zip_asset = release
.assets
.iter()
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
let version = zip_asset
.name
.strip_prefix("firmware-esp32s3-")
.and_then(|s| s.strip_suffix(".zip"))
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
.to_string();
let zip_path = cache.join(&zip_asset.name);
if tokio::fs::metadata(&zip_path).await.is_err() {
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
} else {
job.push_log(format!("Using cached {}", zip_asset.name)).await;
}
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
// partition table + app) meant to be written at offset 0x0 on a freshly
// erased chip — confirmed by inspecting the real zip's contents, as
// opposed to the plain "*.bin" OTA-update image which assumes an
// existing bootloader/partition table already on the chip.
let entry_name = format!(
"firmware-{}-{}.factory.bin",
board.meshtastic_id(),
version
);
let out_path = cache.join(&entry_name);
if tokio::fs::metadata(&out_path).await.is_ok() {
return Ok(out_path);
}
job.push_log(format!(
"Extracting {entry_name} from {}",
zip_asset.name
))
.await;
let zip_path_owned = zip_path.clone();
let entry_name_owned = entry_name.clone();
let out_path_owned = out_path.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
let mut entry = archive
.by_name(&entry_name_owned)
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
let mut out =
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
Ok(())
})
.await
.context("Firmware extraction task panicked")??;
Ok(out_path)
}
async fn fetch_meshcore_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
// Upstream's casing differs between boards (Heltec_v3_... vs
// heltec_v4_...) — match case-insensitively on the exact per-board
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
// variants (there's a "_tft_" in between, so a straight substring match
// on "heltec_v4_companion_radio_usb" is already safe).
let needle = match board {
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
};
let asset = release
.assets
.iter()
.find(|a| {
let lower = a.name.to_lowercase();
lower.contains(needle) && lower.ends_with("-merged.bin")
})
.ok_or_else(|| {
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
})?;
let out_path = cache.join(&asset.name);
if tokio::fs::metadata(&out_path).await.is_ok() {
job.push_log(format!("Using cached {}", asset.name)).await;
return Ok(out_path);
}
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
Ok(out_path)
}
async fn download_to_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Downloading).await;
let resp = client
.get(url)
.send()
.await
.context("Starting firmware download")?
.error_for_status()
.context("Firmware download returned an error status")?;
let total = resp.content_length();
let tmp = dest.with_extension("part");
let mut file = tokio::fs::File::create(&tmp)
.await
.context("Creating firmware download file")?;
let mut stream = resp.bytes_stream();
let mut downloaded: u64 = 0;
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Reading firmware download stream")?;
file.write_all(&chunk)
.await
.context("Writing firmware download")?;
downloaded += chunk.len() as u64;
if let Some(total) = total {
if total > 0 {
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
.await;
}
}
}
file.flush().await.ok();
tokio::fs::rename(&tmp, dest)
.await
.context("Finalizing firmware download")?;
job.push_log(format!(
"Downloaded {} ({downloaded} bytes)",
dest.display()
))
.await;
Ok(())
}
/// Both Heltec V3 and V4 are ESP32-S3 boards.
const ESPTOOL_CHIP: &str = "esp32s3";
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
job.set_stage(FlashStage::Erasing).await;
let mut erase = Command::new("esptool");
erase.args(["--chip", ESPTOOL_CHIP, "--port", path, "erase_flash"]);
run_streamed(erase, job)
.await
.context("esptool erase_flash failed")?;
job.set_stage(FlashStage::Writing).await;
let mut write = Command::new("esptool");
write.args(["--chip", ESPTOOL_CHIP, "--port", path, "write_flash", "0x0"]);
write.arg(image);
run_streamed(write, job)
.await
.context("esptool write_flash failed")?;
Ok(())
}
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
fn rnodeconf_bin() -> String {
std::env::var("ARCHY_RNODECONF_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
}
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
/// --help` on real hardware) — this is the RNode-side equivalent of our
/// "always erase before write" default, since autoinstall doesn't try to
/// preserve any existing on-device state.
async fn rnodeconf_autoinstall(path: &str, job: &Arc<FlashJob>) -> Result<()> {
job.set_stage(FlashStage::Autoinstalling).await;
let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf")
};
cmd.args(["--autoinstall", path]);
run_streamed(cmd, job)
.await
.context("archy-rnodeconf --autoinstall failed")
}
// ─── Subprocess streaming ────────────────────────────────────────────────
fn percent_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
}
async fn run_streamed(mut cmd: Command, job: &Arc<FlashJob>) -> Result<()> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
// the chip in a worse state than either finished or unstarted (see the
// cancellation-safety note in mesh flashing docs). The job is expected
// to run to completion or fail on its own.
let mut child = cmd.spawn().context("Failed to start subprocess")?;
let mut tasks = Vec::new();
if let Some(stdout) = child.stdout.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(cap) = percent_regex().captures(&line) {
if let Ok(pct) = cap[1].parse::<u8>() {
job.set_percent(pct).await;
}
}
job.push_log(line).await;
}
}));
}
if let Some(stderr) = child.stderr.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
job.push_log(line).await;
}
}));
}
let status = child.wait().await.context("Waiting for subprocess")?;
for t in tasks {
let _ = t.await;
}
if !status.success() {
anyhow::bail!("Command exited with {status}");
}
Ok(())
}

View File

@ -8,6 +8,7 @@
pub mod alerts;
pub mod bitcoin_relay;
pub mod crypto;
pub mod flash;
pub mod listener;
pub mod meshtastic;
pub mod message_types;

View File

@ -110,6 +110,47 @@ lands].
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
tested on real hardware vs. code-reviewed only as this is run.
1. ❑ From the hot-swap modal's step 1 (device already probed), press
**Flash Firmware…** → new step shows firmware-family + board pickers and
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
family, board, AND the checkbox are all set.
2. ❑ Confirm what's currently on the test stick via the existing probe
BEFORE flashing it — don't flash the only known-good device without a
fallback board on hand.
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
only exercise a primary/in-use stick once the flow is proven safe.
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
tail update live, ends at "Flash complete".
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
the esp32s3 release zip.
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
completes (no raw esptool erase/write step for this family — see
`mesh/flash.rs` doc comment).
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
by vid:pid. Board auto-detect should fail closed for it every time
(manual board selection required, "couldn't confirm automatically"
warning shown) — this is expected steady-state behavior, not a gap to
close later.
8. ❑ After a successful flash, the modal automatically re-probes and shows
the NEW firmware's badge/details — same as unplugging and replugging
(Section E item 3), but without physically touching the cable.
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
or point at a bad cached asset) — confirm the error surfaces in the
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
rnodeconf command) actually get the board back to a flashable state.
10. ❑ Cancel button only appears (and only works) while still in the
"Downloading firmware…" stage — once erasing/writing starts, no cancel
affordance is offered.
---
After this passes: fold the batch + other agent's work into the next release

View File

@ -443,6 +443,52 @@ free -h
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
- If on WiFi, try wired Ethernet for more stable connectivity
### 21. LoRa radio firmware flash failed / board unresponsive
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
an error, or the radio no longer enumerates as a serial device after a flash
attempt.
**Diagnosis**:
```bash
# Poll the flash job's last-known stage/error directly
curl -s http://localhost:5678/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"mesh.flash-status","params":{}}'
# Confirm the board is still enumerating at all
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
# esptool/rnodeconf binaries present?
which esptool; ls -la /usr/local/bin/archy-rnodeconf
```
**Solutions**:
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
this is expected risk of the "always erase first" default, not a bug.
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
state esptool can always talk to, regardless of what firmware (if any) is
currently on it.
- With the board in bootloader mode, a manual recovery flash can be run
directly over SSH without the UI:
```bash
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
```
- For Reticulum/RNode boards, the equivalent manual recovery is
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
sequence the UI triggers.
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
installed by the last `self-update.sh` run — check
`sudo journalctl -u archipelago-update` for install failures, or install
`esptool` via `sudo apt-get install esptool` directly.
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
for the next detection poll) — the hot-swap modal re-probes automatically
and shows whatever firmware is actually on the board now.
---
## General Maintenance

View File

@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
ca-certificates \
openssl \
chrony \
iputils-ping \
esptool \
python3-venv \
binutils \
libpython3.13 \
locales \
console-setup \
keyboard-configuration \

View File

@ -1,7 +1,7 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Apply Archipelago Settings' : 'Flash Firmware'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
@ -96,9 +96,106 @@
"Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time.
</p>
<button
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
:disabled="!!connecting"
@click="openFlashStep"
>
Flash Firmware
</button>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div>
<!-- Step 3: erase + reflash destructive, opt-in only -->
<div v-else-if="step === 'flash'">
<template v-if="!flashJob?.active && flashJob?.stage !== 'done'">
<p class="text-white/60 text-xs mb-3">
Downloads the latest firmware from upstream and writes it to
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
<select v-model="flashFamily" 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="">Choose</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum RNode</option>
</select>
</div>
<div>
<label class="block text-sm text-white/80 mb-1">Board</label>
<select v-model="flashBoard" 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="">Choose</option>
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically double check before flashing.
Flashing the wrong board's image can brick it.
</p>
</div>
</div>
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
<label class="flex items-start gap-2 text-xs text-red-300">
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
<span>
This <strong>erases the entire chip</strong>, including any existing
keys, identity, and contacts. This cannot be undone.
</span>
</label>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
@click="startFlash"
>
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
</button>
</div>
</template>
<!-- Progress -->
<template v-else>
<div class="text-center py-2">
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full bg-orange-400 transition-all"
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
></div>
</div>
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
</div>
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
</div>
<div class="flex gap-2 mt-4">
<button
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
class="glass-button px-4 py-2 rounded-lg text-sm"
@click="cancelFlash"
>
Cancel
</button>
<button
v-if="!flashJob?.active"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="closeFlashStep"
>
Done
</button>
</div>
</template>
</div>
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-3">
@ -185,7 +282,7 @@
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
@ -194,7 +291,7 @@ const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const step = ref<1 | 2 | 'flash'>(1)
const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('')
const probing = ref(false)
@ -259,7 +356,10 @@ const rfPreset = computed(() => {
// (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => {
if (!visible) return
if (!visible) {
stopFlashPoll()
return
}
step.value = 1
error.value = ''
imageFailed.value = false
@ -344,6 +444,101 @@ async function applySetup() {
connecting.value = false
}
}
// Step 3: erase + reflash
const flashFamily = ref<FlashFirmwareFamily | ''>('')
const flashBoard = ref<FlashBoard | ''>('')
const flashConfirmed = ref(false)
const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
// Best-effort guess from the already-resolved board image label the user
// can always override; a wrong guess just means the checkbox below draws
// their attention to the warning before anything destructive happens.
const boardAutoDetected = computed(() => /v4/i.test(deviceImage.value.label) || /v3/i.test(deviceImage.value.label))
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
case 'done': return 'Flash complete'
case 'failed': return 'Flash failed'
default: return ''
}
})
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = /v4/i.test(deviceImage.value.label) ? 'heltec-v4' : /v3/i.test(deviceImage.value.label) ? 'heltec-v3' : ''
flashConfirmed.value = false
flashJob.value = null
error.value = ''
step.value = 'flash'
}
function stopFlashPoll() {
if (flashPollTimer) {
clearInterval(flashPollTimer)
flashPollTimer = null
}
}
async function pollFlashStatus() {
try {
const status = await mesh.flashStatus()
flashJob.value = status
if (!status.active) {
stopFlashPoll()
if (status.done && !status.error) {
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
// card reflects whatever firmware is actually on the board now.
const path = devicePath.value
probing.value = true
try {
probe.value = await mesh.probeDevice(path)
} catch {
probe.value = null
} finally {
probing.value = false
}
}
}
} catch {
stopFlashPoll()
}
}
async function startFlash() {
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
starting.value = true
error.value = ''
try {
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
stopFlashPoll()
flashPollTimer = setInterval(pollFlashStatus, 1500)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
} finally {
starting.value = false
}
}
async function cancelFlash() {
try {
await mesh.flashCancel()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to cancel'
}
}
function closeFlashStep() {
stopFlashPoll()
step.value = 1
}
</script>
<style scoped>

View File

@ -55,6 +55,23 @@ export interface MeshDeviceProbe {
max_contacts: number | null
}
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus {
active: boolean
board?: FlashBoard
family?: FlashFirmwareFamily
path?: string
stage?: FlashStage
percent?: number | null
log_tail?: string[]
done?: boolean
error?: string | null
}
/** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams {
enabled?: boolean
@ -343,6 +360,32 @@ export const useMeshStore = defineStore('mesh', () => {
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
})
}
/** Available firmware version(s) for a family v1 only ever returns
* ["latest"], since firmware is always fetched from upstream at flash
* time rather than pinned/bundled. */
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
const res = await rpcClient.call<{ versions: string[] }>({
method: 'mesh.flash-list-firmware',
params: { family },
})
return res.versions
}
/** Erase + reflash a detected radio. `board` is optional omit it to let
* the backend auto-resolve from the port's USB vid:pid; if that fails
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
* must ask the user to pick the board explicitly. Always erases first. */
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
await rpcClient.call({
method: 'mesh.flash-device',
params: board ? { path, family, board } : { path, family },
})
}
async function flashStatus(): Promise<FlashJobStatus> {
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
}
async function flashCancel(): Promise<void> {
await rpcClient.call({ method: 'mesh.flash-cancel' })
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */
@ -979,6 +1022,10 @@ export const useMeshStore = defineStore('mesh', () => {
undismissedDetectedDevices,
dismissDetectedDevice,
probeDevice,
flashListFirmware,
flashDevice,
flashStatus,
flashCancel,
startGlobalDetection,
fetchPeers,
fetchMessages,

View File

@ -84,6 +84,36 @@ if ! command -v nano >/dev/null 2>&1; then
fi
fi
if ! command -v ping >/dev/null 2>&1; then
log "Installing iputils-ping..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
ok "ping installed"
else
warn "Unable to install ping automatically; continuing update"
fi
fi
if ! command -v esptool >/dev/null 2>&1; then
log "Installing esptool for LoRa radio firmware flashing..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
ok "esptool installed"
else
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
fi
fi
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
# below (discovered the hard way: ensurepip needs python3-venv, and
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
# of these are pulled in by a bare `python3` package on Debian trixie).
for pkg in python3-venv binutils libpython3.13; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
log "Installing $pkg (reticulum-daemon build prerequisite)..."
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
fi
done
# Fetch latest
log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE"
@ -155,6 +185,30 @@ sudo cp "$BUILT_BIN" "$INSTALL_BIN"
sudo chmod +x "$INSTALL_BIN"
ok "Backend installed"
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
# Non-fatal: archipelago falls back to its dev venv path if the packaged
# binaries aren't present, so a missing/failed build here degrades mesh
# Reticulum support rather than breaking the update. This mirrors
# deploy-to-target.sh's existing manual-deploy step, which until now was the
# only path that ever installed these — a node that only ever received OTA
# self-updates had neither binary.
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
for tool in archy-reticulum-daemon archy-rnodeconf; do
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
sudo chmod +x "/usr/local/bin/$tool"
ok "$tool installed"
else
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
fi
done
else
warn "reticulum-daemon tools build failed — continuing without updating them"
fi
fi
# Build frontend
log "Building Vue frontend (production)..."
cd "$FRONTEND_DIR"