From e0ac328db9fad948760b058e6cb98cd7e07a3af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:36:21 +0200 Subject: [PATCH 1/3] Add LongFred HTTP firmware update over Soft-AP and LAN. Queue POST /api/v1/firmware as an updateFirmware job, discover STA hosts via mDNS, and keep settings programming on Soft-AP only. Co-authored-by: Cursor --- crates/wireless-programmer/src/cli/client.rs | 55 +++- crates/wireless-programmer/src/cli/mod.rs | 43 ++- crates/wireless-programmer/src/drivers.rs | 30 +- crates/wireless-programmer/src/ipc.rs | 141 ++++++--- crates/wireless-programmer/src/jobs.rs | 50 ++- crates/wireless-programmer/src/runtime.rs | 287 ++++++++++++++++-- .../tests/fake_mode_test.rs | 28 +- crates/wp-client/src/client.rs | 35 ++- crates/wp-client/src/lib.rs | 4 +- crates/wp-core/src/capabilities.rs | 3 + crates/wp-drivers/src/longfred/constants.rs | 6 + crates/wp-drivers/src/longfred/mod.rs | 32 +- crates/wp-drivers/src/wifred/mod.rs | 1 + crates/wp-drivers/tests/longfred_write.rs | 21 ++ crates/wp-fake/src/longfred.rs | 47 ++- crates/wp-link/src/lib.rs | 2 + crates/wp-link/src/mdns.rs | 174 +++++++++++ crates/wp-proto/src/results.rs | 5 + crates/wp-proto/src/wire.rs | 44 +++ docs/api.md | 53 +++- docs/cli.md | 41 ++- docs/drivers/longfred.md | 18 +- docs/drivers/wifred.md | 1 + docs/go-client.md | 4 +- go/client/client.go | 72 +++-- 25 files changed, 1066 insertions(+), 131 deletions(-) create mode 100644 crates/wp-link/src/mdns.rs diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 01a24d3..f3ca31a 100644 --- a/crates/wireless-programmer/src/cli/client.rs +++ b/crates/wireless-programmer/src/cli/client.rs @@ -7,7 +7,7 @@ use wp_client::{CandidateRef, JobFrame, JobStateWire}; use super::{ build_client, resolve_socket, CliError, Command, CommonArgs, IdentifyArgs, JobAction, JobArgs, - ProbeArgs, + ProbeArgs, ScanArgs, UpdateFirmwareArgs, }; type HandlerResult = Result<(), CliError>; @@ -19,6 +19,7 @@ pub fn run(command: Command, socket_override: Option) -> ExitCode { Command::Scan(a) => scan(&socket, a), Command::Probe(a) => probe(&socket, a), Command::Program(a) => super::program::run(&socket, a), + Command::UpdateFirmware(a) => update_firmware(&socket, a), Command::Identify(a) => identify(&socket, a), Command::LinkStatus(a) => link_status(&socket, a), Command::Hello(a) => hello(&socket, a), @@ -98,13 +99,61 @@ fn print_scan(candidates: &[wp_client::CandidateWire], json: bool) { } } -fn scan(socket: &Path, args: CommonArgs) -> HandlerResult { +fn scan(socket: &Path, args: ScanArgs) -> HandlerResult { let c = build_client(socket, args.common.timeout); - let candidates = c.scan()?; + let mode = if args.mode == "lan" { + wp_client::ReachMode::Lan + } else { + wp_client::ReachMode::Ap + }; + let candidates = c.scan_mode(mode)?; print_scan(&candidates, args.common.json); Ok(()) } +fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { + if !args.file.is_file() { + return Err(CliError::File { + path: args.file.display().to_string(), + message: "not a file".into(), + }); + } + let mode = if args.mode == "lan" || args.host.is_some() { + wp_client::ReachMode::Lan + } else { + wp_client::ReachMode::Ap + }; + let key = args + .key + .clone() + .or_else(|| args.host.clone()) + .ok_or_else(|| CliError::Usage("provide --key and/or --host".into()))?; + let c = build_client(socket, args.common.timeout); + let candidate = wp_client::CandidateRef { + driver: args.driver.clone(), + key, + }; + let started = c.update_firmware( + mode, + Some(candidate), + args.file.display().to_string(), + args.host.clone(), + )?; + if args.no_watch { + if args.common.json { + print_json(&started); + } else { + println!("job {}", started.job_id); + } + return Ok(()); + } + let json = args.common.json; + let last = c + .job_watch(&started.job_id)? + .drain_with(|frame| print_frame(frame, json))?; + outcome(last) +} + fn probe(socket: &Path, args: ProbeArgs) -> HandlerResult { let c = build_client(socket, args.common.timeout); let info = c.probe(candidate(&args.driver, &args.key))?; diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index 47c4ee9..065d26d 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -53,12 +53,14 @@ pub struct Cli { pub enum Command { /// Run the IPC daemon (default when no subcommand is given). Daemon(DaemonArgs), - /// Enumerate candidate devices on the radio. - Scan(CommonArgs), + /// Enumerate candidate devices on the radio (or LAN mDNS). + Scan(ScanArgs), /// Read a single candidate's device info. Probe(ProbeArgs), /// Start a programming job and stream its progress. Program(ProgramArgs), + /// Upload firmware (`.app.bin`) over HTTP Soft-AP or LAN. + UpdateFirmware(UpdateFirmwareArgs), /// Blink a device's LED so an operator can find it. Identify(IdentifyArgs), /// Report radio/link state. @@ -83,13 +85,48 @@ pub struct ClientCommon { } /// Arguments for subcommands that take only the shared client flags -/// (`scan`, `link-status`, `hello`). +/// (`link-status`, `hello`). #[derive(Debug, Parser)] pub struct CommonArgs { #[command(flatten)] pub common: ClientCommon, } +/// `scan` arguments. +#[derive(Debug, Parser)] +pub struct ScanArgs { + #[command(flatten)] + pub common: ClientCommon, + /// `ap` (Soft-AP radio, default) or `lan` (mDNS `_longfred-ota._tcp`). + #[arg(long, default_value = "ap", value_parser = ["ap", "lan"])] + pub mode: String, +} + +/// `update-firmware` arguments. +#[derive(Debug, Parser)] +pub struct UpdateFirmwareArgs { + #[command(flatten)] + pub common: ClientCommon, + /// `ap` (Soft-AP, default) or `lan` (layout Wi‑Fi, no radio). + #[arg(long, default_value = "ap", value_parser = ["ap", "lan"])] + pub mode: String, + /// Driver identifier (default `longfred`). + #[arg(long, default_value = "longfred")] + pub driver: String, + /// Candidate key (BSSID in AP mode, IPv4 in LAN mode). + #[arg(long)] + pub key: Option, + /// LAN IPv4 (skips mDNS). Implies `--mode lan` when set alone with `--file`. + #[arg(long)] + pub host: Option, + /// Path to ESP32-C6 `.app.bin`. + #[arg(long)] + pub file: PathBuf, + /// Do not stream job progress after starting the job. + #[arg(long)] + pub no_watch: bool, +} + /// `probe` arguments. #[derive(Debug, Parser)] pub struct ProbeArgs { diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index a11126e..a35730e 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -7,7 +7,7 @@ use std::net::Ipv4Addr; use wp_core::{ CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, Observation, - Outcome, ProgressSink, ProgramRequest, Transport, + Outcome, ProgramRequest, ProgressSink, Transport, }; use wp_drivers::{LongFredDriver, WiFredDriver}; @@ -146,6 +146,34 @@ impl DriverRegistry { } } + /// Whether this driver can upload firmware over HTTP. + pub fn supports_firmware_update(&self, driver: Driver) -> bool { + match driver { + Driver::WiFred => self.wifred.capabilities().supports_firmware_update, + Driver::LongFred => self.longfred.capabilities().supports_firmware_update, + } + } + + /// Upload firmware over the supplied transport. + pub async fn update_firmware( + &self, + driver: Driver, + transport: Transport<'_>, + image: &[u8], + progress: &mut dyn ProgressSink, + ) -> Result { + match driver { + Driver::WiFred => Err(DriverError::Other( + "firmware update is not supported".into(), + )), + Driver::LongFred => { + self.longfred + .update_firmware(transport, image, progress) + .await + } + } + } + /// Borrow the WiFred driver. pub fn wifred(&self) -> &WiFredDriver { &self.wifred diff --git a/crates/wireless-programmer/src/ipc.rs b/crates/wireless-programmer/src/ipc.rs index 49c0dad..5338e87 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -210,8 +210,17 @@ impl ServerInner { error: None, }, RequestKind::Scan => { - tracing::info!("scan started"); - match self.runtime.scan() { + let lan = matches!( + req.params, + Some(Params::Scan(ref p)) if p.mode == wp_proto::ReachMode::Lan + ); + tracing::info!(lan, "scan started"); + let scanned = if lan { + self.runtime.scan_lan() + } else { + self.runtime.scan() + }; + match scanned { Ok(found) => { let candidates: Vec = found .iter() @@ -255,39 +264,31 @@ impl ServerInner { } } RequestKind::Probe => match req.params { - Some(Params::Probe(p)) => { - match self.runtime.registry().driver_for(&p.candidate) { - Some(d) => match self.runtime.probe(d, &p.candidate.key) { - Ok(info) => Response { - kind: RequestKind::Probe, - result: Some(ResultBody::Probe(device_info_from_probe( - d.id_str(), - &p.candidate.key, - &info, - ))), - error: None, - }, - Err(e) => { - err_response(RequestKind::Probe, "probe_failed", &e.to_string()) - } + Some(Params::Probe(p)) => match self.runtime.registry().driver_for(&p.candidate) { + Some(d) => match self.runtime.probe(d, &p.candidate.key) { + Ok(info) => Response { + kind: RequestKind::Probe, + result: Some(ResultBody::Probe(device_info_from_probe( + d.id_str(), + &p.candidate.key, + &info, + ))), + error: None, }, - None => err_response( - RequestKind::Probe, - "unknown_driver", - "no driver owns this candidate", - ), - } - } + Err(e) => err_response(RequestKind::Probe, "probe_failed", &e.to_string()), + }, + None => err_response( + RequestKind::Probe, + "unknown_driver", + "no driver owns this candidate", + ), + }, _ => err_response(RequestKind::Probe, "bad_params", "missing params"), }, RequestKind::Program => match req.params { Some(Params::Program(p)) => { - let roster_addrs: Vec = p - .request - .roster - .iter() - .filter_map(|e| e.address) - .collect(); + let roster_addrs: Vec = + p.request.roster.iter().filter_map(|e| e.address).collect(); tracing::info!( driver = %p.candidate.driver, key = %p.candidate.key, @@ -302,11 +303,7 @@ impl ServerInner { ); match self.runtime.registry().driver_for(&p.candidate) { Some(d) => { - match self.runtime.submit_program( - d, - &p.candidate.key, - p.request, - ) { + match self.runtime.submit_program(d, &p.candidate.key, p.request) { Ok(id) => { tracing::info!( job_id = %id.0, @@ -373,11 +370,7 @@ impl ServerInner { }, RequestKind::JobWatch => { // Handled in handle_conn via stream_job_watch. - err_response( - RequestKind::JobWatch, - "internal", - "job.watch must stream", - ) + err_response(RequestKind::JobWatch, "internal", "job.watch must stream") } RequestKind::JobCancel => match req.params { Some(Params::Job(p)) => { @@ -425,6 +418,69 @@ impl ServerInner { error: None, } } + RequestKind::UpdateFirmware => match req.params { + Some(Params::UpdateFirmware(p)) => { + let driver_id = p + .candidate + .as_ref() + .map(|c| c.driver.as_str()) + .unwrap_or("longfred"); + let key = p + .host + .clone() + .or_else(|| p.candidate.as_ref().map(|c| c.key.clone())) + .unwrap_or_default(); + if key.is_empty() { + return err_response( + RequestKind::UpdateFirmware, + "bad_params", + "candidate.key or host is required", + ); + } + if p.mode == wp_proto::ReachMode::Lan { + if let Some(h) = p.host.as_deref() { + self.runtime.cache_lan_host(h, None); + } + } + match crate::drivers::Driver::from_id(driver_id) { + Some(d) => { + match self.runtime.submit_firmware( + d, + &key, + crate::jobs::FirmwareJob { + mode: p.mode, + path: std::path::PathBuf::from(&p.path), + host: p.host, + }, + ) { + Ok(id) => Response { + kind: RequestKind::UpdateFirmware, + result: Some(ResultBody::UpdateFirmware( + wp_proto::ProgramResult { + job_id: id.0.clone(), + }, + )), + error: None, + }, + Err(e) => { + let code = match &e { + crate::jobs::JobError::Busy(_) => "busy", + crate::jobs::JobError::FirmwareUnsupported => "driverError", + _ => "firmware_failed", + }; + err_response(RequestKind::UpdateFirmware, code, &e.to_string()) + } + } + } + None => err_response( + RequestKind::UpdateFirmware, + "unknown_driver", + "no driver owns this candidate", + ), + } + } + _ => err_response(RequestKind::UpdateFirmware, "bad_params", "missing params"), + }, } } } @@ -518,7 +574,10 @@ fn device_info_from_probe( .get("firmwareRevision") .and_then(|v| v.as_str()) .map(str::to_string); - let battery_mv = info.get("batteryMv").and_then(|v| v.as_u64()).map(|n| n as u32); + let battery_mv = info + .get("batteryMv") + .and_then(|v| v.as_u64()) + .map(|n| n as u32); wp_proto::DeviceInfoWire { driver: driver.into(), key: key.into(), diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index 2bb9dbd..ff7dd38 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -9,11 +9,14 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::DriverError; -use wp_proto::ProgramRequestWire; +use wp_proto::{ProgramRequestWire, ReachMode}; /// Overall job deadline. pub const JOB_DEADLINE: Duration = Duration::from_secs(120); +/// Firmware POST deadline (matches LongFred HTTP timeout). +pub const FIRMWARE_DEADLINE: Duration = Duration::from_secs(120); + /// A job identifier. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct JobId(pub String); @@ -101,6 +104,29 @@ pub enum JobError { /// The driver failed at runtime. #[error("driver: {0}")] Driver(#[from] DriverError), + /// Firmware update is not supported by this driver. + #[error("firmware update is not supported")] + FirmwareUnsupported, +} + +/// Payload stored for the worker. +#[derive(Debug, Clone)] +pub enum JobPayload { + /// Soft-AP settings programming. + Program(ProgramRequestWire), + /// HTTP firmware upload. + Firmware(FirmwareJob), +} + +/// Firmware job parameters (image stays on disk). +#[derive(Debug, Clone)] +pub struct FirmwareJob { + /// Soft-AP or LAN. + pub mode: ReachMode, + /// Path to `.app.bin`. + pub path: std::path::PathBuf, + /// Explicit LAN IPv4, when set. + pub host: Option, } /// Internal job record. @@ -108,7 +134,7 @@ struct JobRecord { snapshot: JobSnapshot, frames: Vec, cancel: bool, - request: Option, + payload: Option, } /// A shared job registry. Only one job may be active at a time. @@ -137,12 +163,12 @@ impl JobRegistry { self.submit(driver, key, None) } - /// Start a job and store the programming request for the worker. + /// Start a job and store the payload for the worker. pub fn submit( &self, driver: &str, key: &str, - request: Option, + payload: Option, ) -> Result { let mut inner = self.inner.lock(); if let Some(active) = inner.active.as_ref() { @@ -162,20 +188,28 @@ impl JobRegistry { }, frames: Vec::new(), cancel: false, - request, + payload, }; inner.active = Some(id.clone()); inner.jobs.insert(id.clone(), rec); Ok(JobId(id)) } - /// Take the stored programming request (worker pulls once). - pub fn take_request(&self, id: &JobId) -> Option { + /// Take the stored payload (worker pulls once). + pub fn take_payload(&self, id: &JobId) -> Option { self.inner .lock() .jobs .get_mut(&id.0) - .and_then(|r| r.request.take()) + .and_then(|r| r.payload.take()) + } + + /// Take the stored programming request (worker pulls once). + pub fn take_request(&self, id: &JobId) -> Option { + match self.take_payload(id) { + Some(JobPayload::Program(w)) => Some(w), + _ => None, + } } /// Whether a non-terminal job currently holds the radio. diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index abdc85c..6c4bde4 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -11,7 +11,7 @@ use std::time::Duration; use parking_lot::Mutex; use wp_core::{ - CommissioningNet, Observation, ProgressSink, ProgramRequest, RosterEntry, ThrottleServer, + CommissioningNet, Observation, ProgramRequest, ProgressSink, RosterEntry, ThrottleServer, Transport, WifiCredentials, }; use wp_link::{BoundedHttpClient, Radio, ScanResult}; @@ -109,13 +109,10 @@ impl Runtime { /// Scan the radio and claim candidates via the driver registry. pub fn scan(&self) -> Result, wp_core::DriverError> { let radio = Arc::clone(&self.radio); - let results = self - .rt - .handle() - .block_on(async move { - let mut r = radio.lock().await; - r.scan(64).await - })?; + let results = self.rt.handle().block_on(async move { + let mut r = radio.lock().await; + r.scan(64).await + })?; let mut out = Vec::new(); let mut cache = self.cache.lock(); @@ -139,6 +136,80 @@ impl Runtime { Ok(out) } + /// Discover LongFred HTTP OTA advertisers via mDNS (`_longfred-ota._tcp`). + pub fn scan_lan(&self) -> Result, wp_core::DriverError> { + let hosts = wp_link::discover_ota_hosts(Duration::from_millis(1500)) + .map_err(|e| wp_core::DriverError::Other(format!("mdns: {e}")))?; + let mut out = Vec::new(); + let mut cache = self.cache.lock(); + for h in hosts { + let key = h.ipv4.to_string(); + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::LongFred.id_str().into(), + key: key.clone(), + label: format!("{} ({})", h.hostname, h.ipv4), + rssi: None, + }; + cache.insert((cached.driver.clone(), key), cached.clone()); + out.push(cached); + } + Ok(out) + } + + /// Remember a LAN host so `updateFirmware` can skip scan when `--host` is set. + pub fn cache_lan_host(&self, host: &str, label: Option<&str>) { + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::LongFred.id_str().into(), + key: host.to_string(), + label: label.unwrap_or(host).to_string(), + rssi: None, + }; + self.cache + .lock() + .insert((cached.driver.clone(), cached.key.clone()), cached); + } + + /// Queue a firmware-upload job. + pub fn submit_firmware( + &self, + driver: Driver, + key: &str, + job: crate::jobs::FirmwareJob, + ) -> Result { + if !self.registry.supports_firmware_update(driver) { + return Err(crate::jobs::JobError::FirmwareUnsupported); + } + let id = self.jobs.submit( + driver.id_str(), + key, + Some(crate::jobs::JobPayload::Firmware(job)), + )?; + tracing::info!( + job_id = %id.0, + driver = driver.id_str(), + key, + "firmware job queued for worker" + ); + if let Err(e) = self.tx.blocking_send(id.clone()) { + tracing::error!(job_id = %id.0, error = %e, "failed to enqueue firmware job"); + self.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some(&format!("worker channel closed: {e}")), + ); + return Err(crate::jobs::JobError::Driver(wp_core::DriverError::Other( + "worker channel closed".into(), + ))); + } + Ok(id) + } + /// Look up a cached candidate. pub fn cached(&self, driver: &str, key: &str) -> Option { self.cache @@ -159,9 +230,11 @@ impl Runtime { let borrowed = owned.borrow(); self.registry.validate(driver, &borrowed)?; - let id = self - .jobs - .submit(driver.id_str(), key, Some(request))?; + let id = self.jobs.submit( + driver.id_str(), + key, + Some(crate::jobs::JobPayload::Program(request)), + )?; tracing::info!( job_id = %id.0, driver = driver.id_str(), @@ -191,9 +264,7 @@ impl Runtime { key: &str, ) -> Result { let candidate = self.cached(driver.id_str(), key).ok_or_else(|| { - wp_core::DriverError::Other( - "candidate not in scan cache; run scan first".into(), - ) + wp_core::DriverError::Other("candidate not in scan cache; run scan first".into()) })?; let net = self.effective_net(driver); let radio = Arc::clone(&self.radio); @@ -377,8 +448,7 @@ impl ProgressSink for JobProgressSink<'_> { _ => JobState::Writing, }; tracing::info!(job_id = %self.id.0, step, ?state, "job step"); - self.jobs - .transition(self.id, state, Some(step), None, None); + self.jobs.transition(self.id, state, Some(step), None, None); } fn progress(&mut self, progress: u8) { @@ -424,18 +494,25 @@ async fn run_job(rt: &Runtime, id: JobId) { return; } - let Some(wire) = rt.jobs.take_request(&id) else { - tracing::error!(job_id = %id.0, "job missing program request"); + let Some(payload) = rt.jobs.take_payload(&id) else { + tracing::error!(job_id = %id.0, "job missing payload"); rt.jobs.transition( &id, JobState::Failed, None, None, - Some("missing program request"), + Some("missing job payload"), ); return; }; + match payload { + crate::jobs::JobPayload::Program(wire) => run_program_job(rt, id, wire).await, + crate::jobs::JobPayload::Firmware(job) => run_firmware_job(rt, id, job).await, + } +} + +async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { let snap = match rt.jobs.snapshot(&id) { Some(s) => s, None => { @@ -623,11 +700,7 @@ async fn run_job(rt: &Runtime, id: JobId) { restarted = o.restarted, "job finished successfully" ); - let detail = if o.restarted { - Some("restarted") - } else { - None - }; + let detail = if o.restarted { Some("restarted") } else { None }; rt.jobs .transition(&id, JobState::Done, Some("done"), Some(100), detail); } @@ -639,15 +712,179 @@ async fn run_job(rt: &Runtime, id: JobId) { error = %e, "job failed" ); + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&e.to_string())); + } + } +} + +async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob) { + use std::net::Ipv4Addr; + use wp_proto::ReachMode; + + let snap = match rt.jobs.snapshot(&id) { + Some(s) => s, + None => return, + }; + let Some(driver) = Driver::from_id(&snap.driver) else { + rt.jobs + .transition(&id, JobState::Failed, None, None, Some("unknown driver")); + return; + }; + + let image = match std::fs::read(&job.path) { + Ok(b) if !b.is_empty() => b, + Ok(_) => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware file is empty"), + ); + return; + } + Err(e) => { rt.jobs.transition( &id, JobState::Failed, None, None, - Some(&e.to_string()), + Some(&format!("read {}: {e}", job.path.display())), ); + return; + } + }; + + rt.jobs + .transition(&id, JobState::Writing, Some("write"), Some(0), None); + + let mut sink = JobProgressSink { + jobs: &rt.jobs, + id: &id, + }; + + let outcome = match job.mode { + ReachMode::Lan => { + let host = job + .host + .clone() + .or_else(|| rt.cached(&snap.driver, &snap.key).map(|c| c.key)) + .unwrap_or_else(|| snap.key.clone()); + if host.parse::().is_err() { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("LAN firmware update needs an IPv4 --host or scan --mode lan key"), + ); + return; + } + let mut client = make_firmware_http_client(&host, 80, None); + let transport = Transport::Http(&mut client); + rt.registry + .update_firmware(driver, transport, &image, &mut sink) + .await } + ReachMode::Ap => { + let candidate = match rt.cached(&snap.driver, &snap.key) { + Some(c) => c, + None => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("candidate not in scan cache; run scan first"), + ); + return; + } + }; + let net = rt.effective_net(driver); + rt.jobs + .transition(&id, JobState::Joining, Some("join"), None, None); + let mut radio = rt.radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + if let Err(e) = radio.connect_open(&candidate.ssid, bssid).await { + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + if let Err(e) = radio.set_address(net.source, net.prefix).await { + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + if let Err(e) = radio.link_up().await { + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + drop(radio); + rt.jobs + .transition(&id, JobState::Writing, Some("write"), None, None); + let mut client = make_firmware_http_client( + &net.host.to_string(), + net.port, + Some(SocketAddr::from((net.source, 0))), + ); + let transport = Transport::Http(&mut client); + let result = rt + .registry + .update_firmware(driver, transport, &image, &mut sink) + .await; + { + let mut radio = rt.radio.lock().await; + let _ = radio.release().await; + } + result + } + }; + + match outcome { + Ok(o) => { + let detail = if o.restarted { Some("restarted") } else { None }; + rt.jobs + .transition(&id, JobState::Done, Some("done"), Some(100), detail); + } + Err(e) => { + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&e.to_string())); + } + } +} + +fn make_firmware_http_client( + host: &str, + port: u16, + source: Option, +) -> BoundedHttpClient { + let mut c = BoundedHttpClient::new(host, port) + .with_deadline(crate::jobs::FIRMWARE_DEADLINE) + .with_retries(0); + if let Some(src) = source { + c = c.with_source(src); } + c } /// Helper used by tests / fake mode to wait briefly for frames. diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs index fae985d..abe30ac 100644 --- a/crates/wireless-programmer/tests/fake_mode_test.rs +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -5,9 +5,7 @@ use std::sync::Arc; use std::time::Duration; use wp_fake::{CompositeFakeDevice, FakeRadio}; -use wp_proto::{ - ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire, -}; +use wp_proto::{ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire}; use wireless_programmer::config::Config; use wireless_programmer::drivers::{Driver, DriverRegistry}; @@ -46,10 +44,12 @@ fn setup_runtime() -> Arc { // Keep the accept loop alive for the duration of the test process. std::mem::forget(bootstrap); - let mut cfg = Config::default(); - cfg.socket = temp_socket(); - cfg.interface = Some("fake".into()); - cfg.require_auth = false; + let mut cfg = Config { + socket: temp_socket(), + interface: Some("fake".into()), + require_auth: false, + ..Default::default() + }; cfg.finalize_auth(); cfg.commissioning_net_override = Some(Config::localhost_commissioning(local.port())); @@ -141,7 +141,12 @@ fn fake_program_wifred_reaches_done() { .submit_program(Driver::WiFred, &c.key, wifred_request()) .expect("submit"); let state = wait_terminal(&rt, &id); - assert_eq!(state, JobState::Done, "detail={:?}", rt.jobs().snapshot(&id)); + assert_eq!( + state, + JobState::Done, + "detail={:?}", + rt.jobs().snapshot(&id) + ); } #[test] @@ -156,7 +161,12 @@ fn fake_program_longfred_reaches_done() { .submit_program(Driver::LongFred, &c.key, longfred_request()) .expect("submit"); let state = wait_terminal(&rt, &id); - assert_eq!(state, JobState::Done, "detail={:?}", rt.jobs().snapshot(&id)); + assert_eq!( + state, + JobState::Done, + "detail={:?}", + rt.jobs().snapshot(&id) + ); } #[test] diff --git a/crates/wp-client/src/client.rs b/crates/wp-client/src/client.rs index 06daea2..5623326 100644 --- a/crates/wp-client/src/client.rs +++ b/crates/wp-client/src/client.rs @@ -112,9 +112,19 @@ impl Client { /// `scan`: enumerate candidate devices on the radio. pub fn scan(&self) -> Result, ClientError> { + self.scan_mode(wp_proto::ReachMode::Ap) + } + + /// `scan` with an explicit reach mode (`ap` or `lan`). + pub fn scan_mode(&self, mode: wp_proto::ReachMode) -> Result, ClientError> { + let params = if mode == wp_proto::ReachMode::Ap { + Some(Params::None) + } else { + Some(Params::Scan(wp_proto::ScanParams { mode })) + }; let resp = self.round_trip(&Request { kind: RequestKind::Scan, - params: Some(Params::None), + params, })?; match self.expect_result(resp, RequestKind::Scan)? { ResultBody::Scan(c) => Ok(c), @@ -122,6 +132,29 @@ impl Client { } } + /// `updateFirmware`: queue a firmware-upload job. + pub fn update_firmware( + &self, + mode: wp_proto::ReachMode, + candidate: Option, + path: impl Into, + host: Option, + ) -> Result { + let resp = self.round_trip(&Request { + kind: RequestKind::UpdateFirmware, + params: Some(Params::UpdateFirmware(wp_proto::UpdateFirmwareParams { + mode, + candidate, + path: path.into(), + host, + })), + })?; + match self.expect_result(resp, RequestKind::UpdateFirmware)? { + ResultBody::UpdateFirmware(p) | ResultBody::Program(p) => Ok(p), + other => Err(unexpected_body(other)), + } + } + /// `probe`: read a single candidate's device info. pub fn probe(&self, candidate: CandidateRef) -> Result { let resp = self.round_trip(&Request { diff --git a/crates/wp-client/src/lib.rs b/crates/wp-client/src/lib.rs index 53cacbc..a0d53e2 100644 --- a/crates/wp-client/src/lib.rs +++ b/crates/wp-client/src/lib.rs @@ -17,6 +17,6 @@ pub use watch::WatchStream; pub use wp_proto::{ CandidateRef, CandidateWire, DeviceInfoWire, FunctionMappingWire, HelloResult, JobFrame, - JobSnapshot, JobStateWire, LinkStatusWire, ProgramRequestWire, ProgramResult, RosterEntryWire, - ThrottleServerWire, WifiCredentialsWire, + JobSnapshot, JobStateWire, LinkStatusWire, ProgramRequestWire, ProgramResult, ReachMode, + RosterEntryWire, ThrottleServerWire, WifiCredentialsWire, }; diff --git a/crates/wp-core/src/capabilities.rs b/crates/wp-core/src/capabilities.rs index 9421d16..8ba3e42 100644 --- a/crates/wp-core/src/capabilities.rs +++ b/crates/wp-core/src/capabilities.rs @@ -125,6 +125,8 @@ pub struct DriverCapabilities { pub supports_throttle_server: bool, /// How the device is commissioned. pub commissioning: CommissioningKind, + /// Whether HTTP firmware upload is supported. + pub supports_firmware_update: bool, /// Soft-AP addressing for commissioning, when the driver does not use the /// daemon's historical `192.168.4.x` defaults. pub commissioning_net: Option, @@ -138,6 +140,7 @@ impl From for CapabilitiesWire { identity_format: c.identity_format.into(), supports_throttle_server: c.supports_throttle_server, commissioning: c.commissioning.into(), + supports_firmware_update: c.supports_firmware_update, commissioning_net: c.commissioning_net.map(Into::into), } } diff --git a/crates/wp-drivers/src/longfred/constants.rs b/crates/wp-drivers/src/longfred/constants.rs index fb75b18..3013fdc 100644 --- a/crates/wp-drivers/src/longfred/constants.rs +++ b/crates/wp-drivers/src/longfred/constants.rs @@ -28,8 +28,14 @@ pub const MAX_FUNCTION: u8 = 0; /// Settings read endpoint. pub const SETTINGS_PATH: &str = "/api/v1/settings"; +/// Firmware upload endpoint (raw `.app.bin`). +pub const FIRMWARE_PATH: &str = "/api/v1/firmware"; + /// Exit programming mode endpoint. pub const PROGRAMMING_MODE_OFF_PATH: &str = "/api/v1/programming-mode/off"; /// JSON content type for PUT bodies. pub const JSON_CONTENT_TYPE: &str = "application/json"; + +/// Firmware POST content type. +pub const FIRMWARE_CONTENT_TYPE: &str = "application/octet-stream"; diff --git a/crates/wp-drivers/src/longfred/mod.rs b/crates/wp-drivers/src/longfred/mod.rs index 0083d01..5cc2ea8 100644 --- a/crates/wp-drivers/src/longfred/mod.rs +++ b/crates/wp-drivers/src/longfred/mod.rs @@ -6,6 +6,7 @@ //! //! - `GET /api/v1/settings` //! - `PUT /api/v1/settings` +//! - `POST /api/v1/firmware` //! - `POST /api/v1/programming-mode/off` //! //! Configuration is written as a single JSON PUT, verified with a GET, then @@ -22,8 +23,8 @@ use wp_core::{ }; pub use constants::{ - CONFIG_AP_PORT, CONFIG_HOST, CONFIG_PREFIX_LEN, CONFIG_SOURCE, MAX_FUNCTION, MAX_ROSTER_SLOTS, - WIFI_CONFIG_SSID_PREFIX, + CONFIG_AP_PORT, CONFIG_HOST, CONFIG_PREFIX_LEN, CONFIG_SOURCE, FIRMWARE_CONTENT_TYPE, + FIRMWARE_PATH, MAX_FUNCTION, MAX_ROSTER_SLOTS, WIFI_CONFIG_SSID_PREFIX, }; pub use discovery::identify; pub use settings::{build_settings_put, format_roster_addr, verify}; @@ -63,6 +64,7 @@ impl DeviceDriver for LongFredDriver { // callers can share a request shape with WiFred. supports_throttle_server: true, commissioning: wp_core::CommissioningKind::SoftAp, + supports_firmware_update: true, commissioning_net: Some(CommissioningNet { host: CONFIG_HOST, port: CONFIG_AP_PORT, @@ -132,6 +134,32 @@ impl DeviceDriver for LongFredDriver { } } +impl LongFredDriver { + /// Stream an ESP32-C6 app image to `POST /api/v1/firmware`. + /// + /// # Errors + /// + /// Returns [`DriverError`] when the HTTP POST fails. + pub async fn update_firmware( + &self, + transport: Transport<'_>, + image: &[u8], + progress: &mut dyn ProgressSink, + ) -> Result { + let client = http_client(transport)?; + progress.step("write"); + progress.detail(&format!("{} bytes", image.len())); + client + .request("POST", FIRMWARE_PATH, Some((FIRMWARE_CONTENT_TYPE, image))) + .map_err(|e| DriverError::Http(e.to_string()))?; + progress.step("restart"); + Ok(Outcome { + restarted: true, + mismatches: Vec::new(), + }) + } +} + /// Extract the HTTP client from a [`Transport`]. fn http_client(transport: Transport<'_>) -> Result<&mut dyn wp_core::HttpClient, DriverError> { match transport { diff --git a/crates/wp-drivers/src/wifred/mod.rs b/crates/wp-drivers/src/wifred/mod.rs index 97014f5..046d565 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -57,6 +57,7 @@ impl DeviceDriver for WiFredDriver { identity_format: IdentityFormat::Digits { len: 6 }, supports_throttle_server: true, commissioning: wp_core::CommissioningKind::SoftAp, + supports_firmware_update: false, // Historical Soft-AP defaults (`192.168.4.1` / `.2/24`) live in the // daemon config; leave unset so existing behaviour is unchanged. commissioning_net: None, diff --git a/crates/wp-drivers/tests/longfred_write.rs b/crates/wp-drivers/tests/longfred_write.rs index 508f877..5d33b92 100644 --- a/crates/wp-drivers/tests/longfred_write.rs +++ b/crates/wp-drivers/tests/longfred_write.rs @@ -41,6 +41,7 @@ impl HttpClient for FakeHttp { ("PUT", "/api/v1/settings") | ("POST", "/api/v1/programming-mode/off") => { Ok(Vec::new()) } + ("POST", "/api/v1/firmware") => Ok(br#"{"ok":true}"#.to_vec()), _ => Err(io::Error::other(format!("unexpected {method} {path}"))), } } @@ -174,3 +175,23 @@ async fn program_skips_exit_on_verify_mismatch() { assert_eq!(fake.requests.len(), 2); assert_eq!(fake.requests[1].0, "GET"); } + +#[tokio::test] +async fn update_firmware_posts_app_image() { + let mut fake = FakeHttp { + requests: Vec::new(), + get_settings: std::collections::VecDeque::new(), + }; + let image = vec![0xE9, 0, 0, 0, 0x0D, 0]; + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let outcome = LongFredDriver::new() + .update_firmware(transport, &image, &mut progress) + .await + .expect("firmware"); + assert!(outcome.restarted); + assert_eq!(fake.requests.len(), 1); + assert_eq!(fake.requests[0].0, "POST"); + assert_eq!(fake.requests[0].1, "/api/v1/firmware"); + assert_eq!(fake.requests[0].2.as_ref().unwrap(), &image); +} diff --git a/crates/wp-fake/src/longfred.rs b/crates/wp-fake/src/longfred.rs index cceb440..645f283 100644 --- a/crates/wp-fake/src/longfred.rs +++ b/crates/wp-fake/src/longfred.rs @@ -49,20 +49,32 @@ impl LongFredFake { } if let Some(login) = body.pointer("/bigfred/login").and_then(Value::as_str) { - if let Some(obj) = self.settings.get_mut("bigfred").and_then(Value::as_object_mut) { + if let Some(obj) = self + .settings + .get_mut("bigfred") + .and_then(Value::as_object_mut) + { obj.insert("login".into(), json!(login)); obj.insert("pin_set".into(), json!(true)); } } if let Some(mode) = body.get("roster_mode").and_then(Value::as_str) { - if let Some(obj) = self.settings.get_mut("roster").and_then(Value::as_object_mut) { + if let Some(obj) = self + .settings + .get_mut("roster") + .and_then(Value::as_object_mut) + { obj.insert("mode".into(), json!(mode)); } } if let Some(roster) = body.get("roster").and_then(Value::as_array) { - if let Some(obj) = self.settings.get_mut("roster").and_then(Value::as_object_mut) { + if let Some(obj) = self + .settings + .get_mut("roster") + .and_then(Value::as_object_mut) + { obj.insert("entries".into(), Value::Array(roster.clone())); } } @@ -109,6 +121,18 @@ impl FakeDevice for LongFredFake { } ok_text("ok") } + ("POST", "/api/v1/firmware") => { + let n = req.body.map(<[u8]>::len).unwrap_or(0); + if n == 0 { + FakeResponse { + status: 400, + content_type: "text/plain", + body: b"empty image".to_vec(), + } + } else { + ok_json(b"{\"ok\":true}".to_vec()) + } + } _ => not_found(), } } @@ -197,4 +221,21 @@ mod tests { assert!(!fake.programming_mode); assert_eq!(fake.settings["programming_mode"], false); } + + #[test] + fn firmware_post_accepts_body() { + let mut fake = LongFredFake::new(); + let resp = fake.handle(FakeRequest { + method: "POST", + path: "/api/v1/firmware", + body: Some(&[0xE9, 0, 1, 2]), + }); + assert_eq!(resp.status, 200); + let empty = fake.handle(FakeRequest { + method: "POST", + path: "/api/v1/firmware", + body: Some(&[]), + }); + assert_eq!(empty.status, 400); + } } diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 4d00346..aef272e 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -4,10 +4,12 @@ #![forbid(unsafe_code)] pub mod http; +pub mod mdns; pub mod radio; pub mod rfkill; pub use http::{percent_encode, BoundedHttpClient, MAX_BODY_BYTES}; +pub use mdns::{discover_ota_hosts, parse_ota_hosts, OtaHost, OTA_HTTP_SERVICE}; pub use radio::{ first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, diff --git a/crates/wp-link/src/mdns.rs b/crates/wp-link/src/mdns.rs new file mode 100644 index 0000000..228a8e0 --- /dev/null +++ b/crates/wp-link/src/mdns.rs @@ -0,0 +1,174 @@ +//! Minimal mDNS query for `_longfred-ota._tcp.local`. + +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::time::{Duration, Instant}; + +/// LongFred STA HTTP OTA service. +pub const OTA_HTTP_SERVICE: &str = "_longfred-ota._tcp.local"; + +const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; +const TYPE_A: u16 = 1; +const TYPE_PTR: u16 = 12; +const TYPE_SRV: u16 = 33; + +/// A LongFred advertising HTTP OTA on the LAN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OtaHost { + /// Instance / hostname label. + pub hostname: String, + /// IPv4 from an A record. + pub ipv4: Ipv4Addr, + /// SRV port (HTTP, typically 80). + pub port: u16, +} + +/// Send a PTR query and collect A/SRV answers for [`OTA_HTTP_SERVICE`]. +/// +/// # Errors +/// +/// Returns [`std::io::Error`] on socket failure. +pub fn discover_ota_hosts(wait: Duration) -> std::io::Result> { + let sock = UdpSocket::bind("0.0.0.0:0")?; + sock.set_read_timeout(Some(Duration::from_millis(200)))?; + sock.set_multicast_ttl_v4(1)?; + let q = ptr_query(OTA_HTTP_SERVICE); + sock.send_to(&q, SocketAddrV4::new(MDNS_GROUP, MDNS_PORT))?; + + let deadline = Instant::now() + wait; + let mut found: Vec = Vec::new(); + let mut buf = [0u8; 1500]; + while Instant::now() < deadline { + match sock.recv_from(&mut buf) { + Ok((n, _)) => { + for h in parse_ota_hosts(&buf[..n]) { + if !found.iter().any(|e| e.ipv4 == h.ipv4 && e.port == h.port) { + found.push(h); + } + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(e), + } + } + Ok(found) +} + +fn ptr_query(service: &str) -> Vec { + let mut q = vec![0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]; + for label in service.split('.') { + q.push(u8::try_from(label.len()).unwrap_or(0)); + q.extend_from_slice(label.as_bytes()); + } + q.push(0); + q.extend_from_slice(&[0x00, TYPE_PTR as u8, 0x00, 0x01]); + q +} + +fn be16(pkt: &[u8], off: usize) -> Option { + Some((u16::from(*pkt.get(off)?) << 8) | u16::from(*pkt.get(off + 1)?)) +} + +fn read_name(pkt: &[u8], start: usize) -> Option<(String, usize)> { + let mut labels = Vec::new(); + let mut off = start; + let mut next_after: Option = None; + let mut jumps = 0usize; + loop { + let len = *pkt.get(off)?; + if len == 0 { + off += 1; + break; + } + if len & 0xc0 == 0xc0 { + let ptr = (usize::from(len & 0x3f) << 8) | usize::from(*pkt.get(off + 1)?); + if next_after.is_none() { + next_after = Some(off + 2); + } + jumps += 1; + if jumps > 16 { + return None; + } + off = ptr; + continue; + } + let n = usize::from(len); + off += 1; + let bytes = pkt.get(off..off + n)?; + labels.push(String::from_utf8_lossy(bytes).into_owned()); + off += n; + } + Some((labels.join("."), next_after.unwrap_or(off))) +} + +/// Parse A/SRV records from an mDNS packet (host-testable). +pub fn parse_ota_hosts(pkt: &[u8]) -> Vec { + let mut out = Vec::new(); + if pkt.len() < 12 { + return out; + } + let an = be16(pkt, 6).unwrap_or(0); + let ns = be16(pkt, 8).unwrap_or(0); + let ar = be16(pkt, 10).unwrap_or(0); + let mut off = 12usize; + let mut port = 80u16; + let mut hostname = String::new(); + for _ in 0..an.saturating_add(ns).saturating_add(ar) { + let Some((name, nend)) = read_name(pkt, off) else { + break; + }; + off = nend; + let Some(typ) = be16(pkt, off) else { break }; + off += 8; + let Some(rdlen) = be16(pkt, off) else { break }; + off += 2; + let rdata = off; + off = off.saturating_add(usize::from(rdlen)); + if typ == TYPE_SRV && rdlen >= 6 { + if let Some(p) = be16(pkt, rdata + 4) { + port = p; + } + hostname = name.split('.').next().unwrap_or("longfred").to_string(); + } + if typ == TYPE_A && rdlen == 4 { + if let (Some(&a), Some(&b), Some(&c), Some(&d)) = ( + pkt.get(rdata), + pkt.get(rdata + 1), + pkt.get(rdata + 2), + pkt.get(rdata + 3), + ) { + let host = if hostname.is_empty() { + name.split('.').next().unwrap_or("longfred").to_string() + } else { + hostname.clone() + }; + out.push(OtaHost { + hostname: host, + ipv4: Ipv4Addr::new(a, b, c, d), + port, + }); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ptr_query_contains_service_labels() { + let q = ptr_query(OTA_HTTP_SERVICE); + assert!(q + .windows(b"_longfred-ota".len()) + .any(|w| w == b"_longfred-ota")); + } + + #[test] + fn parse_empty_packet() { + assert!(parse_ota_hosts(&[]).is_empty()); + } +} diff --git a/crates/wp-proto/src/results.rs b/crates/wp-proto/src/results.rs index a51491d..7567d2b 100644 --- a/crates/wp-proto/src/results.rs +++ b/crates/wp-proto/src/results.rs @@ -16,6 +16,8 @@ pub enum ResultBody { Probe(DeviceInfoWire), /// `program` response. Program(ProgramResult), + /// `updateFirmware` response (queued job id). + UpdateFirmware(ProgramResult), /// `job.get` response. Job(JobSnapshot), /// `job.watch` stream frame. @@ -67,6 +69,9 @@ pub struct CapabilitiesWire { pub supports_throttle_server: bool, /// How the device is commissioned. pub commissioning: CommissioningKindWire, + /// Whether the driver can upload firmware over HTTP. + #[serde(default)] + pub supports_firmware_update: bool, /// Soft-AP addressing for commissioning, when not using daemon defaults. #[serde(skip_serializing_if = "Option::is_none", default)] pub commissioning_net: Option, diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index 2fce687..e1f8607 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -74,6 +74,8 @@ pub enum RequestKind { Identify, /// `link.status`: report radio/link state. LinkStatus, + /// `updateFirmware`: upload an app image over HTTP (Soft-AP or LAN). + UpdateFirmware, } /// Method parameters. @@ -89,6 +91,10 @@ pub enum Params { Job(JobParams), /// Arguments for [`RequestKind::Identify`]. Identify(IdentifyParams), + /// Arguments for [`RequestKind::Scan`] (optional; omitted means Soft-AP). + Scan(ScanParams), + /// Arguments for [`RequestKind::UpdateFirmware`]. + UpdateFirmware(UpdateFirmwareParams), /// No parameters. None, } @@ -134,6 +140,44 @@ pub struct IdentifyParams { pub count: Option, } +/// How to reach a LongFred for firmware or scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReachMode { + /// Soft-AP programming network (radio scan / join). + #[default] + Ap, + /// Device already on the layout LAN (mDNS / `--host`). + Lan, +} + +/// `scan` parameters. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanParams { + /// Soft-AP radio scan (`ap`, default) or LAN mDNS (`lan`). + #[serde(default)] + pub mode: ReachMode, +} + +/// `updateFirmware` parameters. The image stays on disk; the socket frame +/// only carries the path (1 MiB JSON limit). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateFirmwareParams { + /// Soft-AP (`ap`, default) or layout LAN (`lan`). + #[serde(default)] + pub mode: ReachMode, + /// Candidate from `scan`. Optional when [`Self::host`] is set in LAN mode. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub candidate: Option, + /// Path to an ESP32-C6 `.app.bin` on the hub. + pub path: String, + /// Explicit IPv4 for LAN mode (skips mDNS). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub host: Option, +} + /// A reference to a scan result, stable for the lifetime of a scan session. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/docs/api.md b/docs/api.md index 0e2aad1..4e7f3a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,10 +27,11 @@ the response so callers can correlate requests without an explicit id. | Method | Params | Result | Notes | |----------------|-------------------------|-----------------------|--------------------------------| -| `hello` | none | `HelloResult` | Version + driver capabilities | -| `scan` | none | `Candidate[]` | Enumerate devices on the radio | -| `probe` | `{ candidate }` | `DeviceInfo` | Read a single device's info | -| `program` | `{ candidate, request }` | `ProgramResult` | Start a job, returns `jobId` | +| `hello` | none | `HelloResult` | Version + driver capabilities | +| `scan` | `{ mode? }` | `Candidate[]` | Soft-AP radio (`ap`, default) or LAN mDNS (`lan`) | +| `probe` | `{ candidate }` | `DeviceInfo` | Read a single device's info | +| `program` | `{ candidate, request }` | `ProgramResult` | Start a job, returns `jobId` | +| `updateFirmware` | `{ mode, candidate?, path, host? }` | `ProgramResult` | HTTP firmware upload job | | `job.get` | `{ jobId }` | `JobSnapshot` | Snapshot a job's state | | `job.watch` | `{ jobId }` | `JobFrame` (stream) | Stream progress until terminal | | `job.cancel` | `{ jobId }` | `JobSnapshot` | Request cancellation | @@ -42,7 +43,7 @@ the response so callers can correlate requests without an explicit id. Returns the daemon version and the list of registered drivers with their capabilities (max roster slots, max function index, identity format, commissioning kind, optional Soft-AP `commissioningNet`, throttle-server -support). +support, firmware-update support). `version` is the release tag from the ELF section `.wireless-programmer.version` when the binary was published via the release workflow; otherwise the Cargo @@ -50,11 +51,48 @@ package version. `commit` is the matching tag/build commit when available. ### `scan` -Triggers an nl80211 scan and returns the candidates each driver claims: +Optional `params.mode` is `"ap"` (default) or `"lan"`. + +Soft-AP (`ap`) triggers an nl80211 scan and returns the candidates each +driver claims: - WiFred: every AP whose SSID starts with `wiFred-config` - LongFred: every AP whose SSID starts with `longfred_prog` +LAN (`lan`) does not use the radio. It queries mDNS for +`_longfred-ota._tcp.local` and returns LongFred candidates whose `key` is +the advertised IPv4. + +### `updateFirmware` + +Starts a firmware-upload job. The image path is on the hub filesystem +(typically a `.app.bin` produced by `espflash save-image` without +`--merge`). `mode` is `"ap"` or `"lan"`. + +- **AP**: join the LongFred Soft-AP like `program`, then + `POST /api/v1/firmware` with `application/octet-stream`. The HTTP + transfer has a 120 s deadline and is not retried. After a successful + reboot the device stays in programming mode. +- **LAN**: no radio. HTTP to `candidate.key` (an IPv4 from `scan` with + `mode: "lan"`) or `params.host`. The throttle must have HTTP OTA + enabled from the Firmware update menu. After reboot it rejoins layout + Wi‑Fi. + +A driver with `supportsFirmwareUpdate: false` (WiFred) returns +`driverError`. A second job while the radio is held returns `busy` +(LAN jobs do not take the radio). + +```jsonc +{ + "type": "updateFirmware", + "params": { + "mode": "ap", + "candidate": { "driver": "longfred", "key": "AA:BB:CC:DD:EE:01" }, + "path": "/data/firmware/longfred-markwtech-esp32c6.app.bin" + } +} +``` + ### `probe` Reads a single candidate's device info over the radio (associate → HTTP GET @@ -156,9 +194,10 @@ over the same socket. Every client subcommand accepts `--json` | Subcommand | Purpose | |------------|---------| -| `scan` | Enumerate candidate devices on the radio | +| `scan [--mode ap\|lan]` | Enumerate Soft-AP APs or LAN OTA hosts | | `probe --driver --key` | Read a single candidate's device info | | `program --driver --key ...` | Start a programming job and stream progress to completion | +| `update-firmware --mode ap\|lan --file ...` | Upload firmware over HTTP | | `identify --driver --key [--count N]` | Blink the device LED | | `job get\|watch\|cancel --id` | Inspect or control a running job | | `link-status` | Report radio/link state | diff --git a/docs/cli.md b/docs/cli.md index cb0e5cd..8d68365 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,10 +9,11 @@ wireless-programmer [OPTIONS] [COMMAND] Commands: daemon Run the IPC daemon (default when no subcommand is given) - scan Enumerate candidate devices on the radio - probe Read a single candidate's device info - program Start a programming job and stream its progress - identify Blink a device's LED so an operator can find it + scan Enumerate candidate devices (Soft-AP radio or LAN mDNS) + probe Read a single candidate's device info + program Start a programming job and stream its progress + update-firmware Upload a firmware image over HTTP (Soft-AP or LAN) + identify Blink a device's LED so an operator can find it link-status Report radio/link state hello Exchange version + driver capabilities job Inspect or control a running job @@ -96,12 +97,15 @@ Every client subcommand accepts: # 1. What drivers does this daemon know? wireless-programmer hello -# 2. Bring the radio up and scan for config APs. +# 2. Bring the radio up and scan for config APs (Soft-AP, default). wireless-programmer scan # DRIVER KEY RSSI LABEL # wifred AA:BB:CC:DD:EE:01 -54 wiFred-config-AABBCCDDEE01 # wifred AA:BB:CC:DD:EE:02 -61 wiFred-config-AABBCCDDEE02 +# LAN scan (LongFred HTTP OTA via mDNS `_longfred-ota._tcp`): +wireless-programmer scan --mode lan + # 3. Read one device's current config over the radio. wireless-programmer probe --driver wifred --key AA:BB:CC:DD:EE:01 @@ -116,6 +120,33 @@ nothing matches; pipe `--json` for scripting: wireless-programmer scan --json | jq '.[] | select(.rssi != null) | .key' ``` +## Firmware update + +`update-firmware` POSTs an application image (`.app.bin`, not a merged +flash dump) to LongFred `POST /api/v1/firmware`. The HTTP transfer has a +120 s deadline and is **not** retried. WiFred does not support firmware +upload. + +Use `--mode ap` after putting the throttle into Soft-AP programming mode +(8-second chord). Use `--mode lan` when the throttle is already on the +layout Wi‑Fi and the operator has opened **Firmware update** in the Extras +menu (HTTP is enabled only while that screen is shown). + +```bash +# Soft-AP: join longfred_prog_*, POST the image, keep programming_mode. +wireless-programmer update-firmware --mode ap --driver longfred \ + --key AA:BB:CC:DD:EE:01 --file longfred-markwtech-esp32c6.app.bin + +# LAN: no radio; HTTP to the IPv4 from scan --mode lan (or --host). +wireless-programmer update-firmware --mode lan --driver longfred \ + --key 192.168.1.42 --file longfred-markwtech-esp32c6.app.bin +wireless-programmer update-firmware --mode lan --host 192.168.1.42 \ + --file longfred-markwtech-esp32c6.app.bin +``` + +Like `program`, the command watches the job by default; `--no-watch` +returns the job id immediately. + ## Programming workflow `program` starts a job, then opens a `job.watch` stream and prints progress diff --git a/docs/drivers/longfred.md b/docs/drivers/longfred.md index b6181ff..94ec77b 100644 --- a/docs/drivers/longfred.md +++ b/docs/drivers/longfred.md @@ -8,7 +8,8 @@ programming mode. In programming mode the firmware raises an **open** WiFi AP named `longfred_prog_XXXXXX` (6 hex characters derived from the MAC). The Soft-AP uses a static address `192.168.0.1/24` (not the ESP-IDF Soft-AP default of -`192.168.4.1`). The driver advertises this via +`192.168.4.1`) and a DHCP pool `192.168.0.50–200`. The wireless-programmer +source address `.2` is **outside** that pool. The driver advertises this via `capabilities.commissioningNet`: | Field | Value | @@ -32,6 +33,7 @@ Candidate identity: SSID prefix `longfred_prog`, stable key = BSSID. | `maxFunctionIndex` | 0 (no function maps via settings) | | `identityFormat` | `Alphanumeric { max_len: 16 }` | | `supportsThrottleServer` | true (field accepted, unused) | +| `supportsFirmwareUpdate` | true | | `commissioning` | `SoftAp` | | `commissioningNet` | `192.168.0.1` / source `.2` /24 | @@ -59,6 +61,20 @@ JSON document as-is. The PSK / PIN are never logged by the daemon. +## Firmware update + +`POST /api/v1/firmware` with `Content-Type: application/octet-stream` and +the raw `.app.bin` body (ESP32-C6 app image, magic `0xE9`). Do not send a +merged flash dump. + +- Soft-AP: same join as programming; after reboot `programming_mode` stays + set so the device returns to the AP. +- LAN: HTTP to the layout IPv4 while the Firmware update menu is open; + after reboot the device rejoins layout Wi‑Fi. Discover hosts via mDNS + `_longfred-ota._tcp.local` (`scan --mode lan`). + +The HTTP transfer has a 120 s deadline and is not retried. + ## Testing Covered by unit tests in `longfred/discovery.rs` and `longfred/settings.rs`, diff --git a/docs/drivers/wifred.md b/docs/drivers/wifred.md index d879e79..c727a02 100644 --- a/docs/drivers/wifred.md +++ b/docs/drivers/wifred.md @@ -30,6 +30,7 @@ The AP runs a web server on port 80 with a built-in DHCP server at | `maxFunctionIndex` | 16 (`MAX_FUNCTION`) | | `identityFormat` | `Digits { len: 6 }` | | `supportsThrottleServer` | true | +| `supportsFirmwareUpdate` | false | | `commissioning` | `SoftAp` | The identity is a **6-digit BigFred pairing code** written into the firmware's diff --git a/docs/go-client.md b/docs/go-client.md index d51c29c..5cfe326 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -63,9 +63,11 @@ failure (see [Errors](#errors)). | Method | Wire method | Returns | |--------|-------------|---------| | `Hello()` | `hello` | `*HelloResult` (version + drivers) | -| `Scan()` | `scan` | `[]CandidateWire` | +| `Scan()` | `scan` | `[]CandidateWire` (Soft-AP) | +| `ScanMode(mode)` | `scan` | `[]CandidateWire` (`ap` or `lan`) | | `Probe(candidate)` | `probe` | `*DeviceInfoWire` | | `Program(candidate, req)` | `program` | `*ProgramResult` (job id) | +| `UpdateFirmware(mode, candidate, path, host)` | `updateFirmware` | `*ProgramResult` (job id) | | `JobGet(jobID)` | `job.get` | `*JobSnapshot` | | `JobCancel(jobID)` | `job.cancel` | `*JobSnapshot` | | `Identify(candidate, count)` | `identify` | `nil` | diff --git a/go/client/client.go b/go/client/client.go index e454c80..33cedff 100644 --- a/go/client/client.go +++ b/go/client/client.go @@ -38,12 +38,13 @@ type CommissioningKindWire string // CapabilitiesWire mirrors wp_proto::CapabilitiesWire. type CapabilitiesWire struct { - MaxRosterSlots uint8 `json:"maxRosterSlots"` - MaxFunctionIndex uint8 `json:"maxFunctionIndex"` - IdentityFormat IdentityFormatWire `json:"identityFormat"` - SupportsThrottleServer bool `json:"supportsThrottleServer"` - Commissioning CommissioningKindWire `json:"commissioning"` - CommissioningNet *CommissioningNetWire `json:"commissioningNet,omitempty"` + MaxRosterSlots uint8 `json:"maxRosterSlots"` + MaxFunctionIndex uint8 `json:"maxFunctionIndex"` + IdentityFormat IdentityFormatWire `json:"identityFormat"` + SupportsThrottleServer bool `json:"supportsThrottleServer"` + SupportsFirmwareUpdate bool `json:"supportsFirmwareUpdate"` + Commissioning CommissioningKindWire `json:"commissioning"` + CommissioningNet *CommissioningNetWire `json:"commissioningNet,omitempty"` } // CommissioningNetWire mirrors wp_proto::CommissioningNetWire. @@ -103,21 +104,21 @@ type FunctionMappingWire struct { // RosterEntryWire mirrors wp_proto::RosterEntryWire. type RosterEntryWire struct { - Address *uint16 `json:"address,omitempty"` - LongAddress *bool `json:"longAddress,omitempty"` - Mode string `json:"mode,omitempty"` - Direction *uint8 `json:"direction,omitempty"` - Functions []FunctionMappingWire `json:"functions,omitempty"` + Address *uint16 `json:"address,omitempty"` + LongAddress *bool `json:"longAddress,omitempty"` + Mode string `json:"mode,omitempty"` + Direction *uint8 `json:"direction,omitempty"` + Functions []FunctionMappingWire `json:"functions,omitempty"` } // ProgramRequestWire mirrors wp_proto::ProgramRequestWire. type ProgramRequestWire struct { - Identity string `json:"identity"` - Wifi WifiCredentialsWire `json:"wifi"` - Server ThrottleServerWire `json:"server"` - Roster []RosterEntryWire `json:"roster"` - Bigfred *BigfredCredsWire `json:"bigfred,omitempty"` - RosterMode string `json:"rosterMode,omitempty"` + Identity string `json:"identity"` + Wifi WifiCredentialsWire `json:"wifi"` + Server ThrottleServerWire `json:"server"` + Roster []RosterEntryWire `json:"roster"` + Bigfred *BigfredCredsWire `json:"bigfred,omitempty"` + RosterMode string `json:"rosterMode,omitempty"` } // BigfredCredsWire mirrors wp_proto::BigfredCredsWire. @@ -196,6 +197,9 @@ type requestParams struct { Request *ProgramRequestWire `json:"request,omitempty"` JobID string `json:"jobId,omitempty"` Count *uint32 `json:"count,omitempty"` + Mode string `json:"mode,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` } // Client dials the wireless-programmer Unix socket. @@ -252,10 +256,19 @@ func (c *Client) Hello() (*HelloResult, error) { return &out, nil } -// Scan enumerates candidate devices on the radio. +// Scan enumerates candidate devices on the radio (Soft-AP). func (c *Client) Scan() ([]CandidateWire, error) { + return c.ScanMode("ap") +} + +// ScanMode enumerates candidates. mode is "ap" (radio Soft-AP) or "lan" (mDNS). +func (c *Client) ScanMode(mode string) ([]CandidateWire, error) { + var params *requestParams + if mode != "" && mode != "ap" { + params = &requestParams{Mode: mode} + } var resp Response - if err := c.roundTrip(request{Type: "scan"}, &resp); err != nil { + if err := c.roundTrip(request{Type: "scan", Params: params}, &resp); err != nil { return nil, err } if resp.Type == "error" { @@ -271,6 +284,27 @@ func (c *Client) Scan() ([]CandidateWire, error) { return out, nil } +// UpdateFirmware queues an HTTP firmware upload job (image path on the hub). +// mode is "ap" or "lan". host is an optional LAN IPv4. +func (c *Client) UpdateFirmware(mode string, candidate *CandidateRef, path, host string) (*ProgramResult, error) { + params := &requestParams{Mode: mode, Path: path, Host: host, Candidate: candidate} + var resp Response + if err := c.roundTrip(request{Type: "updateFirmware", Params: params}, &resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, responseError(resp) + } + if resp.Type != "updateFirmware" { + return nil, fmt.Errorf("unexpected response type %q", resp.Type) + } + var out ProgramResult + if err := json.Unmarshal(resp.Result, &out); err != nil { + return nil, fmt.Errorf("decode updateFirmware: %w", err) + } + return &out, nil +} + // Probe reads a single candidate's device info. func (c *Client) Probe(candidate CandidateRef) (*DeviceInfoWire, error) { var resp Response From f0dace5fbf377bbd4fda0b9f5f3255e69c54baf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:58:09 +0200 Subject: [PATCH 2/3] Add LongFred USB firmware updates via espflash. Scan serial ports and flash ELF, merged .bin, or .app.bin with --mode usb so the first dual-slot install does not require a manual espflash invocation. Co-authored-by: Cursor --- crates/wireless-programmer/src/cli/client.rs | 35 +- crates/wireless-programmer/src/cli/mod.rs | 18 +- crates/wireless-programmer/src/ipc.rs | 68 +++- crates/wireless-programmer/src/jobs.rs | 8 +- crates/wireless-programmer/src/runtime.rs | 110 +++++- crates/wp-client/src/client.rs | 4 + crates/wp-link/src/espflash.rs | 394 +++++++++++++++++++ crates/wp-link/src/lib.rs | 6 + crates/wp-proto/src/wire.rs | 17 +- docs/api.md | 33 +- docs/cli.md | 24 +- docs/drivers/longfred.md | 7 +- docs/go-client.md | 4 +- go/client/client.go | 33 +- 14 files changed, 669 insertions(+), 92 deletions(-) create mode 100644 crates/wp-link/src/espflash.rs diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index f3ca31a..5871b51 100644 --- a/crates/wireless-programmer/src/cli/client.rs +++ b/crates/wireless-programmer/src/cli/client.rs @@ -101,16 +101,20 @@ fn print_scan(candidates: &[wp_client::CandidateWire], json: bool) { fn scan(socket: &Path, args: ScanArgs) -> HandlerResult { let c = build_client(socket, args.common.timeout); - let mode = if args.mode == "lan" { - wp_client::ReachMode::Lan - } else { - wp_client::ReachMode::Ap - }; + let mode = parse_reach_mode(&args.mode); let candidates = c.scan_mode(mode)?; print_scan(&candidates, args.common.json); Ok(()) } +fn parse_reach_mode(mode: &str) -> wp_client::ReachMode { + match mode { + "lan" => wp_client::ReachMode::Lan, + "usb" => wp_client::ReachMode::Usb, + _ => wp_client::ReachMode::Ap, + } +} + fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { if !args.file.is_file() { return Err(CliError::File { @@ -118,7 +122,9 @@ fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { message: "not a file".into(), }); } - let mode = if args.mode == "lan" || args.host.is_some() { + let mode = if args.port.is_some() || args.mode == "usb" { + wp_client::ReachMode::Usb + } else if args.mode == "lan" || args.host.is_some() { wp_client::ReachMode::Lan } else { wp_client::ReachMode::Ap @@ -126,18 +132,25 @@ fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { let key = args .key .clone() - .or_else(|| args.host.clone()) - .ok_or_else(|| CliError::Usage("provide --key and/or --host".into()))?; + .or_else(|| args.port.clone()) + .or_else(|| args.host.clone()); + if key.is_none() && mode != wp_client::ReachMode::Usb { + return Err(CliError::Usage("provide --key and/or --host".into())); + } let c = build_client(socket, args.common.timeout); - let candidate = wp_client::CandidateRef { + let candidate = key.map(|key| wp_client::CandidateRef { driver: args.driver.clone(), key, - }; + }); let started = c.update_firmware( mode, - Some(candidate), + candidate, args.file.display().to_string(), args.host.clone(), + args.port.clone(), + args.partition_table + .as_ref() + .map(|p| p.display().to_string()), )?; if args.no_watch { if args.common.json { diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index 065d26d..b558dc9 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -97,8 +97,8 @@ pub struct CommonArgs { pub struct ScanArgs { #[command(flatten)] pub common: ClientCommon, - /// `ap` (Soft-AP radio, default) or `lan` (mDNS `_longfred-ota._tcp`). - #[arg(long, default_value = "ap", value_parser = ["ap", "lan"])] + /// `ap` (Soft-AP radio, default), `lan` (mDNS `_longfred-ota._tcp`), or `usb`. + #[arg(long, default_value = "ap", value_parser = ["ap", "lan", "usb"])] pub mode: String, } @@ -107,19 +107,25 @@ pub struct ScanArgs { pub struct UpdateFirmwareArgs { #[command(flatten)] pub common: ClientCommon, - /// `ap` (Soft-AP, default) or `lan` (layout Wi‑Fi, no radio). - #[arg(long, default_value = "ap", value_parser = ["ap", "lan"])] + /// `ap` (Soft-AP, default), `lan` (layout Wi‑Fi), or `usb` (`espflash`). + #[arg(long, default_value = "ap", value_parser = ["ap", "lan", "usb"])] pub mode: String, /// Driver identifier (default `longfred`). #[arg(long, default_value = "longfred")] pub driver: String, - /// Candidate key (BSSID in AP mode, IPv4 in LAN mode). + /// Candidate key (BSSID in AP mode, IPv4 in LAN mode, serial device in USB mode). #[arg(long)] pub key: Option, /// LAN IPv4 (skips mDNS). Implies `--mode lan` when set alone with `--file`. #[arg(long)] pub host: Option, - /// Path to ESP32-C6 `.app.bin`. + /// USB serial device (e.g. `/dev/ttyACM0`). Implies `--mode usb`. + #[arg(long)] + pub port: Option, + /// CSV partition table for ELF USB flashes (default: `partitions.csv` next to `--file`). + #[arg(long)] + pub partition_table: Option, + /// Path to a LongFred image (`.app.bin`, merged `.bin`, or ELF). #[arg(long)] pub file: PathBuf, /// Do not stream job progress after starting the job. diff --git a/crates/wireless-programmer/src/ipc.rs b/crates/wireless-programmer/src/ipc.rs index 5338e87..3042781 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -210,15 +210,15 @@ impl ServerInner { error: None, }, RequestKind::Scan => { - let lan = matches!( - req.params, - Some(Params::Scan(ref p)) if p.mode == wp_proto::ReachMode::Lan - ); - tracing::info!(lan, "scan started"); - let scanned = if lan { - self.runtime.scan_lan() - } else { - self.runtime.scan() + let mode = match req.params { + Some(Params::Scan(ref p)) => p.mode, + _ => wp_proto::ReachMode::Ap, + }; + tracing::info!(?mode, "scan started"); + let scanned = match mode { + wp_proto::ReachMode::Lan => self.runtime.scan_lan(), + wp_proto::ReachMode::Usb => self.runtime.scan_usb(), + wp_proto::ReachMode::Ap => self.runtime.scan(), }; match scanned { Ok(found) => { @@ -423,18 +423,19 @@ impl ServerInner { let driver_id = p .candidate .as_ref() - .map(|c| c.driver.as_str()) - .unwrap_or("longfred"); + .map(|c| c.driver.clone()) + .unwrap_or_else(|| "longfred".into()); let key = p - .host + .port .clone() + .or_else(|| p.host.clone()) .or_else(|| p.candidate.as_ref().map(|c| c.key.clone())) .unwrap_or_default(); - if key.is_empty() { + if key.is_empty() && p.mode != wp_proto::ReachMode::Usb { return err_response( RequestKind::UpdateFirmware, "bad_params", - "candidate.key or host is required", + "candidate.key, host, or port is required", ); } if p.mode == wp_proto::ReachMode::Lan { @@ -442,7 +443,38 @@ impl ServerInner { self.runtime.cache_lan_host(h, None); } } - match crate::drivers::Driver::from_id(driver_id) { + let key = if key.is_empty() && p.mode == wp_proto::ReachMode::Usb { + match self.runtime.scan_usb() { + Ok(found) if found.len() == 1 => found[0].key.clone(), + Ok(found) if found.is_empty() => { + return err_response( + RequestKind::UpdateFirmware, + "noCandidates", + "no USB serial ports; pass --port", + ); + } + Ok(_) => { + return err_response( + RequestKind::UpdateFirmware, + "bad_params", + "multiple USB ports; pass --port", + ); + } + Err(e) => { + return err_response( + RequestKind::UpdateFirmware, + "scan_failed", + &e.to_string(), + ); + } + } + } else { + key + }; + if p.mode == wp_proto::ReachMode::Usb { + self.runtime.cache_usb_port(&key, None); + } + match crate::drivers::Driver::from_id(&driver_id) { Some(d) => { match self.runtime.submit_firmware( d, @@ -451,6 +483,12 @@ impl ServerInner { mode: p.mode, path: std::path::PathBuf::from(&p.path), host: p.host, + port: p.port.or_else(|| { + (p.mode == wp_proto::ReachMode::Usb).then(|| key.clone()) + }), + partition_table: p + .partition_table + .map(std::path::PathBuf::from), }, ) { Ok(id) => Response { diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index ff7dd38..06c1335 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -121,12 +121,16 @@ pub enum JobPayload { /// Firmware job parameters (image stays on disk). #[derive(Debug, Clone)] pub struct FirmwareJob { - /// Soft-AP or LAN. + /// Soft-AP, LAN, or USB. pub mode: ReachMode, - /// Path to `.app.bin`. + /// Path to the image (`.app.bin`, merged `.bin`, or ELF). pub path: std::path::PathBuf, /// Explicit LAN IPv4, when set. pub host: Option, + /// USB serial device, when set. + pub port: Option, + /// CSV partition table for ELF USB flashes. + pub partition_table: Option, } /// Internal job record. diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index 6c4bde4..ca79edf 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -158,6 +158,42 @@ impl Runtime { Ok(out) } + /// Enumerate USB serial ports (`espflash list-ports` / `/dev/ttyUSB*` / `ttyACM*`). + pub fn scan_usb(&self) -> Result, wp_core::DriverError> { + let ports = wp_link::list_usb_ports() + .map_err(|e| wp_core::DriverError::Other(format!("usb scan: {e}")))?; + let mut out = Vec::new(); + let mut cache = self.cache.lock(); + for p in ports { + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::LongFred.id_str().into(), + key: p.path.clone(), + label: p.label, + rssi: None, + }; + cache.insert((cached.driver.clone(), cached.key.clone()), cached.clone()); + out.push(cached); + } + Ok(out) + } + + /// Remember a USB serial device so `updateFirmware` can skip scan when `--port` is set. + pub fn cache_usb_port(&self, port: &str, label: Option<&str>) { + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::LongFred.id_str().into(), + key: port.to_string(), + label: label.unwrap_or(port).to_string(), + rssi: None, + }; + self.cache + .lock() + .insert((cached.driver.clone(), cached.key.clone()), cached); + } + /// Remember a LAN host so `updateFirmware` can skip scan when `--host` is set. pub fn cache_lan_host(&self, host: &str, label: Option<&str>) { let cached = CachedCandidate { @@ -732,27 +768,31 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob return; }; - let image = match std::fs::read(&job.path) { - Ok(b) if !b.is_empty() => b, - Ok(_) => { - rt.jobs.transition( - &id, - JobState::Failed, - None, - None, - Some("firmware file is empty"), - ); - return; - } - Err(e) => { - rt.jobs.transition( - &id, - JobState::Failed, - None, - None, - Some(&format!("read {}: {e}", job.path.display())), - ); - return; + let image = if job.mode == ReachMode::Usb { + Vec::new() + } else { + match std::fs::read(&job.path) { + Ok(b) if !b.is_empty() => b, + Ok(_) => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware file is empty"), + ); + return; + } + Err(e) => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some(&format!("read {}: {e}", job.path.display())), + ); + return; + } } }; @@ -765,6 +805,34 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob }; let outcome = match job.mode { + ReachMode::Usb => { + let port = job + .port + .clone() + .or_else(|| rt.cached(&snap.driver, &snap.key).map(|c| c.key)) + .unwrap_or_else(|| snap.key.clone()); + if port.is_empty() { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("USB firmware update needs --port or scan --mode usb"), + ); + return; + } + sink.step("write"); + sink.detail(&format!("espflash {port}")); + let table = job.partition_table.clone(); + let image_path = job.path.clone(); + match wp_link::flash_usb(&port, &image_path, table.as_deref()) { + Ok(()) => Ok(wp_core::Outcome { + restarted: true, + mismatches: Vec::new(), + }), + Err(e) => Err(e), + } + } ReachMode::Lan => { let host = job .host diff --git a/crates/wp-client/src/client.rs b/crates/wp-client/src/client.rs index 5623326..923ef99 100644 --- a/crates/wp-client/src/client.rs +++ b/crates/wp-client/src/client.rs @@ -139,6 +139,8 @@ impl Client { candidate: Option, path: impl Into, host: Option, + port: Option, + partition_table: Option, ) -> Result { let resp = self.round_trip(&Request { kind: RequestKind::UpdateFirmware, @@ -147,6 +149,8 @@ impl Client { candidate, path: path.into(), host, + port, + partition_table, })), })?; match self.expect_result(resp, RequestKind::UpdateFirmware)? { diff --git a/crates/wp-link/src/espflash.rs b/crates/wp-link/src/espflash.rs new file mode 100644 index 0000000..1364cff --- /dev/null +++ b/crates/wp-link/src/espflash.rs @@ -0,0 +1,394 @@ +//! Invoke the `espflash` CLI to list USB serial ports and flash LongFred. + +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use wp_core::DriverError; + +/// LongFred is ESP32-C6. +pub const CHIP: &str = "esp32c6"; + +/// `ota_0` offset in LongFred `partitions.csv`. +pub const OTA0_OFFSET: u32 = 0x1_0000; + +/// Dual-slot table (`ota_0` + `ota_1` + metadata) needs an 8 MiB chip. +pub const FLASH_SIZE: &str = "8mb"; + +/// USB `espflash` deadline (erase + write of a full image). +pub const USB_FLASH_DEADLINE: Duration = Duration::from_secs(180); + +/// A USB serial device that may be a LongFred UART / USB-Serial-JTAG port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbPort { + /// Device node (e.g. `/dev/ttyACM0`). + pub path: String, + /// Human-readable label. + pub label: String, +} + +/// How to flash a firmware file over USB. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImageKind { + /// ELF: `espflash flash --partition-table`. + Elf, + /// App image (`.app.bin`, magic `0xE9`): `write-bin` at [`OTA0_OFFSET`]. + AppBin { + /// Flash offset. + offset: u32, + }, + /// Merged flash dump (`save-image --merge`): `write-bin` at 0x0. + MergedBin { + /// Flash offset. + offset: u32, + }, +} + +/// Classify an image from its path, header bytes, and length. +/// +/// # Errors +/// +/// Returns a message when the file is not an ELF, ESP app image, or merged dump. +pub fn classify_image(path: &Path, header: &[u8], file_len: u64) -> Result { + if header.len() >= 4 && header[..4] == [0x7f, b'E', b'L', b'F'] { + return Ok(ImageKind::Elf); + } + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if name.ends_with(".app.bin") { + return Ok(ImageKind::AppBin { + offset: OTA0_OFFSET, + }); + } + if header.first() != Some(&0xE9) { + return Err(format!( + "{} is not an ELF or ESP32 image (expected ELF magic or 0xE9)", + path.display() + )); + } + if name.ends_with(".bin") && file_len > u64::from(OTA0_OFFSET) { + return Ok(ImageKind::MergedBin { offset: 0 }); + } + Ok(ImageKind::AppBin { + offset: OTA0_OFFSET, + }) +} + +/// Look for `partitions.csv` next to the image when the caller did not pass one. +#[must_use] +pub fn resolve_partition_table(image: &Path, explicit: Option<&Path>) -> Option { + if let Some(p) = explicit { + if p.is_file() { + return Some(p.to_path_buf()); + } + } + let dir = image.parent()?; + for name in ["partitions.csv", "partition-table.csv"] { + let p = dir.join(name); + if p.is_file() { + return Some(p); + } + } + None +} + +fn before_reset(port: &str) -> &'static str { + if port.contains("ttyACM") || port.contains("usbmodem") { + "usb-reset" + } else { + "default-reset" + } +} + +/// Build the `espflash` argv (not including the program name). +/// +/// # Errors +/// +/// ELF flashes require a partition table so LongFred dual-slot layout is used +/// instead of the bundled espflash default. +pub fn flash_argv( + kind: &ImageKind, + port: &str, + image: &Path, + partition_table: Option<&Path>, +) -> Result, String> { + let image = image.display().to_string(); + let before = before_reset(port); + let mut args = vec![ + String::new(), // filled below + "--non-interactive".into(), + "--skip-update-check".into(), + "--chip".into(), + CHIP.into(), + "--port".into(), + port.into(), + "--before".into(), + before.into(), + ]; + match kind { + ImageKind::Elf => { + let table = partition_table.ok_or_else(|| { + "ELF USB flash needs --partition-table (LongFred partitions.csv)".to_string() + })?; + args[0] = "flash".into(); + args.push("--flash-size".into()); + args.push(FLASH_SIZE.into()); + args.push("--partition-table".into()); + args.push(table.display().to_string()); + args.push(image); + } + ImageKind::AppBin { offset } | ImageKind::MergedBin { offset } => { + args[0] = "write-bin".into(); + args.push(format!("{offset:#x}")); + args.push(image); + } + } + Ok(args) +} + +/// Parse `espflash list-ports -n` (one device path per line). +#[must_use] +pub fn parse_list_ports_output(stdout: &str) -> Vec { + let mut out = Vec::new(); + for line in stdout.lines() { + let path = line.trim(); + if path.is_empty() || path.starts_with('#') { + continue; + } + if !looks_like_serial_path(path) { + continue; + } + out.push(port_from_path(path)); + } + out +} + +fn looks_like_serial_path(path: &str) -> bool { + let name = Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + name.starts_with("ttyusb") + || name.starts_with("ttyacm") + || name.starts_with("cu.usb") + || name.starts_with("cu.wch") + || path.starts_with("/dev/") +} + +fn port_from_path(path: &str) -> UsbPort { + let label = Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + UsbPort { + path: path.to_string(), + label, + } +} + +fn list_dev_serial_nodes() -> Vec { + let Ok(entries) = std::fs::read_dir("/dev") else { + return Vec::new(); + }; + let mut out = Vec::new(); + for ent in entries.flatten() { + let name = ent.file_name(); + let name = name.to_string_lossy(); + if !(name.starts_with("ttyUSB") || name.starts_with("ttyACM")) { + continue; + } + let path = format!("/dev/{name}"); + out.push(port_from_path(&path)); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out.dedup_by(|a, b| a.path == b.path); + out +} + +/// Enumerate USB serial ports (`espflash list-ports`, then `/dev/ttyUSB*` / `ttyACM*`). +/// +/// # Errors +/// +/// Returns [`io::Error`] only when spawning `espflash` fails for a reason other +/// than a missing binary (missing binary falls back to `/dev`). +pub fn list_usb_ports() -> io::Result> { + match Command::new("espflash") + .args(["list-ports", "-n", "-S"]) + .env("ESPFLASH_SKIP_UPDATE_CHECK", "true") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + { + Ok(out) if out.status.success() => { + let parsed = parse_list_ports_output(&String::from_utf8_lossy(&out.stdout)); + if parsed.is_empty() { + Ok(list_dev_serial_nodes()) + } else { + Ok(parsed) + } + } + Ok(_) | Err(_) => Ok(list_dev_serial_nodes()), + } +} + +/// Flash `image` onto `port` with the `espflash` CLI. +/// +/// # Errors +/// +/// Returns [`DriverError`] when the file cannot be classified, `espflash` is +/// missing, or the process fails / times out. +pub fn flash(port: &str, image: &Path, partition_table: Option<&Path>) -> Result<(), DriverError> { + let mut header = [0u8; 16]; + let mut f = std::fs::File::open(image).map_err(|e| DriverError::Other(e.to_string()))?; + let n = f + .read(&mut header) + .map_err(|e| DriverError::Other(e.to_string()))?; + let file_len = f + .metadata() + .map(|m| m.len()) + .unwrap_or(0) + .max(u64::try_from(n).unwrap_or(0)); + let kind = classify_image(image, &header[..n], file_len).map_err(DriverError::Other)?; + let table = resolve_partition_table(image, partition_table); + let argv = flash_argv(&kind, port, image, table.as_deref()).map_err(DriverError::Other)?; + run_espflash(&argv) +} + +fn run_espflash(argv: &[String]) -> Result<(), DriverError> { + let Some((sub, rest)) = argv.split_first() else { + return Err(DriverError::Other("empty espflash argv".into())); + }; + let mut cmd = Command::new("espflash"); + cmd.arg(sub) + .args(rest) + .env("ESPFLASH_SKIP_UPDATE_CHECK", "true") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + DriverError::Other("espflash not found in PATH".into()) + } else { + DriverError::Other(format!("spawn espflash: {e}")) + } + })?; + let deadline = Instant::now() + USB_FLASH_DEADLINE; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + return Ok(()); + } + let mut stderr = String::new(); + if let Some(mut s) = child.stderr.take() { + let _ = s.read_to_string(&mut stderr); + } + let msg = stderr.trim(); + return Err(DriverError::Other(if msg.is_empty() { + format!("espflash {sub} failed ({status})") + } else { + format!("espflash {sub} failed: {msg}") + })); + } + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(DriverError::Other(format!( + "espflash timed out after {}s", + USB_FLASH_DEADLINE.as_secs() + ))); + } + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(e) => return Err(DriverError::Other(format!("wait espflash: {e}"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_elf_magic() { + let k = classify_image(Path::new("fw.elf"), b"\x7fELF\x01\x01", 100).unwrap(); + assert_eq!(k, ImageKind::Elf); + } + + #[test] + fn classifies_app_bin_suffix() { + let k = classify_image(Path::new("longfred.app.bin"), &[0xE9, 0, 0, 0], 1024).unwrap(); + assert_eq!( + k, + ImageKind::AppBin { + offset: OTA0_OFFSET + } + ); + } + + #[test] + fn classifies_merged_bin_by_size() { + let k = classify_image(Path::new("longfred.bin"), &[0xE9, 0, 0, 0], 0x20_0000).unwrap(); + assert_eq!(k, ImageKind::MergedBin { offset: 0 }); + } + + #[test] + fn small_e9_without_app_suffix_is_app() { + let k = classify_image(Path::new("fw.bin"), &[0xE9, 0], 4096).unwrap(); + assert_eq!( + k, + ImageKind::AppBin { + offset: OTA0_OFFSET + } + ); + } + + #[test] + fn rejects_unknown() { + assert!(classify_image(Path::new("fw.txt"), b"hello", 5).is_err()); + } + + #[test] + fn elf_argv_requires_partition_table() { + let kind = ImageKind::Elf; + assert!(flash_argv(&kind, "/dev/ttyUSB0", Path::new("a.elf"), None).is_err()); + let argv = flash_argv( + &kind, + "/dev/ttyUSB0", + Path::new("a.elf"), + Some(Path::new("partitions.csv")), + ) + .unwrap(); + assert_eq!(argv[0], "flash"); + assert!(argv.contains(&"--partition-table".into())); + assert!(argv.contains(&"partitions.csv".into())); + assert!(argv.contains(&"--flash-size".into())); + assert!(argv.contains(&"default-reset".into())); + } + + #[test] + fn acm_uses_usb_reset() { + let argv = flash_argv( + &ImageKind::AppBin { + offset: OTA0_OFFSET, + }, + "/dev/ttyACM0", + Path::new("a.app.bin"), + None, + ) + .unwrap(); + assert_eq!(argv[0], "write-bin"); + assert!(argv.contains(&"usb-reset".into())); + assert!(argv.contains(&"0x10000".into())); + } + + #[test] + fn parse_name_only_ports() { + let ports = parse_list_ports_output("/dev/ttyUSB0\n/dev/ttyACM0\n\n"); + assert_eq!(ports.len(), 2); + assert_eq!(ports[0].path, "/dev/ttyUSB0"); + assert_eq!(ports[1].label, "ttyACM0"); + } +} diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index aef272e..430d25a 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -3,11 +3,17 @@ #![forbid(unsafe_code)] +pub mod espflash; pub mod http; pub mod mdns; pub mod radio; pub mod rfkill; +pub use espflash::{ + classify_image, flash as flash_usb, flash_argv, list_usb_ports, parse_list_ports_output, + resolve_partition_table, ImageKind, UsbPort, CHIP, FLASH_SIZE, OTA0_OFFSET, USB_FLASH_DEADLINE, +}; + pub use http::{percent_encode, BoundedHttpClient, MAX_BODY_BYTES}; pub use mdns::{discover_ota_hosts, parse_ota_hosts, OtaHost, OTA_HTTP_SERVICE}; pub use radio::{ diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index e1f8607..4de9313 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -149,13 +149,15 @@ pub enum ReachMode { Ap, /// Device already on the layout LAN (mDNS / `--host`). Lan, + /// USB serial via `espflash` (`--port` / `scan --mode usb`). + Usb, } /// `scan` parameters. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScanParams { - /// Soft-AP radio scan (`ap`, default) or LAN mDNS (`lan`). + /// Soft-AP radio scan (`ap`, default), LAN mDNS (`lan`), or USB serial (`usb`). #[serde(default)] pub mode: ReachMode, } @@ -165,17 +167,24 @@ pub struct ScanParams { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateFirmwareParams { - /// Soft-AP (`ap`, default) or layout LAN (`lan`). + /// Soft-AP (`ap`, default), layout LAN (`lan`), or USB `espflash` (`usb`). #[serde(default)] pub mode: ReachMode, - /// Candidate from `scan`. Optional when [`Self::host`] is set in LAN mode. + /// Candidate from `scan`. Optional when [`Self::host`] is set in LAN mode + /// or [`Self::port`] in USB mode. #[serde(skip_serializing_if = "Option::is_none", default)] pub candidate: Option, - /// Path to an ESP32-C6 `.app.bin` on the hub. + /// Path to a LongFred image on the hub (`.app.bin`, merged `.bin`, or ELF). pub path: String, /// Explicit IPv4 for LAN mode (skips mDNS). #[serde(skip_serializing_if = "Option::is_none", default)] pub host: Option, + /// USB serial device (e.g. `/dev/ttyACM0`). USB mode; skips `scan --mode usb`. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub port: Option, + /// CSV partition table for ELF USB flashes (`espflash flash --partition-table`). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub partition_table: Option, } /// A reference to a scan result, stable for the lifetime of a scan session. diff --git a/docs/api.md b/docs/api.md index 4e7f3a6..dfeb042 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,10 +28,10 @@ the response so callers can correlate requests without an explicit id. | Method | Params | Result | Notes | |----------------|-------------------------|-----------------------|--------------------------------| | `hello` | none | `HelloResult` | Version + driver capabilities | -| `scan` | `{ mode? }` | `Candidate[]` | Soft-AP radio (`ap`, default) or LAN mDNS (`lan`) | +| `scan` | `{ mode? }` | `Candidate[]` | Soft-AP (`ap`), LAN mDNS (`lan`), or USB serial (`usb`) | | `probe` | `{ candidate }` | `DeviceInfo` | Read a single device's info | | `program` | `{ candidate, request }` | `ProgramResult` | Start a job, returns `jobId` | -| `updateFirmware` | `{ mode, candidate?, path, host? }` | `ProgramResult` | HTTP firmware upload job | +| `updateFirmware` | `{ mode, candidate?, path, host?, port?, partitionTable? }` | `ProgramResult` | Firmware upload job | | `job.get` | `{ jobId }` | `JobSnapshot` | Snapshot a job's state | | `job.watch` | `{ jobId }` | `JobFrame` (stream) | Stream progress until terminal | | `job.cancel` | `{ jobId }` | `JobSnapshot` | Request cancellation | @@ -51,7 +51,7 @@ package version. `commit` is the matching tag/build commit when available. ### `scan` -Optional `params.mode` is `"ap"` (default) or `"lan"`. +Optional `params.mode` is `"ap"` (default), `"lan"`, or `"usb"`. Soft-AP (`ap`) triggers an nl80211 scan and returns the candidates each driver claims: @@ -63,24 +63,33 @@ LAN (`lan`) does not use the radio. It queries mDNS for `_longfred-ota._tcp.local` and returns LongFred candidates whose `key` is the advertised IPv4. +USB (`usb`) lists serial ports (`espflash list-ports -n`, then +`/dev/ttyUSB*` / `/dev/ttyACM*`). Each candidate `key` is the device node. + ### `updateFirmware` -Starts a firmware-upload job. The image path is on the hub filesystem -(typically a `.app.bin` produced by `espflash save-image` without -`--merge`). `mode` is `"ap"` or `"lan"`. +Starts a firmware-upload job. The image path is on the hub filesystem. +`mode` is `"ap"`, `"lan"`, or `"usb"`. - **AP**: join the LongFred Soft-AP like `program`, then - `POST /api/v1/firmware` with `application/octet-stream`. The HTTP - transfer has a 120 s deadline and is not retried. After a successful + `POST /api/v1/firmware` with `application/octet-stream` (`.app.bin` only). + The HTTP transfer has a 120 s deadline and is not retried. After a successful reboot the device stays in programming mode. - **LAN**: no radio. HTTP to `candidate.key` (an IPv4 from `scan` with `mode: "lan"`) or `params.host`. The throttle must have HTTP OTA enabled from the Firmware update menu. After reboot it rejoins layout Wi‑Fi. +- **USB**: no radio. Runs `espflash` against `params.port` or + `candidate.key` (a serial device from `scan` with `mode: "usb"`). If + neither is set and exactly one port is present, that port is used. + ELF images need `params.partitionTable` (or `partitions.csv` next to + the file) so the dual-slot table is written. Merged `.bin` is + `write-bin` at `0x0`; `.app.bin` is `write-bin` at `ota_0` (`0x10000`). + `espflash` must be on `PATH`. Deadline 180 s. A driver with `supportsFirmwareUpdate: false` (WiFred) returns -`driverError`. A second job while the radio is held returns `busy` -(LAN jobs do not take the radio). +`driverError`. A second job while another job is active returns `busy` +(LAN and USB jobs do not take the radio). ```jsonc { @@ -194,10 +203,10 @@ over the same socket. Every client subcommand accepts `--json` | Subcommand | Purpose | |------------|---------| -| `scan [--mode ap\|lan]` | Enumerate Soft-AP APs or LAN OTA hosts | +| `scan [--mode ap\|lan\|usb]` | Enumerate Soft-AP APs, LAN OTA hosts, or USB serial ports | | `probe --driver --key` | Read a single candidate's device info | | `program --driver --key ...` | Start a programming job and stream progress to completion | -| `update-firmware --mode ap\|lan --file ...` | Upload firmware over HTTP | +| `update-firmware --mode ap\|lan\|usb --file ...` | Upload firmware over HTTP or USB `espflash` | | `identify --driver --key [--count N]` | Blink the device LED | | `job get\|watch\|cancel --id` | Inspect or control a running job | | `link-status` | Report radio/link state | diff --git a/docs/cli.md b/docs/cli.md index 8d68365..a849198 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,7 +12,7 @@ Commands: scan Enumerate candidate devices (Soft-AP radio or LAN mDNS) probe Read a single candidate's device info program Start a programming job and stream its progress - update-firmware Upload a firmware image over HTTP (Soft-AP or LAN) + update-firmware Upload a firmware image (Soft-AP, LAN, or USB espflash) identify Blink a device's LED so an operator can find it link-status Report radio/link state hello Exchange version + driver capabilities @@ -106,6 +106,9 @@ wireless-programmer scan # LAN scan (LongFred HTTP OTA via mDNS `_longfred-ota._tcp`): wireless-programmer scan --mode lan +# USB serial ports (`espflash list-ports` / `/dev/ttyUSB*` / `ttyACM*`): +wireless-programmer scan --mode usb + # 3. Read one device's current config over the radio. wireless-programmer probe --driver wifred --key AA:BB:CC:DD:EE:01 @@ -122,15 +125,17 @@ wireless-programmer scan --json | jq '.[] | select(.rssi != null) | .key' ## Firmware update -`update-firmware` POSTs an application image (`.app.bin`, not a merged -flash dump) to LongFred `POST /api/v1/firmware`. The HTTP transfer has a -120 s deadline and is **not** retried. WiFred does not support firmware -upload. +`update-firmware` uploads a LongFred image. Soft-AP and LAN POST +`.app.bin` to `POST /api/v1/firmware` (120 s, not retried). USB runs +`espflash` on a serial port (ELF, merged `.bin`, or `.app.bin`). WiFred +does not support firmware upload. Use `--mode ap` after putting the throttle into Soft-AP programming mode (8-second chord). Use `--mode lan` when the throttle is already on the layout Wi‑Fi and the operator has opened **Firmware update** in the Extras -menu (HTTP is enabled only while that screen is shown). +menu (HTTP is enabled only while that screen is shown). Use `--mode usb` +with the throttle on a USB-UART (or native USB-Serial-JTAG) cable; +`espflash` must be on `PATH`. ```bash # Soft-AP: join longfred_prog_*, POST the image, keep programming_mode. @@ -142,6 +147,13 @@ wireless-programmer update-firmware --mode lan --driver longfred \ --key 192.168.1.42 --file longfred-markwtech-esp32c6.app.bin wireless-programmer update-firmware --mode lan --host 192.168.1.42 \ --file longfred-markwtech-esp32c6.app.bin + +# USB: first install of the dual-slot table, or a cable update. +wireless-programmer scan --mode usb +wireless-programmer update-firmware --mode usb --port /dev/ttyUSB0 \ + --file longfred-markwtech-esp32c6.elf --partition-table partitions.csv +wireless-programmer update-firmware --mode usb --port /dev/ttyACM0 \ + --file longfred-markwtech-esp32c6.bin ``` Like `program`, the command watches the job by default; `--no-watch` diff --git a/docs/drivers/longfred.md b/docs/drivers/longfred.md index 94ec77b..9c5256d 100644 --- a/docs/drivers/longfred.md +++ b/docs/drivers/longfred.md @@ -72,8 +72,13 @@ merged flash dump. - LAN: HTTP to the layout IPv4 while the Firmware update menu is open; after reboot the device rejoins layout Wi‑Fi. Discover hosts via mDNS `_longfred-ota._tcp.local` (`scan --mode lan`). +- USB: `espflash` on a serial port (`scan --mode usb` / `--port`). ELF + needs `--partition-table partitions.csv` (first install of the dual-slot + table). Merged `.bin` is written at `0x0`; `.app.bin` at `ota_0` + (`0x10000`). Requires `espflash` on `PATH`. -The HTTP transfer has a 120 s deadline and is not retried. +The HTTP transfer has a 120 s deadline and is not retried. USB `espflash` +has a 180 s deadline. ## Testing diff --git a/docs/go-client.md b/docs/go-client.md index 5cfe326..089beda 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -64,10 +64,10 @@ failure (see [Errors](#errors)). |--------|-------------|---------| | `Hello()` | `hello` | `*HelloResult` (version + drivers) | | `Scan()` | `scan` | `[]CandidateWire` (Soft-AP) | -| `ScanMode(mode)` | `scan` | `[]CandidateWire` (`ap` or `lan`) | +| `ScanMode(mode)` | `scan` | `[]CandidateWire` (`ap`, `lan`, or `usb`) | | `Probe(candidate)` | `probe` | `*DeviceInfoWire` | | `Program(candidate, req)` | `program` | `*ProgramResult` (job id) | -| `UpdateFirmware(mode, candidate, path, host)` | `updateFirmware` | `*ProgramResult` (job id) | +| `UpdateFirmware(mode, candidate, path, host, port, partitionTable)` | `updateFirmware` | `*ProgramResult` (job id) | | `JobGet(jobID)` | `job.get` | `*JobSnapshot` | | `JobCancel(jobID)` | `job.cancel` | `*JobSnapshot` | | `Identify(candidate, count)` | `identify` | `nil` | diff --git a/go/client/client.go b/go/client/client.go index 33cedff..dcfe146 100644 --- a/go/client/client.go +++ b/go/client/client.go @@ -193,13 +193,15 @@ type request struct { } type requestParams struct { - Candidate *CandidateRef `json:"candidate,omitempty"` - Request *ProgramRequestWire `json:"request,omitempty"` - JobID string `json:"jobId,omitempty"` - Count *uint32 `json:"count,omitempty"` - Mode string `json:"mode,omitempty"` - Path string `json:"path,omitempty"` - Host string `json:"host,omitempty"` + Candidate *CandidateRef `json:"candidate,omitempty"` + Request *ProgramRequestWire `json:"request,omitempty"` + JobID string `json:"jobId,omitempty"` + Count *uint32 `json:"count,omitempty"` + Mode string `json:"mode,omitempty"` + Path string `json:"path,omitempty"` + Host string `json:"host,omitempty"` + Port string `json:"port,omitempty"` + PartitionTable string `json:"partitionTable,omitempty"` } // Client dials the wireless-programmer Unix socket. @@ -261,7 +263,7 @@ func (c *Client) Scan() ([]CandidateWire, error) { return c.ScanMode("ap") } -// ScanMode enumerates candidates. mode is "ap" (radio Soft-AP) or "lan" (mDNS). +// ScanMode enumerates candidates. mode is "ap" (radio Soft-AP), "lan" (mDNS), or "usb". func (c *Client) ScanMode(mode string) ([]CandidateWire, error) { var params *requestParams if mode != "" && mode != "ap" { @@ -284,10 +286,17 @@ func (c *Client) ScanMode(mode string) ([]CandidateWire, error) { return out, nil } -// UpdateFirmware queues an HTTP firmware upload job (image path on the hub). -// mode is "ap" or "lan". host is an optional LAN IPv4. -func (c *Client) UpdateFirmware(mode string, candidate *CandidateRef, path, host string) (*ProgramResult, error) { - params := &requestParams{Mode: mode, Path: path, Host: host, Candidate: candidate} +// UpdateFirmware queues a firmware-upload job (image path on the hub). +// mode is "ap", "lan", or "usb". host is an optional LAN IPv4; port is a USB serial device. +func (c *Client) UpdateFirmware(mode string, candidate *CandidateRef, path, host, port, partitionTable string) (*ProgramResult, error) { + params := &requestParams{ + Mode: mode, + Path: path, + Host: host, + Port: port, + PartitionTable: partitionTable, + Candidate: candidate, + } var resp Response if err := c.roundTrip(request{Type: "updateFirmware", Params: params}, &resp); err != nil { return nil, err From 4afa21f7c47afe810ab9bda3f30c1084e625e298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:04:03 +0200 Subject: [PATCH 3/3] Keep firmware job.watch alive during USB and HTTP OTA. espflash and the firmware POST block without progress frames, so the 10s client idle dropped the watch. Heartbeat every 3s, honour job cancel, and default update-firmware watch idle to the flash/POST deadline. Co-authored-by: Cursor --- crates/wireless-programmer/src/cli/client.rs | 19 ++- crates/wireless-programmer/src/cli/mod.rs | 3 +- crates/wireless-programmer/src/jobs.rs | 17 ++ crates/wireless-programmer/src/runtime.rs | 156 ++++++++++++++--- crates/wp-drivers/src/longfred/mod.rs | 8 +- crates/wp-link/src/espflash.rs | 22 ++- crates/wp-link/src/http.rs | 168 ++++++++++++++++++- docs/api.md | 5 +- docs/cli.md | 22 ++- docs/go-client.md | 4 +- 10 files changed, 378 insertions(+), 46 deletions(-) diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 5871b51..4286583 100644 --- a/crates/wireless-programmer/src/cli/client.rs +++ b/crates/wireless-programmer/src/cli/client.rs @@ -115,6 +115,23 @@ fn parse_reach_mode(mode: &str) -> wp_client::ReachMode { } } +/// `update-firmware` watch idle: USB matches `espflash` (180 s), HTTP matches +/// the LongFred POST (120 s). Explicit `--timeout` still wins. The daemon also +/// heartbeats every 3 s so a 10 s client (Go) still works. +fn firmware_watch_timeout( + explicit: Option, + mode: wp_client::ReachMode, +) -> Option { + if explicit.is_some() { + return explicit; + } + let d = match mode { + wp_client::ReachMode::Usb => wp_link::USB_FLASH_DEADLINE, + _ => crate::jobs::FIRMWARE_DEADLINE, + }; + Some(humantime::Duration::from(d)) +} + fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { if !args.file.is_file() { return Err(CliError::File { @@ -137,7 +154,7 @@ fn update_firmware(socket: &Path, args: UpdateFirmwareArgs) -> HandlerResult { if key.is_none() && mode != wp_client::ReachMode::Usb { return Err(CliError::Usage("provide --key and/or --host".into())); } - let c = build_client(socket, args.common.timeout); + let c = build_client(socket, firmware_watch_timeout(args.common.timeout, mode)); let candidate = key.map(|key| wp_client::CandidateRef { driver: args.driver.clone(), key, diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index b558dc9..205e46a 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -79,7 +79,8 @@ pub struct ClientCommon { /// Emit machine-readable JSON instead of human-readable text. #[arg(long, global = true)] pub json: bool, - /// Per-operation timeout (e.g. `30s`). + /// Per-operation timeout (e.g. `30s`). Default 10s; `update-firmware` + /// uses 180s (USB) or 120s (HTTP) when omitted. #[arg(long, global = true)] pub timeout: Option, } diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index 06c1335..e499f2a 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -17,6 +17,11 @@ pub const JOB_DEADLINE: Duration = Duration::from_secs(120); /// Firmware POST deadline (matches LongFred HTTP timeout). pub const FIRMWARE_DEADLINE: Duration = Duration::from_secs(120); +/// How often firmware jobs emit a `job.watch` frame while blocked in +/// `espflash` or an HTTP POST. Must stay well under the client idle default +/// (10 s) so Go and CLI watchers do not drop the stream. +pub const WATCH_HEARTBEAT: Duration = Duration::from_secs(3); + /// A job identifier. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct JobId(pub String); @@ -317,3 +322,15 @@ impl Default for JobRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn watch_heartbeat_beats_default_client_idle() { + assert!(WATCH_HEARTBEAT < Duration::from_secs(10)); + assert!(WATCH_HEARTBEAT < FIRMWARE_DEADLINE); + assert!(FIRMWARE_DEADLINE <= wp_link::USB_FLASH_DEADLINE); + } +} diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index ca79edf..655cfae 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -6,8 +6,9 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::{ @@ -803,6 +804,7 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob jobs: &rt.jobs, id: &id, }; + let cancel = Arc::new(AtomicBool::new(false)); let outcome = match job.mode { ReachMode::Usb => { @@ -825,13 +827,22 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob sink.detail(&format!("espflash {port}")); let table = job.partition_table.clone(); let image_path = job.path.clone(); - match wp_link::flash_usb(&port, &image_path, table.as_deref()) { - Ok(()) => Ok(wp_core::Outcome { - restarted: true, - mismatches: Vec::new(), - }), - Err(e) => Err(e), - } + let label = format!("espflash {port}"); + await_blocking_with_heartbeats( + rt, + &id, + &mut sink, + &label, + Arc::clone(&cancel), + move |cancel| { + wp_link::flash_usb(&port, &image_path, table.as_deref(), Some(cancel.as_ref())) + .map(|()| wp_core::Outcome { + restarted: true, + mismatches: Vec::new(), + }) + }, + ) + .await } ReachMode::Lan => { let host = job @@ -849,11 +860,20 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob ); return; } - let mut client = make_firmware_http_client(&host, 80, None); - let transport = Transport::Http(&mut client); - rt.registry - .update_firmware(driver, transport, &image, &mut sink) - .await + sink.step("write"); + sink.detail(&format!("{} bytes", image.len())); + firmware_http_with_heartbeats( + rt, + &id, + &mut sink, + driver, + image, + &host, + 80, + None, + Arc::clone(&cancel), + ) + .await } ReachMode::Ap => { let candidate = match rt.cached(&snap.driver, &snap.key) { @@ -910,16 +930,20 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob drop(radio); rt.jobs .transition(&id, JobState::Writing, Some("write"), None, None); - let mut client = make_firmware_http_client( + sink.step("write"); + sink.detail(&format!("{} bytes", image.len())); + let result = firmware_http_with_heartbeats( + rt, + &id, + &mut sink, + driver, + image, &net.host.to_string(), net.port, Some(SocketAddr::from((net.source, 0))), - ); - let transport = Transport::Http(&mut client); - let result = rt - .registry - .update_firmware(driver, transport, &image, &mut sink) - .await; + Arc::clone(&cancel), + ) + .await; { let mut radio = rt.radio.lock().await; let _ = radio.release().await; @@ -928,15 +952,99 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob } }; + finish_firmware_job(rt, &id, outcome); +} + +#[allow(clippy::too_many_arguments)] +async fn firmware_http_with_heartbeats( + rt: &Runtime, + id: &JobId, + sink: &mut JobProgressSink<'_>, + driver: Driver, + image: Vec, + host: &str, + port: u16, + source: Option, + cancel: Arc, +) -> Result { + let tokio_h = rt.handle(); + let registry = Arc::clone(&rt.registry); + let client = make_firmware_http_client(host, port, source, Some(Arc::clone(&cancel))); + await_blocking_with_heartbeats(rt, id, sink, "firmware http", cancel, move |_cancel| { + let mut client = client; + let mut nop = wp_core::NoProgress; + let transport = Transport::Http(&mut client); + tokio_h.block_on(registry.update_firmware(driver, transport, &image, &mut nop)) + }) + .await +} + +/// Run blocking firmware work off the worker thread and keep `job.watch` +/// alive with a detail frame every [`crate::jobs::WATCH_HEARTBEAT`]. +async fn await_blocking_with_heartbeats( + rt: &Runtime, + id: &JobId, + sink: &mut JobProgressSink<'_>, + label: &str, + cancel: Arc, + work: F, +) -> Result +where + T: Send + 'static, + F: FnOnce(Arc) -> Result + Send + 'static, +{ + let mut handle = tokio::task::spawn_blocking({ + let cancel = Arc::clone(&cancel); + move || work(cancel) + }); + let started = Instant::now(); + loop { + tokio::select! { + biased; + joined = &mut handle => { + return joined.map_err(|e| { + wp_core::DriverError::Other(format!("firmware worker join: {e}")) + })?; + } + _ = tokio::time::sleep(crate::jobs::WATCH_HEARTBEAT) => { + if rt.jobs.is_cancelled(id) { + cancel.store(true, Ordering::Relaxed); + continue; + } + let secs = started.elapsed().as_secs(); + sink.detail(&format!("{label} ({secs}s)")); + } + } + } +} + +fn finish_firmware_job( + rt: &Runtime, + id: &JobId, + outcome: Result, +) { + if rt + .jobs + .snapshot(id) + .map(|s| s.state.is_terminal()) + .unwrap_or(false) + { + return; + } + if rt.jobs.is_cancelled(id) || matches!(outcome, Err(wp_core::DriverError::Cancelled)) { + rt.jobs + .transition(id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } match outcome { Ok(o) => { let detail = if o.restarted { Some("restarted") } else { None }; rt.jobs - .transition(&id, JobState::Done, Some("done"), Some(100), detail); + .transition(id, JobState::Done, Some("done"), Some(100), detail); } Err(e) => { rt.jobs - .transition(&id, JobState::Failed, None, None, Some(&e.to_string())); + .transition(id, JobState::Failed, None, None, Some(&e.to_string())); } } } @@ -945,6 +1053,7 @@ fn make_firmware_http_client( host: &str, port: u16, source: Option, + cancel: Option>, ) -> BoundedHttpClient { let mut c = BoundedHttpClient::new(host, port) .with_deadline(crate::jobs::FIRMWARE_DEADLINE) @@ -952,6 +1061,9 @@ fn make_firmware_http_client( if let Some(src) = source { c = c.with_source(src); } + if let Some(flag) = cancel { + c = c.with_cancel(flag); + } c } diff --git a/crates/wp-drivers/src/longfred/mod.rs b/crates/wp-drivers/src/longfred/mod.rs index 5cc2ea8..dd368ac 100644 --- a/crates/wp-drivers/src/longfred/mod.rs +++ b/crates/wp-drivers/src/longfred/mod.rs @@ -151,7 +151,13 @@ impl LongFredDriver { progress.detail(&format!("{} bytes", image.len())); client .request("POST", FIRMWARE_PATH, Some((FIRMWARE_CONTENT_TYPE, image))) - .map_err(|e| DriverError::Http(e.to_string()))?; + .map_err(|e| { + if e.kind() == std::io::ErrorKind::Interrupted { + DriverError::Cancelled + } else { + DriverError::Http(e.to_string()) + } + })?; progress.step("restart"); Ok(Outcome { restarted: true, diff --git a/crates/wp-link/src/espflash.rs b/crates/wp-link/src/espflash.rs index 1364cff..c74dff2 100644 --- a/crates/wp-link/src/espflash.rs +++ b/crates/wp-link/src/espflash.rs @@ -3,6 +3,7 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use wp_core::DriverError; @@ -236,11 +237,19 @@ pub fn list_usb_ports() -> io::Result> { /// Flash `image` onto `port` with the `espflash` CLI. /// +/// `cancel` is polled while waiting on the child; a true value kills it and +/// returns [`DriverError::Cancelled`]. +/// /// # Errors /// /// Returns [`DriverError`] when the file cannot be classified, `espflash` is -/// missing, or the process fails / times out. -pub fn flash(port: &str, image: &Path, partition_table: Option<&Path>) -> Result<(), DriverError> { +/// missing, or the process fails / times out / is cancelled. +pub fn flash( + port: &str, + image: &Path, + partition_table: Option<&Path>, + cancel: Option<&AtomicBool>, +) -> Result<(), DriverError> { let mut header = [0u8; 16]; let mut f = std::fs::File::open(image).map_err(|e| DriverError::Other(e.to_string()))?; let n = f @@ -254,10 +263,10 @@ pub fn flash(port: &str, image: &Path, partition_table: Option<&Path>) -> Result let kind = classify_image(image, &header[..n], file_len).map_err(DriverError::Other)?; let table = resolve_partition_table(image, partition_table); let argv = flash_argv(&kind, port, image, table.as_deref()).map_err(DriverError::Other)?; - run_espflash(&argv) + run_espflash(&argv, cancel) } -fn run_espflash(argv: &[String]) -> Result<(), DriverError> { +fn run_espflash(argv: &[String], cancel: Option<&AtomicBool>) -> Result<(), DriverError> { let Some((sub, rest)) = argv.split_first() else { return Err(DriverError::Other("empty espflash argv".into())); }; @@ -301,6 +310,11 @@ fn run_espflash(argv: &[String]) -> Result<(), DriverError> { USB_FLASH_DEADLINE.as_secs() ))); } + Ok(None) if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(DriverError::Cancelled); + } Ok(None) => std::thread::sleep(Duration::from_millis(100)), Err(e) => return Err(DriverError::Other(format!("wait espflash: {e}"))), } diff --git a/crates/wp-link/src/http.rs b/crates/wp-link/src/http.rs index 6587ff8..05cc4e5 100644 --- a/crates/wp-link/src/http.rs +++ b/crates/wp-link/src/http.rs @@ -8,11 +8,17 @@ use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::{Duration, Instant}; use socket2::{Domain, Socket, Type}; use wp_core::HttpClient; +/// Socket I/O slice used when a cancel flag is armed, so a firmware POST can +/// abort within about a second of `job cancel`. +const CANCEL_POLL: Duration = Duration::from_secs(1); + /// Maximum response body: 64 KiB. pub const MAX_BODY_BYTES: usize = 64 * 1024; @@ -41,6 +47,8 @@ pub struct BoundedHttpClient { retries: u32, /// Maximum response body size. max_body: usize, + /// When set, long reads/writes abort with [`io::ErrorKind::Interrupted`]. + cancel: Option>, } impl BoundedHttpClient { @@ -54,6 +62,7 @@ impl BoundedHttpClient { connect_deadline: CONNECT_DEADLINE, retries: RETRIES, max_body: MAX_BODY_BYTES, + cancel: None, } } @@ -75,6 +84,12 @@ impl BoundedHttpClient { self } + /// Abort in-flight I/O when `cancel` becomes true (firmware POST). + pub fn with_cancel(mut self, cancel: Arc) -> Self { + self.cancel = Some(cancel); + self + } + /// Issue a single request, returning the raw body. fn request_once( &mut self, @@ -98,6 +113,12 @@ impl BoundedHttpClient { stream.set_read_timeout(Some(self.deadline))?; stream.set_write_timeout(Some(self.deadline))?; let mut stream = stream; + let cancel = self.cancel.as_deref(); + let io_deadline = Instant::now() + self.deadline; + + if cancelled(cancel) { + return Err(io_cancelled()); + } let mut request = format!( "{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n", @@ -111,36 +132,50 @@ impl BoundedHttpClient { )); } request.push_str("\r\n"); - stream.write_all(request.as_bytes())?; + write_all_interruptible(&mut stream, request.as_bytes(), io_deadline, cancel)?; if let Some((_, bytes)) = body { - stream.write_all(bytes)?; + write_all_interruptible(&mut stream, bytes, io_deadline, cancel)?; } - stream.flush()?; + flush_interruptible(&mut stream, io_deadline, cancel)?; - let started = Instant::now(); let mut buf = Vec::with_capacity(4096); let mut chunk = [0u8; 4096]; loop { + if cancelled(cancel) { + return Err(io_cancelled()); + } if buf.len() > self.max_body { return Err(io::Error::new( io::ErrorKind::InvalidData, "response exceeds max body size", )); } - let remaining = self - .deadline - .checked_sub(started.elapsed()) - .unwrap_or_default(); + let remaining = io_deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Err(io::Error::new( io::ErrorKind::TimedOut, "request deadline elapsed", )); } - stream.set_read_timeout(Some(remaining))?; + let slice = if cancel.is_some() { + remaining.min(CANCEL_POLL) + } else { + remaining + }; + stream.set_read_timeout(Some(slice))?; match stream.read(&mut chunk) { Ok(0) => break, Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) + if matches!( + e.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) && cancel.is_some() + && io_deadline.saturating_duration_since(Instant::now()) + > Duration::ZERO => + { + continue; + } Err(e) if e.kind() == io::ErrorKind::TimedOut => { return Err(io::Error::new( io::ErrorKind::TimedOut, @@ -186,6 +221,7 @@ impl HttpClient for BoundedHttpClient { for _ in 0..=self.retries { match self.request_once(method, path, body) { Ok(body) => return Ok(body), + Err(e) if e.kind() == io::ErrorKind::Interrupted => return Err(e), Err(e) => { last = e; } @@ -195,6 +231,93 @@ impl HttpClient for BoundedHttpClient { } } +fn cancelled(cancel: Option<&AtomicBool>) -> bool { + cancel.is_some_and(|c| c.load(Ordering::Relaxed)) +} + +fn io_cancelled() -> io::Error { + io::Error::new(io::ErrorKind::Interrupted, "cancelled") +} + +fn write_all_interruptible( + stream: &mut TcpStream, + mut bytes: &[u8], + deadline: Instant, + cancel: Option<&AtomicBool>, +) -> io::Result<()> { + while !bytes.is_empty() { + if cancelled(cancel) { + return Err(io_cancelled()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "write deadline elapsed", + )); + } + let slice = if cancel.is_some() { + remaining.min(CANCEL_POLL) + } else { + remaining + }; + stream.set_write_timeout(Some(slice))?; + match stream.write(bytes) { + Ok(0) => { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write zero")); + } + Ok(n) => bytes = &bytes[n..], + Err(e) + if matches!( + e.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Err(e) => return Err(e), + } + } + Ok(()) +} + +fn flush_interruptible( + stream: &mut TcpStream, + deadline: Instant, + cancel: Option<&AtomicBool>, +) -> io::Result<()> { + loop { + if cancelled(cancel) { + return Err(io_cancelled()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "write deadline elapsed", + )); + } + let slice = if cancel.is_some() { + remaining.min(CANCEL_POLL) + } else { + remaining + }; + stream.set_write_timeout(Some(slice))?; + match stream.flush() { + Ok(()) => return Ok(()), + Err(e) + if matches!( + e.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Err(e) => return Err(e), + } + } +} + /// Find the start of the response body (after the blank line). fn locate_body(buf: &[u8]) -> io::Result { for i in 3..buf.len() { @@ -282,6 +405,33 @@ mod tests { assert_eq!(c.host, "192.168.4.1"); } + #[test] + fn request_aborts_on_cancel() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let mut buf = [0u8; 64]; + while s.read(&mut buf).unwrap_or(0) > 0 {} + }); + let cancel = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&cancel); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + flag.store(true, Ordering::Relaxed); + }); + let mut c = BoundedHttpClient::new(addr.ip().to_string(), addr.port()) + .with_deadline(Duration::from_secs(10)) + .with_retries(0) + .with_cancel(Arc::clone(&cancel)); + let body = vec![0u8; 64]; + let err = c + .request("POST", "/", Some(("application/octet-stream", &body))) + .expect_err("cancel"); + assert_eq!(err.kind(), io::ErrorKind::Interrupted); + let _ = server.join(); + } + // A trivial in-memory HttpClient for driver tests. #[derive(Default)] pub struct FakeHttp { diff --git a/docs/api.md b/docs/api.md index dfeb042..98f0fff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -147,7 +147,10 @@ writing → verifying → restarting → done`. Progress is observable via Opens a streaming connection. The daemon writes `JobFrame` messages until the job reaches a terminal state (`done`, `failed`, `cancelled`). Callers should set a per-frame idle read deadline (the Go client does this -automatically). +automatically). Firmware jobs emit a detail frame every 3 seconds while +blocked in `espflash` or `POST /api/v1/firmware`, so a 10s per-frame idle +deadline is enough. `job.cancel` kills an in-flight `espflash` child and +aborts the firmware HTTP POST. ### `identify` diff --git a/docs/cli.md b/docs/cli.md index a849198..fdf52fd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -88,7 +88,11 @@ fails at start-up with a non-zero exit. The same choice can be set with Every client subcommand accepts: - `--json` — emit machine-readable JSON instead of human-readable text; -- `--timeout 30s` — per-operation timeout (parsed by `humantime`, default 10s); +- `--timeout 30s` — per-operation timeout (parsed by `humantime`, default 10s). + For `update-firmware` the default is 180s in USB mode and 120s over HTTP, + matching the `espflash` / firmware POST deadline. The daemon also emits a + `job.watch` detail frame every 3s during those transfers, so a 10s idle + client (including the Go SDK) still sees progress; - `--socket PATH` — override the daemon socket path. ## Discovery workflow @@ -157,7 +161,10 @@ wireless-programmer update-firmware --mode usb --port /dev/ttyACM0 \ ``` Like `program`, the command watches the job by default; `--no-watch` -returns the job id immediately. +returns the job id immediately. While `espflash` or the HTTP POST is +running, the daemon writes a detail frame every 3 seconds (for example +`espflash /dev/ttyUSB0 (12s)`). `job cancel` kills the `espflash` child +and aborts an in-flight firmware POST. ## Programming workflow @@ -282,10 +289,13 @@ wireless-programmer job cancel --id # request cancellation its own line. If no frame arrives within the timeout, the client reports `no job progress -frame within ` rather than a bare I/O error. Note that the daemon's -worker loop is hardware-gated: until it drives a live radio, `job.watch` -answers with a single snapshot frame and then goes quiet, so watching a job on -a device-less host reaches that idle deadline by design. +frame within ` rather than a bare I/O error. Firmware jobs keep the +stream alive with a detail frame every 3 seconds, so watching +`update-firmware` does not depend on raising `--timeout` unless you are +talking to an older daemon. Note that the daemon's worker loop is +hardware-gated: until it drives a live radio, `job.watch` answers with a +single snapshot frame and then goes quiet, so watching a job on a +device-less host reaches that idle deadline by design. ## Link status diff --git a/docs/go-client.md b/docs/go-client.md index 089beda..f3937e8 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -131,7 +131,9 @@ like the one above to set them. `JobWatch` opens a streaming connection and returns it; the caller drains `JobFrame`s with `ReadFrame`, which sets a per-frame idle read deadline of -`Timeout`. Close the conn when done. +`Timeout`. Close the conn when done. Firmware jobs heartbeat every 3s, so +the default 10s `Timeout` is enough during USB `espflash` or HTTP OTA. +`JobCancel` stops `espflash` and an in-flight firmware POST. ```go conn, err := c.JobWatch(jobID)