First commit
This commit is contained in:
parent
8213b0aee3
commit
7d31ca5d65
50
core/Cargo.lock
generated
50
core/Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
132
core/archipelago/src/api/rpc/mesh/flash.rs
Normal file
132
core/archipelago/src/api/rpc/mesh/flash.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod flash;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
|
||||
@ -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)),
|
||||
|
||||
645
core/archipelago/src/mesh/flash.rs
Normal file
645
core/archipelago/src/mesh/flash.rs
Normal 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(())
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user