Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 82 additions & 3 deletions crates/wireless-programmer/src/cli/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -19,6 +19,7 @@ pub fn run(command: Command, socket_override: Option<PathBuf>) -> 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),
Expand Down Expand Up @@ -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<humantime::Duration>,
mode: wp_client::ReachMode,
) -> Option<humantime::Duration> {
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))?;
Expand Down
52 changes: 48 additions & 4 deletions crates/wireless-programmer/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<humantime::Duration>,
}

/// 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<String>,
/// LAN IPv4 (skips mDNS). Implies `--mode lan` when set alone with `--file`.
#[arg(long)]
pub host: Option<String>,
/// USB serial device (e.g. `/dev/ttyACM0`). Implies `--mode usb`.
#[arg(long)]
pub port: Option<String>,
/// CSV partition table for ELF USB flashes (default: `partitions.csv` next to `--file`).
#[arg(long)]
pub partition_table: Option<PathBuf>,
/// 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 {
Expand Down
30 changes: 29 additions & 1 deletion crates/wireless-programmer/src/drivers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Outcome, DriverError> {
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
Expand Down
Loading
Loading