diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 01a24d3..4286583 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,91 @@ 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 = 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, + } +} + +/// `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 { + path: args.file.display().to_string(), + message: "not a file".into(), + }); + } + 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 + }; + let key = args + .key + .clone() + .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, firmware_watch_timeout(args.common.timeout, mode)); + let candidate = key.map(|key| wp_client::CandidateRef { + driver: args.driver.clone(), + key, + }); + let started = c.update_firmware( + mode, + 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 { + 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..205e46a 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. @@ -77,19 +79,61 @@ 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, } /// 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), `lan` (mDNS `_longfred-ota._tcp`), or `usb`. + #[arg(long, default_value = "ap", value_parser = ["ap", "lan", "usb"])] + pub mode: String, +} + +/// `update-firmware` arguments. +#[derive(Debug, Parser)] +pub struct UpdateFirmwareArgs { + #[command(flatten)] + pub common: ClientCommon, + /// `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, 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, + /// 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. + #[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..3042781 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 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) => { 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,107 @@ impl ServerInner { error: None, } } + RequestKind::UpdateFirmware => match req.params { + Some(Params::UpdateFirmware(p)) => { + let driver_id = p + .candidate + .as_ref() + .map(|c| c.driver.clone()) + .unwrap_or_else(|| "longfred".into()); + let key = p + .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() && p.mode != wp_proto::ReachMode::Usb { + return err_response( + RequestKind::UpdateFirmware, + "bad_params", + "candidate.key, host, or port is required", + ); + } + if p.mode == wp_proto::ReachMode::Lan { + if let Some(h) = p.host.as_deref() { + self.runtime.cache_lan_host(h, None); + } + } + 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, + &key, + crate::jobs::FirmwareJob { + 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 { + 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 +612,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..e499f2a 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -9,11 +9,19 @@ 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); + +/// 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); @@ -101,6 +109,33 @@ 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, LAN, or USB. + pub mode: ReachMode, + /// 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. @@ -108,7 +143,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 +172,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 +197,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. @@ -279,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 abdc85c..655cfae 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -6,12 +6,13 @@ 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::{ - CommissioningNet, Observation, ProgressSink, ProgramRequest, RosterEntry, ThrottleServer, + CommissioningNet, Observation, ProgramRequest, ProgressSink, RosterEntry, ThrottleServer, Transport, WifiCredentials, }; use wp_link::{BoundedHttpClient, Radio, ScanResult}; @@ -109,13 +110,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 +137,116 @@ 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) + } + + /// 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 { + 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 +267,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 +301,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 +485,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 +531,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 +737,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 +749,322 @@ async fn run_job(rt: &Runtime, id: JobId) { error = %e, "job failed" ); - rt.jobs.transition( + 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 = 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; + } + } + }; + + rt.jobs + .transition(&id, JobState::Writing, Some("write"), Some(0), None); + + let mut sink = JobProgressSink { + jobs: &rt.jobs, + id: &id, + }; + let cancel = Arc::new(AtomicBool::new(false)); + + 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(); + let label = format!("espflash {port}"); + await_blocking_with_heartbeats( + rt, &id, - JobState::Failed, - None, + &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 + .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; + } + sink.step("write"); + sink.detail(&format!("{} bytes", image.len())); + firmware_http_with_heartbeats( + rt, + &id, + &mut sink, + driver, + image, + &host, + 80, None, - Some(&e.to_string()), - ); + Arc::clone(&cancel), + ) + .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); + 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))), + Arc::clone(&cancel), + ) + .await; + { + let mut radio = rt.radio.lock().await; + let _ = radio.release().await; + } + result + } + }; + + 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); + } + 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, + cancel: 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); + } + if let Some(flag) = cancel { + c = c.with_cancel(flag); } + 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..923ef99 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,33 @@ impl Client { } } + /// `updateFirmware`: queue a firmware-upload job. + pub fn update_firmware( + &self, + mode: wp_proto::ReachMode, + candidate: Option, + path: impl Into, + host: Option, + port: Option, + partition_table: Option, + ) -> Result { + let resp = self.round_trip(&Request { + kind: RequestKind::UpdateFirmware, + params: Some(Params::UpdateFirmware(wp_proto::UpdateFirmwareParams { + mode, + candidate, + path: path.into(), + host, + port, + partition_table, + })), + })?; + 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..dd368ac 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,38 @@ 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| { + if e.kind() == std::io::ErrorKind::Interrupted { + DriverError::Cancelled + } else { + 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/espflash.rs b/crates/wp-link/src/espflash.rs new file mode 100644 index 0000000..c74dff2 --- /dev/null +++ b/crates/wp-link/src/espflash.rs @@ -0,0 +1,408 @@ +//! 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::sync::atomic::{AtomicBool, Ordering}; +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. +/// +/// `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 / 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 + .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, cancel) +} + +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())); + }; + 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) 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}"))), + } + } +} + +#[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/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/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 4d00346..430d25a 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -3,11 +3,19 @@ #![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::{ 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..4de9313 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,53 @@ 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, + /// 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), LAN mDNS (`lan`), or USB serial (`usb`). + #[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), layout LAN (`lan`), or USB `espflash` (`usb`). + #[serde(default)] + pub mode: ReachMode, + /// 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 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. #[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..98f0fff 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 (`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?, 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 | @@ -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,57 @@ 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), `"lan"`, or `"usb"`. + +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. + +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. +`mode` is `"ap"`, `"lan"`, or `"usb"`. + +- **AP**: join the LongFred Soft-AP like `program`, then + `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 another job is active returns `busy` +(LAN and USB 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 @@ -100,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` @@ -156,9 +206,10 @@ over the same socket. Every client subcommand accepts `--json` | Subcommand | Purpose | |------------|---------| -| `scan` | Enumerate candidate devices on the radio | +| `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\|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 cb0e5cd..fdf52fd 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 (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 job Inspect or control a running job @@ -87,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 @@ -96,12 +101,18 @@ 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 + +# 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 @@ -116,6 +127,45 @@ nothing matches; pipe `--json` for scripting: wireless-programmer scan --json | jq '.[] | select(.rssi != null) | .key' ``` +## Firmware update + +`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). 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. +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 + +# 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` +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 `program` starts a job, then opens a `job.watch` stream and prints progress @@ -239,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/drivers/longfred.md b/docs/drivers/longfred.md index b6181ff..9c5256d 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,25 @@ 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`). +- 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. USB `espflash` +has a 180 s deadline. + ## 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..f3937e8 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`, `lan`, or `usb`) | | `Probe(candidate)` | `probe` | `*DeviceInfoWire` | | `Program(candidate, req)` | `program` | `*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` | @@ -129,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) diff --git a/go/client/client.go b/go/client/client.go index e454c80..dcfe146 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. @@ -192,10 +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"` + 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. @@ -252,10 +258,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), "lan" (mDNS), or "usb". +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 +286,34 @@ func (c *Client) Scan() ([]CandidateWire, error) { return out, nil } +// 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 + } + 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