2026-08-12 10:55:50 +00:00
// 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
2026-10-05 12:43:49 -04:00
//! image, verify it, then run `write_flash --erase-all 0x0 <image>` with
//! the packaged esptool executable.
2026-08-12 10:55:50 +00:00
//! 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" ,
}
}
}
2026-10-05 12:43:49 -04:00
/// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not
/// board wiring. Require an explicit board until a board-specific identity is
/// available; guessing a Heltec model can write incompatible firmware.
pub fn resolve_flash_board ( _info : & DetectedDeviceInfo ) -> Option < FlashBoard > {
None
2026-08-12 10:55:50 +00:00
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase" )]
pub enum FlashStage {
Downloading ,
2026-10-05 12:43:49 -04:00
Preparing ,
2026-08-12 10:55:50 +00:00
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 ;
/// How long to wait after a successful flash before resuming the mesh
/// listener, so the board finishes its own post-flash boot/reset before we
/// start opening the port (which itself toggles DTR/RTS) again.
const POST_FLASH_SETTLE_DELAY : std ::time ::Duration = std ::time ::Duration ::from_secs ( 5 );
2026-10-05 12:43:49 -04:00
/// Deadline for preparation and warning threshold for the active flasher.
/// A download can be cancelled safely. An active write retains ownership until
/// its subprocess exits, even after this threshold: reporting an aborted job
/// while a detached writer continues would let a retry corrupt the device.
2026-08-12 10:55:50 +00:00
const MAX_JOB_DURATION : std ::time ::Duration = std ::time ::Duration ::from_secs ( 15 * 60 );
/// How long to wait for MeshService::stop() to release the serial port
/// before giving up. Confirmed live 2026-07-23: the listener's own
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
/// between candidates, so stop() can take a while (or, if the loop is
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
/// cycle without leaving a flash request hanging indefinitely if the
/// listener genuinely won't let go.
const STOP_LISTENER_TIMEOUT : std ::time ::Duration = std ::time ::Duration ::from_secs ( 20 );
/// How long to keep retrying the port-free check before giving up.
const PORT_FREE_TIMEOUT : std ::time ::Duration = std ::time ::Duration ::from_secs ( 10 );
/// Confirm nothing else has `path` open by actually opening (and immediately
/// closing) it ourselves. Retries across the timeout since a just-stopped
/// listener's fd can take a moment to actually release even after `stop()`
/// returns (task abort is a request, not an instant guarantee the OS-level
/// resource is gone yet).
async fn wait_for_port_free ( path : & str ) -> Result < () > {
let deadline = tokio ::time ::Instant ::now () + PORT_FREE_TIMEOUT ;
loop {
match serial2_tokio ::SerialPort ::open ( path , 115200 ) {
Ok ( _ ) => return Ok (()),
2026-10-09 08:00:28 -04:00
Err ( error ) if tokio ::time ::Instant ::now () >= deadline => {
return Err ( anyhow ::anyhow! (
"{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port" ,
PORT_FREE_TIMEOUT . as_secs (),
));
}
Err ( _ ) => {}
2026-08-12 10:55:50 +00:00
}
tokio ::time ::sleep ( std ::time ::Duration ::from_millis ( 500 )). await ;
}
}
/// 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" )
}
2026-10-05 12:43:49 -04:00
async fn invalidate_radio_settings_marker ( data_dir : & Path ) -> Result < () > {
// A full-chip flash erased the device's preferences. A previous host-side
// marker is no longer evidence that this radio has the requested settings.
match tokio ::fs ::remove_file ( data_dir . join ( "meshcore-radio-params.json" )). await {
Ok (()) => Ok (()),
Err ( error ) if error . kind () == std ::io ::ErrorKind ::NotFound => Ok (()),
Err ( error ) => Err ( error ). context ( "Firmware written, but old radio-settings marker could not be cleared; reconnect is paused" ),
}
}
2026-08-12 10:55:50 +00:00
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
/// the *entire* request including streaming the response body, which would
/// kill a legitimate large download partway through (Meshtastic's esp32s3
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
/// applies a per-chunk stall timeout, and metadata calls (small JSON
/// responses) get their own short timeout at the call site.
fn github_client () -> Result < reqwest ::Client > {
reqwest ::Client ::builder ()
. user_agent ( "archipelago-mesh-flash" )
. connect_timeout ( std ::time ::Duration ::from_secs ( 10 ))
. build ()
. context ( "Failed to build HTTP client" )
}
/// Applied per-chunk while streaming a firmware download — if the transfer
/// stalls (no bytes for this long) it's treated as a failure, but a slow
/// download that's still making progress is never killed just for taking a
/// while.
const DOWNLOAD_STALL_TIMEOUT : std ::time ::Duration = std ::time ::Duration ::from_secs ( 30 );
/// Applied to metadata calls (GitHub release JSON) — these are small
/// responses with no reason to ever take this long.
const METADATA_TIMEOUT : std ::time ::Duration = std ::time ::Duration ::from_secs ( 20 );
/// 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 ,
2026-10-05 12:43:49 -04:00
#[serde(default)]
size : Option < u64 > ,
#[serde(default)]
digest : Option < String > ,
2026-08-12 10:55:50 +00:00
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name : String ,
assets : Vec < GithubAsset > ,
}
2026-10-05 12:43:49 -04:00
async fn register_flash_job ( handle : & FlashJobHandle , job : & Arc < FlashJob > ) -> Result < () > {
// Keep checking and registering under one lock: concurrent RPCs must
// never start two writers on the same device.
let mut existing = handle . try_write (). map_err ( | _ | anyhow ::anyhow! (
"The radio is being inspected or reconfigured; wait for that operation to finish before flashing"
)) ? ;
if let Some ( current ) = existing . as_ref () {
if ! current . snapshot (). await . done {
anyhow ::bail! ( "A firmware flash is already in progress on this node" );
}
}
* existing = Some ( Arc ::clone ( job ));
Ok (())
}
2026-08-12 10:55:50 +00:00
/// Start a flash job in the background. Returns as soon as the job has been
2026-10-05 12:43:49 -04:00
/// registered — preparation and listener shutdown run in the background.
/// Callers poll `FlashJobHandle` via
2026-08-12 10:55:50 +00:00
/// `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 < () > {
2026-10-05 12:43:49 -04:00
// Missing/corrupt tooling must fail before stopping a working radio or
// registering a job that can never reach the serial device.
if matches! ( family , DeviceType ::Meshtastic | DeviceType ::Meshcore ) {
preflight_esptool (). await ? ;
2026-08-12 10:55:50 +00:00
}
let job = FlashJob ::new ( board , family , path . clone ());
2026-10-05 12:43:49 -04:00
register_flash_job ( handle , & job ). await ? ;
2026-08-12 10:55:50 +00:00
let bg_job = Arc ::clone ( & job );
let bg_service = Arc ::clone ( mesh_service );
let task = tokio ::spawn ( async move {
2026-10-05 12:43:49 -04:00
// Downloads and checksum checks do not need exclusive serial access.
// Keep a working listener alive if upstream/download verification fails.
let prepared_image = if matches! ( family , DeviceType ::Meshcore | DeviceType ::Meshtastic ) {
match tokio ::time ::timeout (
MAX_JOB_DURATION ,
fetch_esptool_image ( board , family , & data_dir , & bg_job ),
)
. await
{
Ok ( Ok ( image )) => Some ( image ),
result => {
let error = match result {
Ok ( Err ( error )) => error ,
_ => anyhow ::anyhow! (
"Firmware download exceeded the time limit; radio was not changed"
),
};
bg_job . fail ( & error ). await ;
return ;
}
}
} else {
None
};
// Cancellation is safe during download. Once listener shutdown starts,
// allow that bounded operation to finish instead of orphaning its task.
bg_job . set_stage ( FlashStage ::Preparing ). await ;
2026-08-12 10:55:50 +00:00
// esptool/archy-rnodeconf need exclusive serial access — release
// the listener's hold on the port before touching it. This USED
// TO run synchronously in start_flash_job before the job was even
// spawned, blocking the RPC call itself on s.stop().await — a real
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
// reconnect/probe sequence that doesn't check its shutdown signal
// between candidates, so stop() never returned. The HTTP request
// timed out client-side ("Operation failed"), while the job
// (already inserted into `handle`) was permanently wedged — nothing
// had been spawned yet to ever mark it done, so every later flash
// attempt failed with "already in progress" until a full restart.
// Now this runs inside the spawned task with its own bounded
// timeout, so the RPC call always returns immediately regardless,
// and a slow-to-stop listener fails the job cleanly instead of
// hanging everything downstream of it forever.
let stop_result = tokio ::time ::timeout ( STOP_LISTENER_TIMEOUT , async {
let mut svc = bg_service . write (). await ;
if let Some ( s ) = svc . as_mut () {
s . stop (). await ;
}
})
. await ;
if stop_result . is_err () {
let err = anyhow ::anyhow! (
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists." ,
STOP_LISTENER_TIMEOUT . as_secs ()
);
bg_job . push_log ( format! ( "ERROR: {err:#} " )). await ;
bg_job . fail ( & err ). await ;
return ;
}
// Belt-and-suspenders port-free check. `stop()` above should have
// fully released the port, but esptool/rnodeconf run as external
// subprocesses for minutes outside our own async runtime — if
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
// fd from an aborted task, anything we haven't anticipated), handing
// the port to the flasher anyway risks exactly the corruption
// confirmed live 2026-07-23: esptool's "device disconnected or
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
// and definitive — before ever starting the flasher.
if let Err ( e ) = wait_for_port_free ( & path ). await {
bg_job . push_log ( format! ( "ERROR: {e:#} " )). await ;
bg_job . fail ( & e ). await ;
return ;
}
// Outer ceiling on top of run_flash's own internal timeouts —
// belt-and-suspenders so that no future hang (network, subprocess,
// anything) can ever wedge the single-flash-job guard permanently
// again the way a stuck download did on 2026-07-23 (every
// subsequent mesh.flash-device call failed with "already in
// progress" until the service was restarted). Generous enough that
// a legitimately slow multi-hundred-MB transfer still completes.
2026-10-05 12:43:49 -04:00
let flash = run_flash (
board ,
family ,
& data_dir ,
& path ,
prepared_image . as_deref (),
& bg_job ,
);
tokio ::pin! ( flash );
let result = match tokio ::time ::timeout ( MAX_JOB_DURATION , & mut flash ). await {
2026-08-12 10:55:50 +00:00
Ok ( inner ) => inner ,
2026-10-05 12:43:49 -04:00
Err ( _ ) if bg_job . snapshot (). await . stage == FlashStage ::Downloading => Err (
anyhow ::anyhow! ( "Firmware download exceeded the time limit; radio was not written" ),
),
Err ( _ ) => {
// Dropping this future does NOT stop its flash subprocess.
// Keep the job busy until it exits, rather than allowing a
// second writer while the first one still owns the device.
bg_job . push_log ( "Flashing is taking longer than expected; waiting for the active tool to exit before allowing another operation" ). await ;
flash . await
}
};
let result = match result {
Ok (()) => invalidate_radio_settings_marker ( & data_dir ). await ,
error => error ,
2026-08-12 10:55:50 +00:00
};
let succeeded = result . is_ok ();
match & result {
Ok (()) => {
bg_job
. push_log ( "Flash completed successfully" . to_string ())
. await ;
info! ( path = % path , board = ? board , family = % family , "LoRa firmware flash succeeded" );
}
Err ( e ) => {
// {:#} (alternate Display) walks the full anyhow context
// chain — plain {} / %e only prints the outermost .context()
// message, which made a real 2026-07-23 esptool failure
// undiagnosable from journalctl alone (just "esptool
// erase_flash failed", no actual esptool stderr).
warn! ( path = % path , error = % format! ( " {e:#} " ), "LoRa firmware flash failed" );
bg_job . push_log ( format! ( "ERROR: {e:#} " )). await ;
}
}
// The board's firmware may now differ from whatever was pinned
// before — clear the pin either way so a later reconnect's strict
// auto-detect order picks up reality instead of getting wedged
// trying the old protocol first.
if let Ok ( mut config ) = super ::load_config ( & data_dir ). await {
config . device_kind = None ;
if let Err ( e ) = super ::save_config ( & data_dir , & config ). await {
warn! ( error = % e , "Failed to clear device_kind pin after flash" );
}
}
if ! succeeded {
2026-10-05 12:43:49 -04:00
if let Err ( error ) = & result {
bg_job . fail ( error ). await ;
}
2026-08-12 10:55:50 +00:00
// Deliberately do NOT auto-restart the listener here. A failed
// flash means we can't vouch for the board's state — reopening
// the port immediately (esptool/rnodeconf's own reset sequence
// plus our open() toggling DTR/RTS again right after) risks
// hammering a marginal device with reconnect attempts. Confirmed
// live 2026-07-23: exactly this sequence left a real Heltec V3
// boot-looping for 5+ minutes after a failed flash. Leave mesh
// stopped; the user reconnects explicitly via the hot-swap
// modal/Mesh page once they've confirmed the board is alive.
warn! (
path = % path ,
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
);
return ;
}
// On success, give the board a moment to finish booting after the
// flash tool's own reset sequence before we start hammering it with
// connection attempts — same reasoning as above, just the
// lower-risk (successful-flash) side of it.
tokio ::time ::sleep ( POST_FLASH_SETTLE_DELAY ). await ;
let mut svc = bg_service . write (). await ;
if let Some ( s ) = svc . as_mut () {
match super ::load_config ( & data_dir ). await {
Ok ( config ) => {
// Only resume if mesh is actually still enabled per the
// CURRENT persisted config — confirmed live 2026-07-23:
// unconditionally forcing a restart here, regardless of
// `enabled`, overrode a user's own concurrent "disable
// mesh" toggle and left the listener running while
// config said disabled. That inconsistent state is what
// made a later legitimate "Keep As Is" click (which
// correctly tries to start on a false→true transition)
// fail with "already running" — the listener had already
// been force-started behind the config's back.
let should_run = config . enabled ;
if let Err ( e ) = s . configure ( config ). await {
warn! ( error = % e , "Failed to resume mesh listener after flash" );
}
if should_run {
if let Err ( e ) = s . start () {
warn! ( error = % e , "Failed to restart mesh listener after flash" );
}
}
}
Err ( e ) => warn! ( error = % e , "Failed to load mesh config after flash" ),
}
}
2026-10-05 12:43:49 -04:00
bg_job . finish (). await ;
2026-08-12 10:55:50 +00:00
});
* job . abort_handle . write (). await = Some ( task . abort_handle ());
Ok (())
}
async fn run_flash (
board : FlashBoard ,
family : DeviceType ,
data_dir : & Path ,
path : & str ,
2026-10-05 12:43:49 -04:00
prepared_image : Option <& Path > ,
2026-08-12 10:55:50 +00:00
job : & Arc < FlashJob > ,
) -> Result < () > {
match family {
DeviceType ::Meshtastic | DeviceType ::Meshcore => {
2026-10-05 12:43:49 -04:00
let image = prepared_image . context ( "Firmware was not prepared before serial access" ) ? ;
esptool_erase_and_write ( path , image , job ). await
2026-08-12 10:55:50 +00:00
}
DeviceType ::Reticulum => {
let lora_region = super ::load_config ( data_dir )
. await
. ok ()
. and_then ( | c | c . lora_region );
rnodeconf_autoinstall ( path , board , lora_region . as_deref (), 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" )
. timeout ( METADATA_TIMEOUT )
. 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" )
. timeout ( METADATA_TIMEOUT )
. 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 )
}) ? ;
2026-10-05 12:43:49 -04:00
anyhow ::ensure! (
Path ::new ( & asset . name )
. file_name ()
. and_then ( | name | name . to_str ())
== Some ( asset . name . as_str ()),
"Invalid firmware asset filename"
);
2026-08-12 10:55:50 +00:00
let out_path = cache . join ( & asset . name );
if tokio ::fs ::metadata ( & out_path ). await . is_ok () {
2026-10-05 12:43:49 -04:00
if verify_meshcore_asset ( & out_path , asset ). await . is_ok () {
job . push_log ( format! ( "Using verified cached {} " , asset . name ))
. await ;
return Ok ( out_path );
}
tokio ::fs ::remove_file ( & out_path )
. await
. context ( "Removing invalid cached firmware" ) ? ;
job . push_log ( "Cached firmware failed verification; downloading a fresh copy" )
. await ;
2026-08-12 10:55:50 +00:00
}
download_to_file ( client , & asset . browser_download_url , & out_path , job ). await ? ;
2026-10-05 12:43:49 -04:00
if let Err ( error ) = verify_meshcore_asset ( & out_path , asset ). await {
// A partial/unverified download must not become next attempt's cache.
let _ = tokio ::fs ::remove_file ( & out_path ). await ;
return Err ( error );
}
2026-08-12 10:55:50 +00:00
Ok ( out_path )
}
2026-10-05 12:43:49 -04:00
async fn verify_meshcore_asset ( path : & Path , asset : & GithubAsset ) -> Result < () > {
use sha2 ::{ Digest , Sha256 };
let size = asset
. size
. context ( "MeshCore release did not provide firmware size" ) ? ;
anyhow ::ensure! (
( 1 ..= 16 * 1024 * 1024 ). contains ( & size ),
"Invalid MeshCore firmware size"
);
anyhow ::ensure! (
tokio ::fs ::metadata ( path ). await ? . len () == size ,
"Firmware size mismatch; radio was not written"
);
let expected = asset
. digest
. as_deref ()
. and_then ( | value | value . strip_prefix ( "sha256:" ))
. context ( "MeshCore release did not provide a SHA-256 checksum" ) ? ;
anyhow ::ensure! (
expected . len () == 64 && expected . bytes (). all ( | byte | byte . is_ascii_hexdigit ()),
"Invalid MeshCore release checksum"
);
let actual = hex ::encode ( Sha256 ::digest ( tokio ::fs ::read ( path ). await ? ));
anyhow ::ensure! (
actual . eq_ignore_ascii_case ( expected ),
"Firmware checksum mismatch; radio was not written"
);
Ok (())
}
2026-08-12 10:55:50 +00:00
async fn download_to_file (
client : & reqwest ::Client ,
url : & str ,
dest : & Path ,
job : & Arc < FlashJob > ,
) -> Result < () > {
job . set_stage ( FlashStage ::Downloading ). await ;
// Bound only the wait for the response to *start* (headers) — NOT a
// request-level `.timeout()`, which would cap the whole body transfer
// again (the bug this replaced: a blanket 30s client timeout killed
// large downloads mid-stream). If the server never responds at all,
// this is what stops the job from hanging forever; the per-chunk stall
// timeout below is what guards the body once streaming starts. Without
// this, a server that accepts the TCP connection but never sends
// headers back hangs this call indefinitely — confirmed live
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
// for good, permanently blocking every subsequent flash attempt with
// "already in progress" until the service was restarted.
let resp = tokio ::time ::timeout ( METADATA_TIMEOUT , client . get ( url ). send ())
. await
. context ( "Firmware download server did not respond" ) ?
. 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 ;
loop {
let next = tokio ::time ::timeout ( DOWNLOAD_STALL_TIMEOUT , stream . next ())
. await
. context ( "Firmware download stalled" ) ? ;
let Some ( chunk ) = next else { break };
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 ;
}
}
}
2026-10-05 12:43:49 -04:00
file . flush (). await . context ( "Flushing firmware download" ) ? ;
file . sync_all (). await . context ( "Saving firmware download" ) ? ;
2026-08-12 10:55:50 +00:00
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" ;
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
/// board+adapter combinations — esptool's own docs recommend retrying at a
/// lower baud rate when this happens. Rather than fail the whole job on the
/// first hiccup, retry once at a conservative baud before giving up.
const ESPTOOL_FALLBACK_BAUD : & str = "115200" ;
/// `write_flash --erase-all` erases the whole chip before writing, in one
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
/// esptool_global_args' doc comment) — without it, --erase-all hits the
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
/// does not support function erase_flash", confirmed live 2026-07-23), since
/// esptool's --erase-all is implemented as the same full-chip-erase command,
/// not a per-sector loop.
async fn esptool_erase_and_write ( path : & str , image : & Path , job : & Arc < FlashJob > ) -> Result < () > {
job . set_stage ( FlashStage ::Writing ). await ;
let image_str = image . to_string_lossy (). to_string ();
esptool_with_retry (
path ,
& [ "write_flash" , "--erase-all" , "0x0" , & image_str ],
job ,
)
. await
. context ( "esptool write_flash failed" ) ? ;
Ok (())
}
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
/// appending `--baud 115200` after the subcommand on the retry path
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
/// time, so the fallback-baud retry never actually got a chance to run.
/// Building global args separately from subcommand args keeps this correct
/// by construction instead of relying on call-site ordering.
///
2026-10-05 12:43:49 -04:00
/// Normal stub-loader mode is required for full-chip erase. The packaged
/// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's
/// stripped esptool package alone did not provide that resource, so changing
/// flags to --no-stub cannot repair this operation.
2026-08-12 10:55:50 +00:00
fn esptool_global_args < 'a > ( path : & 'a str , baud : Option <& 'a str > ) -> Vec <& 'a str > {
let mut args = vec! [ "--chip" , ESPTOOL_CHIP , "--port" , path ];
if let Some ( b ) = baud {
args . push ( "--baud" );
args . push ( b );
}
args
}
2026-10-05 12:43:49 -04:00
fn esptool_executable () -> Result < PathBuf > {
let mut candidates = vec! [ PathBuf ::from ( "/usr/local/bin/archy-esptool" )];
if let Some ( paths ) = std ::env ::var_os ( "PATH" ) {
for directory in std ::env ::split_paths ( & paths ) {
for name in [ "esptool" , "esptool.py" ] {
candidates . push ( directory . join ( name ));
}
}
}
executable_from_candidates ( candidates )
}
fn executable_from_candidates ( candidates : impl IntoIterator < Item = PathBuf > ) -> Result < PathBuf > {
use std ::os ::unix ::fs ::PermissionsExt ;
candidates . into_iter (). find ( | path | {
path . is_file () && path . metadata (). is_ok_and ( | metadata | metadata . permissions (). mode () & 0o111 != 0 )
}). ok_or_else ( || anyhow ::anyhow! (
"Radio flashing tools are missing. Install the complete Archipelago update and retry; the radio has not been changed."
))
}
async fn preflight_esptool () -> Result < PathBuf > {
let executable = esptool_executable () ? ;
check_esptool ( & executable ). await ? ;
Ok ( executable )
}
async fn check_esptool ( executable : & Path ) -> Result < () > {
let mut command = Command ::new ( executable );
command
. arg (
if executable
. file_name ()
. is_some_and ( | name | name == "archy-esptool" )
{
"--archy-self-test"
} else {
"version"
},
)
. kill_on_drop ( true );
let output = tokio ::time ::timeout ( std ::time ::Duration ::from_secs ( 20 ), command . output ())
. await
. context ( "Radio flashing tool did not respond; the radio has not been changed" ) ?
. context ( "Radio flashing tool could not start; check its installation and permissions" ) ? ;
anyhow ::ensure! (
output . status . success (),
"Radio flashing tool self-check failed; reinstall the complete update before retrying"
);
Ok (())
}
fn retryable_flash_error ( error : & anyhow ::Error ) -> bool {
if error
. chain ()
. any ( | cause | cause . downcast_ref ::< std ::io ::Error > (). is_some ())
{
return false ;
}
let detail = format! ( " {error:#} " ). to_lowercase ();
[
"failed to connect" ,
"timed out waiting" ,
"invalid head of packet" ,
"serial data stream stopped" ,
]
. iter ()
. any ( | message | detail . contains ( message ))
}
2026-08-12 10:55:50 +00:00
async fn esptool_with_retry ( path : & str , subcommand : & [ & str ], job : & Arc < FlashJob > ) -> Result < () > {
2026-10-05 12:43:49 -04:00
let executable = preflight_esptool (). await ? ;
let mut cmd = Command ::new ( & executable );
2026-08-12 10:55:50 +00:00
cmd . args ( esptool_global_args ( path , None ));
cmd . args ( subcommand );
match run_streamed ( cmd , None , job ). await {
Ok (()) => Ok (()),
2026-10-05 12:43:49 -04:00
Err ( first_err ) if retryable_flash_error ( & first_err ) => {
2026-08-12 10:55:50 +00:00
job . push_log ( format! (
"First attempt failed ( {first_err:#} ); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
))
. await ;
2026-10-05 12:43:49 -04:00
let mut retry = Command ::new ( & executable );
2026-08-12 10:55:50 +00:00
retry . args ( esptool_global_args ( path , Some ( ESPTOOL_FALLBACK_BAUD )));
retry . args ( subcommand );
run_streamed ( retry , None , job )
. await
. context ( format! ( "retry also failed (first attempt: {first_err:#} )" ))
}
2026-10-05 12:43:49 -04:00
Err ( error ) => Err ( error ),
2026-08-12 10:55:50 +00:00
}
}
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
fn rnodeconf_bin () -> String {
std ::env ::var ( "ARCHY_RNODECONF_BIN" )
. unwrap_or_else ( | _ | "/usr/local/bin/archy-rnodeconf" . to_string ())
}
/// True when `name` resolves to an executable on PATH.
fn which_on_path ( name : & str ) -> bool {
std ::env ::var_os ( "PATH" )
. map ( | paths | {
std ::env ::split_paths ( & paths ). any ( | dir | {
let candidate = dir . join ( name );
candidate . is_file ()
})
})
. unwrap_or ( false )
}
/// `--autoinstall`'s "which board is this" step is interactive by design —
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
/// (their bootstrap-time USB identity is often generic, same root cause as
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
/// sequence observed for a Heltec board that already has *some* RNode
/// firmware installed (the common case — a truly blank chip likely skips
/// straight to the same "Device Selection" menu):
/// 1. numbered device-type menu → answer with the menu number
/// 2. "Hit enter to continue" → answer with a blank line
/// 3. numbered band menu → answer with the menu number
/// 4. "Is the above correct? [y/N]" → answer "y"
/// Feeding all four answers up front (rather than watching stdout for each
/// prompt text) works because the menu is always asked in this fixed order
/// for every board that needs (re)provisioning — verified by driving it
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
/// bootstrap → "Device signature validated" on the next probe).
fn rnodeconf_device_menu_number ( board : FlashBoard ) -> & 'static str {
match board {
FlashBoard ::HeltecV3 => "8" ,
FlashBoard ::HeltecV4 => "9" ,
}
}
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
/// (868/915/923 MHz), not the final operating frequency — that's still
/// configured later via the daemon's interface config, same as today. This
/// is a best-effort mapping from the node's persisted Meshtastic-style
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
/// 915 MHz as the broadest-compatibility default.
fn rnodeconf_band_menu_number ( lora_region : Option <& str > ) -> & 'static str {
match lora_region . map ( | s | s . trim (). to_uppercase ()) {
Some ( r ) if r . contains ( "868" ) => "1" ,
Some ( r ) if r . contains ( "923" ) => "3" ,
_ => "2" ,
}
}
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
/// --help` AND a real end-to-end flash 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 ,
board : FlashBoard ,
lora_region : Option <& 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 if which_on_path ( "rnodeconf" ) {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command ::new ( "rnodeconf" )
} else {
// Older ISOs/OTAs never shipped the tool — say so instead of the
// bare "No such file or directory" the spawn would produce.
anyhow ::bail! (
"{bin} is not installed on this node — RNode flashing needs the packaged \
archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \
sideloaded from a dev box). Update the node, then retry."
);
};
cmd . args ([ "--autoinstall" , path ]);
let stdin = format! (
" {} \n\n {} \n y \n " ,
rnodeconf_device_menu_number ( board ),
rnodeconf_band_menu_number ( lora_region )
);
run_streamed ( cmd , Some ( stdin . into_bytes ()), 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 , stdin : Option < Vec < u8 >> , job : & Arc < FlashJob > ) -> Result < () > {
cmd . stdout ( Stdio ::piped ());
cmd . stderr ( Stdio ::piped ());
if stdin . is_some () {
cmd . stdin ( 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" ) ? ;
if let Some ( bytes ) = stdin {
if let Some ( mut child_stdin ) = child . stdin . take () {
child_stdin
. write_all ( & bytes )
. await
. context ( "Writing to subprocess stdin" ) ? ;
}
}
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 () {
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
// stderr (already captured into job.log_tail by the reader tasks
// above) is what actually explains a failure. Confirmed live
// 2026-07-23: a bare "Command exited with exit status: 1" told us
// nothing when esptool's real error was sitting in the log tail the
// whole time, only visible via the UI's live poll, not journald.
let tail : Vec < String > = job
. snapshot ()
. await
. log_tail
. iter ()
. rev ()
. take ( 10 )
. rev ()
. cloned ()
. collect ();
anyhow ::bail! ( "Command exited with {status} \n {}" , tail . join ( " \n " ));
}
Ok (())
}
2026-10-05 12:43:49 -04:00
#[cfg(test)]
mod flashing_regression_tests {
use super ::* ;
#[tokio::test]
async fn full_flash_invalidates_only_radio_settings_marker () {
let dir = tempfile ::tempdir (). unwrap ();
let marker = dir . path (). join ( "meshcore-radio-params.json" );
tokio ::fs ::write ( & marker , b "old settings" ). await . unwrap ();
let other = dir . path (). join ( "mesh-config.json" );
tokio ::fs ::write ( & other , b "preserved" ). await . unwrap ();
invalidate_radio_settings_marker ( dir . path ()). await . unwrap ();
assert! ( ! marker . exists ());
assert_eq! ( tokio ::fs ::read ( & other ). await . unwrap (), b "preserved" );
invalidate_radio_settings_marker ( dir . path ()). await . unwrap ();
tokio ::fs ::create_dir ( & marker ). await . unwrap ();
assert! ( invalidate_radio_settings_marker ( dir . path ()). await . is_err ());
}
use std ::os ::unix ::fs ::PermissionsExt ;
#[tokio::test]
async fn meshcore_cache_requires_release_size_and_checksum () {
use sha2 ::{ Digest , Sha256 };
let dir = tempfile ::tempdir (). unwrap ();
let file = dir . path (). join ( "fixture.bin" );
tokio ::fs ::write ( & file , b "firmware fixture" ). await . unwrap ();
let mut asset = GithubAsset {
name : "fixture.bin" . into (),
browser_download_url : "https://example.invalid/fixture.bin" . into (),
size : Some ( 16 ),
digest : Some ( format! (
"sha256: {} " ,
hex ::encode ( Sha256 ::digest ( b "firmware fixture" ))
)),
};
assert! ( verify_meshcore_asset ( & file , & asset ). await . is_ok ());
tokio ::fs ::write ( & file , b "tampered fixture" ). await . unwrap ();
assert! ( verify_meshcore_asset ( & file , & asset ). await . is_err ());
tokio ::fs ::write ( & file , b "short" ). await . unwrap ();
assert! ( verify_meshcore_asset ( & file , & asset ). await . is_err ());
tokio ::fs ::write ( & file , b "firmware fixture" ). await . unwrap ();
asset . digest = None ;
assert! ( verify_meshcore_asset ( & file , & asset ). await . is_err ());
}
#[test]
fn generic_usb_ids_do_not_select_firmware () {
for ( vid , pid ) in [( "10c4" , "ea60" ), ( "303a" , "1001" )] {
let info = DetectedDeviceInfo {
path : "/dev/fixture" . into (),
vid : Some ( vid . into ()),
pid : Some ( pid . into ()),
product : None ,
manufacturer : None ,
plugged_at : None ,
};
assert_eq! ( resolve_flash_board ( & info ), None );
}
}
#[test]
fn absent_nonexecutable_and_directory_candidates_are_rejected () {
let dir = tempfile ::tempdir (). unwrap ();
let file = dir . path (). join ( "flasher" );
std ::fs ::write ( & file , "#!/bin/sh \n exit 0 \n " ). unwrap ();
std ::fs ::set_permissions ( & file , std ::fs ::Permissions ::from_mode ( 0o600 )). unwrap ();
assert! ( executable_from_candidates ([
dir . path (). join ( "absent" ),
file . clone (),
dir . path (). to_path_buf ()
])
. is_err ());
std ::fs ::set_permissions ( & file , std ::fs ::Permissions ::from_mode ( 0o700 )). unwrap ();
assert_eq! ( executable_from_candidates ([ file . clone ()]). unwrap (), file );
}
#[tokio::test]
async fn flasher_preflight_reports_missing_interpreter_and_failed_self_check () {
let dir = tempfile ::tempdir (). unwrap ();
let file = dir . path (). join ( "archy-esptool" );
for script in [ "#!/missing/python \n " , "#!/bin/sh \n exit 7 \n " ] {
std ::fs ::write ( & file , script ). unwrap ();
std ::fs ::set_permissions ( & file , std ::fs ::Permissions ::from_mode ( 0o700 )). unwrap ();
assert! ( check_esptool ( & file ). await . is_err ());
}
std ::fs ::write ( & file , "#!/bin/sh \n [ \" $1 \" = --archy-self-test ] \n " ). unwrap ();
assert! ( check_esptool ( & file ). await . is_ok ());
}
#[tokio::test]
async fn flash_registration_excludes_other_writers_and_active_probes () {
let handle = new_job_handle ();
let first = FlashJob ::new (
FlashBoard ::HeltecV3 ,
DeviceType ::Meshcore ,
"/dev/fixture" . into (),
);
let second = FlashJob ::new (
FlashBoard ::HeltecV3 ,
DeviceType ::Meshcore ,
"/dev/fixture" . into (),
);
let probe = handle . read (). await ;
assert! ( register_flash_job ( & handle , & first ). await . is_err ());
drop ( probe );
let ( a , b ) = tokio ::join! (
register_flash_job ( & handle , & first ),
register_flash_job ( & handle , & second )
);
assert_eq! ( usize ::from ( a . is_ok ()) + usize ::from ( b . is_ok ()), 1 );
handle . read (). await . as_ref (). unwrap (). finish (). await ;
let next = FlashJob ::new (
FlashBoard ::HeltecV3 ,
DeviceType ::Meshcore ,
"/dev/fixture" . into (),
);
assert! ( register_flash_job ( & handle , & next ). await . is_ok ());
}
#[test]
fn retries_only_known_serial_transport_failures () {
for kind in [
std ::io ::ErrorKind ::NotFound ,
std ::io ::ErrorKind ::PermissionDenied ,
] {
assert! ( ! retryable_flash_error (
& anyhow ::Error ::from ( std ::io ::Error ::from ( kind ))
. context ( "Failed to start subprocess" )
));
}
for message in [
"Wrong chip" ,
"Invalid image" ,
"module missing" ,
"permission denied" ,
"port is busy" ,
] {
assert! ( ! retryable_flash_error ( & anyhow ::anyhow! ( message )));
}
assert! ( retryable_flash_error ( & anyhow ::anyhow! (
"Failed to connect to ESP32-S3: timed out waiting for packet header"
)));
assert_eq! (
esptool_global_args ( "/dev/fixture" , Some ( "115200" )),
[
"--chip" ,
"esp32s3" ,
"--port" ,
"/dev/fixture" ,
"--baud" ,
"115200"
]
);
}
}