|
|
|
@@ -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(())
|
|
|
|
|
}
|