From 7d31ca5d65b01b5c766868a5d0332b57039e728e Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 23 Jul 2026 00:33:55 +0000 Subject: [PATCH] First commit --- core/Cargo.lock | 50 ++ core/archipelago/Cargo.toml | 4 + core/archipelago/src/api/rpc/dispatcher.rs | 4 + core/archipelago/src/api/rpc/mesh/flash.rs | 132 ++++ core/archipelago/src/api/rpc/mesh/mod.rs | 1 + core/archipelago/src/api/rpc/mod.rs | 4 + core/archipelago/src/mesh/flash.rs | 645 ++++++++++++++++++ core/archipelago/src/mesh/mod.rs | 1 + docs/combined-test-plan-2026-07-22.md | 41 ++ docs/troubleshooting.md | 46 ++ .../_archived/build-auto-installer-iso.sh | 5 + .../components/mesh/MeshDeviceSetupModal.vue | 203 +++++- neode-ui/src/stores/mesh.ts | 47 ++ scripts/self-update.sh | 54 ++ 14 files changed, 1233 insertions(+), 4 deletions(-) create mode 100644 core/archipelago/src/api/rpc/mesh/flash.rs create mode 100644 core/archipelago/src/mesh/flash.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 5ab2ea49..3f504dbd 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -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", +] diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9e1cd48a..d29cece0 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -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" diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index bfc27c34..89677c01 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/mesh/flash.rs b/core/archipelago/src/api/rpc/mesh/flash.rs new file mode 100644 index 00000000..c4fd4fc0 --- /dev/null +++ b/core/archipelago/src/api/rpc/mesh/flash.rs @@ -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 { + 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 { + 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, + ) -> Result { + 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, + ) -> Result { + 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 { + 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 { + 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"), + } + } +} diff --git a/core/archipelago/src/api/rpc/mesh/mod.rs b/core/archipelago/src/api/rpc/mesh/mod.rs index fe497876..51b53bc7 100644 --- a/core/archipelago/src/api/rpc/mesh/mod.rs +++ b/core/archipelago/src/api/rpc/mesh/mod.rs @@ -1,5 +1,6 @@ mod assistant; mod bitcoin_ops; +mod flash; mod messaging; mod safety; mod status; diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index fba353c3..9f2cd0a6 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -88,6 +88,9 @@ pub struct RpcHandler { endpoint_rate_limiter: EndpointRateLimiter, response_cache: ResponseCache, mesh_service: Arc>>, + /// 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>>>, /// 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)), diff --git a/core/archipelago/src/mesh/flash.rs b/core/archipelago/src/mesh/flash.rs new file mode 100644 index 00000000..279749d1 --- /dev/null +++ b/core/archipelago/src/mesh/flash.rs @@ -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 `. +//! 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-.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 { + 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, + pub log_tail: Vec, + pub done: bool, + pub error: Option, +} + +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, + /// 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>, +} + +impl FlashJob { + fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc { + 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) { + 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>>>; + +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::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> { + 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, +} + +/// 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>>, + 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, +) -> 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, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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, +) -> 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) -> 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) -> 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 = 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) -> 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::() { + 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(()) +} diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 4b2b76bf..b2e071a2 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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; diff --git a/docs/combined-test-plan-2026-07-22.md b/docs/combined-test-plan-2026-07-22.md index 590e357b..d47adcca 100644 --- a/docs/combined-test-plan-2026-07-22.md +++ b/docs/combined-test-plan-2026-07-22.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 10476296..9b8830cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 + ``` +- 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 diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 546009c3..c5787d6f 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -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 \ diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue index c33e56e5..ae2fe414 100644 --- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue +++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue @@ -1,7 +1,7 @@