diff --git a/Cargo.lock b/Cargo.lock index dcf9d35..b32d7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "wp-client", "wp-core", "wp-drivers", + "wp-fake", "wp-link", "wp-proto", ] @@ -1105,6 +1106,21 @@ dependencies = [ "wp-link", ] +[[package]] +name = "wp-fake" +version = "0.1.0" +dependencies = [ + "log", + "parking_lot", + "quick-xml", + "serde", + "serde_json", + "tokio", + "wp-core", + "wp-drivers", + "wp-link", +] + [[package]] name = "wp-link" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 05c3c2f..5280ae3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/wp-link", "crates/wp-drivers", "crates/wp-client", + "crates/wp-fake", "crates/wireless-programmer", ] diff --git a/Makefile b/Makefile index 08e296d..be01feb 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,23 @@ CARGO ?= cargo RUSTUP_TOOLCHAIN ?= stable export RUSTUP_TOOLCHAIN -.PHONY: all build release release-musl check test test-release-assertions clean fmt clippy +# Optional wireless iface for `make dev` (e.g. INTERFACE=wlan0). +INTERFACE ?= + +.PHONY: all build release release-musl check test test-release-assertions clean fmt clippy dev all: build build: $(CARGO) build --workspace +# Build and run the daemon in the foreground (local development). +# Override iface: make dev INTERFACE=wlp2s0 +# Override data root / socket: DATA_DIR=/tmp/wp-dev make dev +dev: + $(CARGO) run -p wireless-programmer -- daemon --verbose \ + $(if $(INTERFACE),--interface $(INTERFACE),) + release: $(CARGO) build --workspace --release diff --git a/README.md b/README.md index 1919222..814a2c9 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,29 @@ crates/ wp-proto/ socket wire types + 4-byte-LE length+JSON framing wp-core/ DeviceDriver trait, capabilities, typed errors wp-link/ radio (nl80211/rtnetlink) + bounded HTTP client - wp-drivers/ wifred/ — NewHeiko WiFred driver + wp-drivers/ wifred/, longfred/ — Soft-AP programming drivers + wp-fake/ FakeRadio + Soft-AP HTTP mocks (dev / tests) wp-client/ Rust client SDK (mirrors go/client) wireless-programmer/ bin: socket server, job registry, dispatch + CLI go/client/ Go client (vendored by bigfred) -docs/ api.md, cli.md, go-client.md, drivers/wifred.md +docs/ api.md, cli.md, go-client.md, drivers/ ``` +## Fake mode (no WiFi hardware) + +```bash +# Full daemon with fake radio + Soft-AP HTTP mock (one candidate per driver) +wireless-programmer daemon --interface fake --verbose +# Optional: --fake-webserver-port 8070 (default) or 0 for ephemeral + +# Standalone Soft-AP HTTP mock only (no IPC / radio) +wireless-programmer fake --driver wifred --bind 127.0.0.1:8070 +wireless-programmer fake --driver longfred +``` + +With `--interface fake`, scan always returns one WiFred and one LongFred +candidate; programming talks to an in-process HTTP mock on `127.0.0.1`. + ## Memory profile Every crate is **allocation-conscious** (an administrative service, not a hot @@ -46,10 +62,14 @@ max 64 scan results, max 8 socket connections, max 1 MiB socket frame, max ```bash make build # debug +make dev # build + run daemon in foreground (`--verbose`) make release # release (opt-level z, LTO, strip) make release-musl TARGET_MUSL=aarch64-unknown-linux-musl # static arm64 → dist/ ``` +`make dev` accepts `INTERFACE=wlan0` and the usual env vars (`DATA_DIR`, +`WIRELESS_PROGRAMMER_ALLOW_USERS`, …). + Or the usual Cargo checks: ```bash @@ -67,10 +87,9 @@ workflow (`dcc-bigfred/common` `rust-musl-ci`); tagged releases inject ## Socket API Length-prefixed JSON on `$BIGFRED_DATA_DIR/run/wireless-programmer/wireless-programmer.sock` -(`DATA_DIR`, fallback `/data`), mode `0660`, peers verified with -`SO_PEERCRED`. The socket is chowned to the primary group of the first -allowlist entry, without which `0660` would refuse every non-root peer before -its credentials could be checked. See `docs/api.md`. +(`DATA_DIR`, fallback `/data`). Peer auth is **off by default** (socket +`0666`); enable with `--require-auth` / `WIRELESS_PROGRAMMER_REQUIRE_AUTH` +for `0660` + `SO_PEERCRED` allowlist. See `docs/api.md`. ## CLI diff --git a/crates/wireless-programmer/Cargo.toml b/crates/wireless-programmer/Cargo.toml index 3598f80..9ab66d9 100644 --- a/crates/wireless-programmer/Cargo.toml +++ b/crates/wireless-programmer/Cargo.toml @@ -11,12 +11,17 @@ description = "Daemon that discovers and programs physical throttle hardware for name = "wireless-programmer" path = "src/main.rs" +[lib] +name = "wireless_programmer" +path = "src/lib.rs" + [dependencies] wp-proto = { path = "../wp-proto" } wp-core = { path = "../wp-core" } wp-link = { path = "../wp-link" } wp-drivers = { path = "../wp-drivers" } wp-client = { path = "../wp-client" } +wp-fake = { path = "../wp-fake" } serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 40a9480..4209b4a 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,11 +19,13 @@ 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), Command::Job(a) => job(&socket, a), Command::Daemon(_) => unreachable!("daemon is not a client command"), + Command::Fake(_) => unreachable!("fake is not a client command"), }; match result { Ok(()) => ExitCode::SUCCESS, @@ -97,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))?; @@ -166,6 +246,14 @@ fn hello(socket: &Path, args: CommonArgs) -> HandlerResult { println!("drivers:"); for d in &h.drivers { println!(" {} — {}", d.id, d.name); + println!( + " firmware update: {}", + if d.capabilities.supports_firmware_update { + "yes" + } else { + "no" + } + ); } Ok(()) } diff --git a/crates/wireless-programmer/src/cli/daemon.rs b/crates/wireless-programmer/src/cli/daemon.rs index e1ef332..7674f6e 100644 --- a/crates/wireless-programmer/src/cli/daemon.rs +++ b/crates/wireless-programmer/src/cli/daemon.rs @@ -1,14 +1,19 @@ //! Daemon subcommand runner (the previous `main` behaviour). +use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::Arc; use clap::Args; use tracing_subscriber::EnvFilter; +use wp_link::{Nl80211Radio, Radio}; use crate::config::Config; use crate::drivers::DriverRegistry; use crate::ipc::Server; +use crate::jobs::JobRegistry; +use crate::runtime::Runtime; /// `daemon` arguments. #[derive(Debug, Clone, Default, Args)] @@ -21,8 +26,28 @@ pub struct DaemonArgs { /// /// When omitted, the first wireless interface under `/sys/class/net` is /// selected. Overrides `WIRELESS_PROGRAMMER_INTERFACE` when set. + /// + /// The special value `fake` enables an in-process fake radio and Soft-AP + /// HTTP mock (one candidate per driver) without real WiFi hardware. #[arg(short = 'i', long = "interface", value_name = "IFACE")] pub interface: Option, + + /// Require SO_PEERCRED peer authentication against the allowlist. + /// + /// Off by default. Also enabled by `WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`. + #[arg(long = "require-auth")] + pub require_auth: bool, + + /// Comma-separated login names allowed when `--require-auth` is set. + /// Defaults to `bigfred,bigfred-wizard`. Overrides + /// `WIRELESS_PROGRAMMER_ALLOW_USERS`. + #[arg(long = "allow-users", value_name = "USERS")] + pub allow_users: Option, + + /// Listen port for the in-process fake Soft-AP HTTP server when + /// `--interface fake`. Default 8070. Use `0` for an ephemeral port. + #[arg(long = "fake-webserver-port", value_name = "PORT")] + pub fake_webserver_port: Option, } /// Run the IPC daemon until shutdown. @@ -38,7 +63,6 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod if let Some(s) = socket_override { cfg.socket = s; } - // CLI wins over the environment default baked into Config::default. if let Some(iface) = args.interface { let iface = iface.trim().to_string(); if iface.is_empty() { @@ -47,28 +71,126 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod } cfg.interface = Some(iface); } + if let Some(port) = args.fake_webserver_port { + cfg.fake_webserver_port = Some(port); + } + if args.require_auth { + cfg.require_auth = true; + } + if let Some(list) = args.allow_users { + cfg.allow_users = list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Into::into) + .collect(); + if !cfg.allow_users.is_empty() { + cfg.require_auth = true; + } + } - // Validate the preferred interface early so a typo fails at start-up - // rather than on the first scan/program request. - if let Some(ref name) = cfg.interface { - match wp_link::resolve_wireless_interface(Some(name)) { - Ok(resolved) => cfg.interface = Some(resolved), - Err(e) => { - tracing::error!("wireless interface: {e}"); - return ExitCode::FAILURE; + let fake = cfg.is_fake_radio(); + if fake && cfg.require_auth { + tracing::warn!("fake radio mode: forcing peer auth off"); + cfg.require_auth = false; + } + cfg.finalize_auth(); + + if !fake { + if let Some(ref name) = cfg.interface { + match wp_link::resolve_wireless_interface(Some(name)) { + Ok(resolved) => cfg.interface = Some(resolved), + Err(e) => { + tracing::error!("wireless interface: {e}"); + return ExitCode::FAILURE; + } } } } match &cfg.interface { + Some(name) if name == "fake" => { + tracing::info!("wireless interface: fake (in-process mock)") + } Some(name) => tracing::info!("wireless interface: {name}"), None => tracing::info!("wireless interface: auto (first wireless)"), } + if cfg.require_auth { + tracing::info!( + "peer auth: enabled (allow_users={:?}, mode={:o})", + cfg.allow_users, + cfg.socket_mode + ); + } else { + tracing::info!( + "peer auth: disabled (socket mode {:o}; any local peer may connect)", + cfg.socket_mode + ); + } + + // For fake mode, bind the HTTP mock first so we know the port (supports 0). + let fake_listener = if fake { + let want = cfg.fake_webserver_port.unwrap_or(8070); + let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, want)); + match std::net::TcpListener::bind(bind) { + Ok(l) => { + if let Err(e) = l.set_nonblocking(true) { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + match l.local_addr() { + Ok(addr) => { + cfg.commissioning_net_override = + Some(Config::localhost_commissioning(addr.port())); + tracing::info!("fake Soft-AP HTTP mock will listen on {addr}"); + Some(l) + } + Err(e) => { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + } + } + Err(e) => { + tracing::error!("fake webserver bind: {e}"); + return ExitCode::FAILURE; + } + } + } else { + None + }; let registry = DriverRegistry::new(); - let runtime = Server::new(cfg, registry); + let jobs = JobRegistry::new(); - match runtime.run() { + let radio: Box = if fake { + Box::new(wp_fake::FakeRadio::one_per_driver()) + } else { + match Nl80211Radio::with_interface_opt(cfg.interface.as_deref()) { + Ok(r) => Box::new(r), + Err(e) => { + tracing::error!("radio open: {e}"); + return ExitCode::FAILURE; + } + } + }; + + let runtime = match Runtime::new(cfg, registry, jobs, radio) { + Ok(r) => r, + Err(e) => { + tracing::error!("runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + if let Some(std_listener) = fake_listener { + if let Err(e) = spawn_fake_from_std_listener(&runtime, std_listener) { + tracing::error!("fake webserver: {e}"); + return ExitCode::FAILURE; + } + } + + match Server::new(runtime).run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { tracing::error!("fatal: {e}"); @@ -76,3 +198,22 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCod } } } + +fn spawn_fake_from_std_listener( + runtime: &Arc, + std_listener: std::net::TcpListener, +) -> Result<(), String> { + let device: Arc> = + Arc::new(tokio::sync::Mutex::new(wp_fake::CompositeFakeDevice::all())); + // `TcpListener::from_std` needs a Tokio reactor — enter via the daemon runtime. + runtime.handle().block_on(async move { + let listener = + tokio::net::TcpListener::from_std(std_listener).map_err(|e| e.to_string())?; + tokio::spawn(async move { + if let Err(e) = wp_fake::FakeHttpServer::serve(listener, device).await { + tracing::error!("fake Soft-AP HTTP mock stopped: {e}"); + } + }); + Ok::<(), String>(()) + }) +} diff --git a/crates/wireless-programmer/src/cli/fake.rs b/crates/wireless-programmer/src/cli/fake.rs new file mode 100644 index 0000000..2696617 --- /dev/null +++ b/crates/wireless-programmer/src/cli/fake.rs @@ -0,0 +1,70 @@ +//! Standalone Soft-AP HTTP mock (`wireless-programmer fake`). + +use std::net::SocketAddr; +use std::process::ExitCode; +use std::sync::Arc; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +/// `fake` arguments — runs only the Soft-AP HTTP mock (no daemon / radio / IPC). +#[derive(Debug, Parser)] +pub struct FakeArgs { + /// Driver to emulate (`wifred` | `longfred`). + #[arg(long)] + pub driver: String, + + /// Bind address for the mock HTTP server. + #[arg(long, default_value = "127.0.0.1:8070")] + pub bind: SocketAddr, + + /// Verbose logging. + #[arg(short, long)] + pub verbose: bool, +} + +/// Run a standalone fake Soft-AP HTTP server until Ctrl-C. +pub fn run_fake(args: FakeArgs) -> ExitCode { + let filter = if args.verbose { + EnvFilter::new("debug") + } else { + EnvFilter::new("info") + }; + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let device: Arc> = match args.driver.as_str() { + "wifred" => Arc::new(tokio::sync::Mutex::new(wp_fake::WifredFake::new())), + "longfred" => Arc::new(tokio::sync::Mutex::new(wp_fake::LongFredFake::new())), + other => { + tracing::error!("unknown driver {other:?}; expected wifred or longfred"); + return ExitCode::FAILURE; + } + }; + + let rt = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + tracing::error!("tokio runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + match rt.block_on(async { + let local = wp_fake::bind_and_serve(args.bind, device).await?; + tracing::info!( + "fake Soft-AP for driver={} listening on {local} (Ctrl-C to stop)", + args.driver + ); + tokio::signal::ctrl_c().await?; + Ok::<(), std::io::Error>(()) + }) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + tracing::error!("fake server: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index 871da62..205e46a 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -2,6 +2,7 @@ mod client; mod daemon; +mod fake; mod program; use std::path::{Path, PathBuf}; @@ -10,6 +11,7 @@ use clap::{Parser, Subcommand}; use wp_client::ClientError; pub use daemon::{run_daemon, DaemonArgs}; +pub use fake::{run_fake, FakeArgs}; /// Command-line interface. #[derive(Debug, Parser)] @@ -35,6 +37,15 @@ pub struct Cli { /// `daemon --interface`. Overrides `WIRELESS_PROGRAMMER_INTERFACE`. #[arg(short = 'i', long = "interface", value_name = "IFACE")] pub interface: Option, + + /// Require SO_PEERCRED peer authentication (daemon only). Also accepted + /// on `daemon --require-auth`. + #[arg(long = "require-auth")] + pub require_auth: bool, + + /// Comma-separated allowlist when peer auth is on (daemon only). + #[arg(long = "allow-users", value_name = "USERS")] + pub allow_users: Option, } /// Top-level subcommands. @@ -42,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. @@ -56,6 +69,8 @@ pub enum Command { Hello(CommonArgs), /// Inspect or control a running job. Job(JobArgs), + /// Run a standalone Soft-AP HTTP mock for one driver (no daemon). + Fake(FakeArgs), } /// Shared client-side flags. @@ -64,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/config.rs b/crates/wireless-programmer/src/config.rs index fde2225..0a32a74 100644 --- a/crates/wireless-programmer/src/config.rs +++ b/crates/wireless-programmer/src/config.rs @@ -1,8 +1,10 @@ //! Daemon configuration. -use std::net::SocketAddr; +use std::net::Ipv4Addr; use std::path::PathBuf; +use wp_core::CommissioningNet; + /// Daemon configuration, resolved from CLI + environment. #[derive(Debug, Clone)] pub struct Config { @@ -10,12 +12,17 @@ pub struct Config { pub socket: PathBuf, /// Socket mode (permissions). pub socket_mode: u32, + /// When `true`, enforce [`Self::allow_users`] via `SO_PEERCRED`. + /// Off by default (open to any local peer that can open the socket). + pub require_auth: bool, /// Users allowed to connect (login names), matched via SO_PEERCRED. + /// Used only when [`Self::require_auth`] is `true`. pub allow_users: Vec, /// Login name whose primary group owns the socket. `None` means "use the - /// first entry of `allow_users`", matching microinit's `socketAllowUsers` - /// model. Without a group owner a `0660` socket is unreachable for every - /// allowlisted peer, since DAC rejects `connect(2)` before `SO_PEERCRED`. + /// first entry of `allow_users`" when auth is on, matching microinit's + /// `socketAllowUsers` model. Without a group owner a `0660` socket is + /// unreachable for every allowlisted peer, since DAC rejects `connect(2)` + /// before `SO_PEERCRED`. pub socket_group_user: Option, /// Data directory (BIGFRED_DATA_DIR / DATA_DIR / /data). pub data_dir: PathBuf, @@ -23,23 +30,33 @@ pub struct Config { pub version: String, /// Git commit, when built with WIRELESS_PROGRAMMER_GIT_COMMIT. pub commit: Option, - /// Source address bound on the wireless interface during programming. - pub source_addr: SocketAddr, /// Wireless interface to use (`wlan0`, `wlp2s0`, …). `None` means auto- - /// select the first wireless interface at radio open time. + /// select the first wireless interface at radio open time. The special + /// value `"fake"` enables in-process fake radio + HTTP device mock. pub interface: Option, + /// When set, override driver Soft-AP addressing (fake mode points at + /// `127.0.0.1:port`). + pub commissioning_net_override: Option, + /// Listen port for the in-process fake HTTP server when + /// `interface == "fake"`. `None` defaults to 8070; `Some(0)` asks the OS + /// for an ephemeral port. + pub fake_webserver_port: Option, } impl Default for Config { fn default() -> Self { let data_dir = resolve_data_dir(); + let require_auth = resolve_require_auth(); + let allow_users = resolve_allow_users(require_auth); + let socket_mode = if require_auth { 0o660 } else { 0o666 }; Self { socket: data_dir .join("run") .join("wireless-programmer") .join("wireless-programmer.sock"), - socket_mode: 0o660, - allow_users: resolve_allow_users(), + socket_mode, + require_auth, + allow_users, socket_group_user: std::env::var("WIRELESS_PROGRAMMER_SOCKET_GROUP_USER") .ok() .map(|s| s.trim().to_string()) @@ -47,8 +64,9 @@ impl Default for Config { data_dir, version: resolve_version(), commit: resolve_commit(), - source_addr: "192.168.4.2:0".parse().expect("valid default source addr"), interface: resolve_interface_env(), + commissioning_net_override: None, + fake_webserver_port: resolve_fake_web_port_env(), } } } @@ -77,28 +95,88 @@ fn resolve_commit() -> Option { } impl Config { + /// Apply auth-related settings after CLI overrides. Keeps socket mode in + /// sync with [`Self::require_auth`] and fills the default allowlist when + /// auth is enabled without an explicit list. + pub fn finalize_auth(&mut self) { + if self.require_auth && self.allow_users.is_empty() { + self.allow_users = default_allow_users(); + } + if !self.require_auth { + // Open socket when peer auth is off — any local process may connect. + self.socket_mode = 0o666; + } else if self.socket_mode == 0o666 { + self.socket_mode = 0o660; + } + } + + /// Whether this config requests fake radio mode (`--interface fake`). + #[must_use] + pub fn is_fake_radio(&self) -> bool { + self.interface.as_deref() == Some("fake") + } + /// Login name whose primary group should own the socket: the explicit - /// override when set, otherwise the first allowlist entry. + /// override when set, otherwise the first allowlist entry (auth on only). #[must_use] pub fn socket_group_owner(&self) -> Option<&str> { - self.socket_group_user - .as_deref() - .or_else(|| self.allow_users.first().map(String::as_str)) + if let Some(ref u) = self.socket_group_user { + return Some(u.as_str()); + } + if self.require_auth { + self.allow_users.first().map(String::as_str) + } else { + None + } + } + + /// Build a local commissioning override pointing at `127.0.0.1:port`. + #[must_use] + pub fn localhost_commissioning(port: u16) -> CommissioningNet { + CommissioningNet { + host: Ipv4Addr::LOCALHOST, + port, + source: Ipv4Addr::LOCALHOST, + prefix: 8, + } + } +} + +/// `WIRELESS_PROGRAMMER_REQUIRE_AUTH` — truthy values enable peer auth. +/// Default: off. +fn resolve_require_auth() -> bool { + match std::env::var("WIRELESS_PROGRAMMER_REQUIRE_AUTH") { + Ok(v) => { + let v = v.trim().to_ascii_lowercase(); + matches!(v.as_str(), "1" | "true" | "yes" | "on") + } + Err(_) => false, } } -/// Resolve the peer allowlist. Defaults to `bigfred` and `bigfred-wizard`; -/// override with `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login -/// names, replaces the default). -fn resolve_allow_users() -> Vec { +fn default_allow_users() -> Vec { + vec!["bigfred".into(), "bigfred-wizard".into()] +} + +/// Resolve the peer allowlist. When auth is off, returns empty (unused). +/// When auth is on: `WIRELESS_PROGRAMMER_ALLOW_USERS` or the BigFred defaults. +fn resolve_allow_users(require_auth: bool) -> Vec { match std::env::var("WIRELESS_PROGRAMMER_ALLOW_USERS") { - Ok(v) => v - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(Into::into) - .collect::>(), - Err(_) => vec!["bigfred".into(), "bigfred-wizard".into()], + Ok(v) => { + let list = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(Into::into) + .collect::>(); + if require_auth && list.is_empty() { + default_allow_users() + } else { + list + } + } + Err(_) if require_auth => default_allow_users(), + Err(_) => Vec::new(), } } @@ -110,6 +188,12 @@ fn resolve_interface_env() -> Option { .filter(|s| !s.is_empty()) } +fn resolve_fake_web_port_env() -> Option { + std::env::var("WIRELESS_PROGRAMMER_FAKE_WEB_PORT") + .ok() + .and_then(|s| s.trim().parse().ok()) +} + /// Resolve the BigFred data directory. pub fn resolve_data_dir() -> PathBuf { if let Ok(d) = std::env::var("BIGFRED_DATA_DIR") { @@ -120,3 +204,64 @@ pub fn resolve_data_dir() -> PathBuf { } PathBuf::from("/data") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finalize_auth_fills_default_allowlist() { + let mut cfg = Config { + require_auth: true, + allow_users: Vec::new(), + socket_mode: 0o666, + ..Config::default() + }; + cfg.finalize_auth(); + assert_eq!(cfg.allow_users, default_allow_users()); + assert_eq!(cfg.socket_mode, 0o660); + } + + #[test] + fn finalize_auth_opens_socket_when_auth_off() { + let mut cfg = Config { + require_auth: false, + allow_users: default_allow_users(), + socket_mode: 0o660, + ..Config::default() + }; + cfg.finalize_auth(); + assert_eq!(cfg.socket_mode, 0o666); + } + + #[test] + fn socket_group_owner_none_when_auth_off() { + let cfg = Config { + require_auth: false, + allow_users: default_allow_users(), + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), None); + } + + #[test] + fn socket_group_owner_uses_allowlist_when_auth_on() { + let cfg = Config { + require_auth: true, + allow_users: vec!["bigfred".into()], + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), Some("bigfred")); + } + + #[test] + fn is_fake_radio_detects_interface() { + let cfg = Config { + interface: Some("fake".into()), + ..Config::default() + }; + assert!(cfg.is_fake_radio()); + } +} diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index e3fa30d..83bfc3e 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -3,7 +3,12 @@ //! The driver set is closed at compile time, so dispatch uses an enum //! (guidelines §8.2) rather than `Box`. -use wp_core::{DeviceCandidate, DeviceDriver, DriverCapabilities, Observation}; +use std::net::Ipv4Addr; + +use wp_core::{ + CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, Observation, + Outcome, ProgramRequest, ProgressSink, Transport, +}; use wp_drivers::{LongFredDriver, WiFredDriver}; /// All registered drivers. @@ -31,6 +36,28 @@ impl Driver { Driver::LongFred => "LongFred", } } + + /// Soft-AP addressing for commissioning. + pub fn commissioning_net(self) -> CommissioningNet { + match self { + Driver::WiFred => CommissioningNet { + host: Ipv4Addr::new(192, 168, 4, 1), + port: 80, + source: Ipv4Addr::new(192, 168, 4, 2), + prefix: 24, + }, + Driver::LongFred => wp_drivers::longfred::commissioning_net(), + } + } + + /// Parse a driver id string. + pub fn from_id(id: &str) -> Option { + match id { + "wifred" => Some(Driver::WiFred), + "longfred" => Some(Driver::LongFred), + _ => None, + } + } } /// A registry of all drivers, owning their instances. @@ -71,11 +98,7 @@ impl DriverRegistry { /// Find the driver owning a candidate. pub fn driver_for(&self, candidate: &wp_proto::CandidateRef) -> Option { - match candidate.driver.as_str() { - "wifred" => Some(Driver::WiFred), - "longfred" => Some(Driver::LongFred), - _ => None, - } + Driver::from_id(candidate.driver.as_str()) } /// Claim a raw observation against every driver. @@ -85,6 +108,100 @@ impl DriverRegistry { .or_else(|| self.wifred.identify(obs)) } + /// Validate a request against the driver's capabilities. + pub fn validate( + &self, + driver: Driver, + req: &ProgramRequest<'_>, + ) -> Result<(), wp_core::ValidationError> { + match driver { + Driver::WiFred => self.wifred.validate(req), + Driver::LongFred => self.longfred.validate(req), + } + } + + /// Probe a device over the supplied transport. + pub async fn probe( + &self, + driver: Driver, + transport: Transport<'_>, + ) -> Result { + match driver { + Driver::WiFred => self.wifred.probe(transport).await, + Driver::LongFred => self.longfred.probe(transport).await, + } + } + + /// Program a device over the supplied transport. + pub async fn program( + &self, + driver: Driver, + transport: Transport<'_>, + req: &ProgramRequest<'_>, + progress: &mut dyn ProgressSink, + ) -> Result { + match driver { + Driver::WiFred => self.wifred.program(transport, req, progress).await, + Driver::LongFred => self.longfred.program(transport, req, progress).await, + } + } + + /// 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 + } + } + } + + /// Blink a device LED (WiFred Soft-AP only). + pub async fn blink( + &self, + driver: Driver, + transport: Transport<'_>, + count: Option, + ) -> Result<(), DriverError> { + match driver { + Driver::WiFred => { + let client = match transport { + Transport::Http(c) => c, + Transport::Bytes(_) => { + return Err(DriverError::Other( + "wifred identify requires an HTTP transport".into(), + )); + } + }; + client + .request("GET", &wp_drivers::wifred::identify_request(count), None) + .map_err(|e| DriverError::Http(e.to_string()))?; + Ok(()) + } + Driver::LongFred => Err(DriverError::Other( + "LongFred has no LED identify in programming mode".into(), + )), + } + } + /// 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 e94da67..930f5c9 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -1,9 +1,10 @@ -//! Unix socket server: length-prefixed JSON, SO_PEERCRED, 0660. +//! Unix socket server: length-prefixed JSON, optional SO_PEERCRED, 0660/0666. //! //! Wire format matches `microinit` (see `microinit/src/ipc.rs`): a 4-byte LE //! length prefix followed by JSON, with each message `type`-tagged. -//! Permissions follow the microinit `socketAllowUsers` model: the socket is -//! `0660` and peer credentials are checked against an allowlist. +//! Peer authentication is **off by default**. When enabled (`--require-auth` +//! / `WIRELESS_PROGRAMMER_REQUIRE_AUTH`), the socket is `0660` and peers are +//! checked against an allowlist via `SO_PEERCRED`. use std::io; use std::os::unix::fs::PermissionsExt; @@ -18,24 +19,18 @@ use wp_proto::{ }; use crate::config::Config; -use crate::drivers::DriverRegistry; -use crate::jobs::{JobRegistry, JobState}; +use crate::jobs::JobState; +use crate::runtime::Runtime; /// The IPC server. pub struct Server { - cfg: Config, - registry: DriverRegistry, - jobs: JobRegistry, + runtime: Arc, } impl Server { - /// Construct the server. - pub fn new(cfg: Config, registry: DriverRegistry) -> Self { - Self { - cfg, - registry, - jobs: JobRegistry::new(), - } + /// Construct the server around a shared [`Runtime`]. + pub fn new(runtime: Arc) -> Self { + Self { runtime } } /// Bind and serve until shutdown. @@ -44,23 +39,21 @@ impl Server { /// /// Returns [`io::Error`] on bind/listen failure. pub fn run(self) -> io::Result<()> { - let socket = &self.cfg.socket; + let socket = self.runtime.config().socket.clone(); if let Some(parent) = socket.parent() { std::fs::create_dir_all(parent)?; } if socket.exists() { - std::fs::remove_file(socket)?; + std::fs::remove_file(&socket)?; } - let listener = UnixListener::bind(socket)?; - let perms = std::fs::Permissions::from_mode(self.cfg.socket_mode); - std::fs::set_permissions(socket, perms)?; - set_socket_group(socket, &self.cfg); + let listener = UnixListener::bind(&socket)?; + let perms = std::fs::Permissions::from_mode(self.runtime.config().socket_mode); + std::fs::set_permissions(&socket, perms)?; + set_socket_group(&socket, self.runtime.config()); tracing::info!("listening on {}", socket.display()); let inner = Arc::new(ServerInner { - cfg: self.cfg, - registry: self.registry, - jobs: self.jobs, + runtime: self.runtime, }); for stream in listener.incoming() { @@ -77,9 +70,7 @@ impl Server { } struct ServerInner { - cfg: Config, - registry: DriverRegistry, - jobs: JobRegistry, + runtime: Arc, } impl ServerInner { @@ -100,6 +91,13 @@ impl ServerInner { return Ok(()); } }; + // JobWatch streams many frames on one connection until terminal. + if req.kind == RequestKind::JobWatch { + if let Err(e) = self.stream_job_watch(&mut stream, req) { + tracing::warn!("job.watch stream error: {e}"); + } + return Ok(()); + } let resp = self.dispatch(req); if let Err(e) = write_frame(&mut stream, &resp) { tracing::warn!("frame write error: {e}"); @@ -109,9 +107,15 @@ impl ServerInner { } fn peer_allowed(&self, stream: &UnixStream) -> bool { - if self.cfg.allow_users.is_empty() { + let cfg = self.runtime.config(); + if !cfg.require_auth { return true; } + if cfg.allow_users.is_empty() { + // Auth on with an empty list should never happen after + // finalize_auth, but fail closed. + return false; + } let creds = match getsockopt(stream, PeerCredentials) { Ok(c) => c, Err(_) => return false, @@ -119,86 +123,282 @@ impl ServerInner { let uid = creds.uid(); let name = username_for_uid(uid); match name { - Some(n) => self.cfg.allow_users.iter().any(|u| u == &n), + Some(n) => cfg.allow_users.iter().any(|u| u == &n), None => false, } } + fn stream_job_watch(&self, stream: &mut UnixStream, req: Request) -> io::Result<()> { + let write = |stream: &mut UnixStream, resp: &Response| { + write_frame(stream, resp).map_err(|e| io::Error::other(e.to_string())) + }; + let job_id = match req.params { + Some(Params::Job(p)) => crate::jobs::JobId(p.job_id), + _ => { + write( + stream, + &err_response(RequestKind::JobWatch, "bad_params", "missing params"), + )?; + return Ok(()); + } + }; + if self.runtime.jobs().snapshot(&job_id).is_none() { + write( + stream, + &err_response(RequestKind::JobWatch, "not_found", "no such job"), + )?; + return Ok(()); + } + let mut since = 0usize; + let mut sent_snapshot = false; + loop { + let Some(frames) = self.runtime.jobs().frames_since(&job_id, since) else { + write( + stream, + &err_response(RequestKind::JobWatch, "not_found", "no such job"), + )?; + return Ok(()); + }; + let mut terminal = false; + for f in &frames { + let wire = job_frame_to_wire(f); + terminal = wire.state.is_terminal(); + write( + stream, + &Response { + kind: RequestKind::JobWatch, + result: Some(ResultBody::JobWatch(wire)), + error: None, + }, + )?; + } + since += frames.len(); + if terminal { + return Ok(()); + } + // Emit a snapshot once so the client sees Queued before any + // transition frames exist. Do not bump `since` — that would skip + // the first real frame (often the only Failed/Cancelled frame). + if since == 0 && !sent_snapshot { + if let Some(s) = self.runtime.jobs().snapshot(&job_id) { + let wire = snapshot_to_frame(s); + let terminal = wire.state.is_terminal(); + write( + stream, + &Response { + kind: RequestKind::JobWatch, + result: Some(ResultBody::JobWatch(wire)), + error: None, + }, + )?; + sent_snapshot = true; + if terminal { + return Ok(()); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } + fn dispatch(&self, req: Request) -> Response { match req.kind { RequestKind::Hello => Response { kind: RequestKind::Hello, result: Some(ResultBody::Hello(wp_proto::HelloResult { - version: self.cfg.version.clone(), - commit: self.cfg.commit.clone(), - drivers: self.registry.driver_infos(), + version: self.runtime.config().version.clone(), + commit: self.runtime.config().commit.clone(), + drivers: self.runtime.registry().driver_infos(), })), error: None, }, - RequestKind::Scan => Response { - kind: RequestKind::Scan, - result: Some(ResultBody::Scan(Vec::new())), - error: None, - }, - RequestKind::Probe => Response { - kind: RequestKind::Probe, - result: None, - error: Some(ErrorBody::new( - "not_implemented", - "probe requires a live radio (hardware)", - )), + RequestKind::Scan => { + let mode = match req.params { + Some(Params::Scan(ref p)) => p.mode, + _ => wp_proto::ReachMode::Ap, + }; + if mode == wp_proto::ReachMode::Ap && self.runtime.radio_held() { + return err_response( + RequestKind::Scan, + "busy", + "radio in use by a programming job", + ); + } + 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() + .map(|c| wp_proto::CandidateWire { + driver: c.driver.clone(), + key: c.key.clone(), + label: c.label.clone(), + rssi: c.rssi, + }) + .collect(); + if candidates.is_empty() { + tracing::info!("scan finished: no handsets found"); + } else { + let names: Vec<&str> = + candidates.iter().map(|c| c.label.as_str()).collect(); + tracing::info!( + count = candidates.len(), + ?names, + "scan finished: found handsets" + ); + for c in &candidates { + tracing::info!( + driver = %c.driver, + key = %c.key, + label = %c.label, + rssi = ?c.rssi, + "scan candidate" + ); + } + } + Response { + kind: RequestKind::Scan, + result: Some(ResultBody::Scan(candidates)), + error: None, + } + } + Err(e) => { + tracing::warn!(error = %e, "scan failed"); + err_response(RequestKind::Scan, "scan_failed", &e.to_string()) + } + } + } + RequestKind::Probe => match req.params { + Some(Params::Probe(p)) => { + if self.runtime.radio_held() { + err_response( + RequestKind::Probe, + "busy", + "radio in use by a programming job", + ) + } else { + 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()) + } + }, + 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)) => match self.registry.driver_for(&p.candidate) { - Some(_d) => match self.jobs.start(&p.candidate.driver, &p.candidate.key) { - Ok(id) => Response { - kind: RequestKind::Program, - result: Some(ResultBody::Program(wp_proto::ProgramResult { - job_id: id.0.clone(), - })), - error: None, - }, - Err(e) => err_response(RequestKind::Program, "busy", &e.to_string()), - }, - None => err_response( - RequestKind::Program, - "unknown_driver", - "no driver owns this candidate", - ), - }, - _ => err_response(RequestKind::Program, "bad_params", "missing params"), + Some(Params::Program(p)) => { + let roster_addrs: Vec = + p.request.roster.iter().filter_map(|e| e.address).collect(); + tracing::info!( + driver = %p.candidate.driver, + key = %p.candidate.key, + identity = %p.request.identity, + wifi_ssid = %p.request.wifi.ssid, + server = %format!("{}:{}", p.request.server.host, p.request.server.port), + automatic = ?p.request.server.automatic, + roster = ?roster_addrs, + bigfred_login = ?p.request.bigfred.as_ref().map(|b| b.login.as_str()), + roster_mode = ?p.request.roster_mode, + "program request received" + ); + match self.runtime.registry().driver_for(&p.candidate) { + Some(d) => { + match self.runtime.submit_program(d, &p.candidate.key, p.request) { + Ok(id) => { + tracing::info!( + job_id = %id.0, + driver = %p.candidate.driver, + key = %p.candidate.key, + "program job queued" + ); + Response { + kind: RequestKind::Program, + result: Some(ResultBody::Program( + wp_proto::ProgramResult { + job_id: id.0.clone(), + }, + )), + error: None, + } + } + Err(e) => { + tracing::warn!( + driver = %p.candidate.driver, + key = %p.candidate.key, + error = %e, + "program rejected" + ); + let code = match &e { + crate::jobs::JobError::Busy(_) => "busy", + crate::jobs::JobError::Validation(_) => "validation", + _ => "program_failed", + }; + err_response(RequestKind::Program, code, &e.to_string()) + } + } + } + None => { + tracing::warn!( + driver = %p.candidate.driver, + "program rejected: unknown driver" + ); + err_response( + RequestKind::Program, + "unknown_driver", + "no driver owns this candidate", + ) + } + } + } + _ => { + tracing::warn!("program rejected: missing params"); + err_response(RequestKind::Program, "bad_params", "missing params") + } }, RequestKind::JobGet => match req.params { - Some(Params::Job(p)) => match self.jobs.snapshot(&crate::jobs::JobId(p.job_id)) { - Some(s) => Response { - kind: RequestKind::JobGet, - result: Some(ResultBody::Job(snapshot_to_wire(s))), - error: None, - }, - None => err_response(RequestKind::JobGet, "not_found", "no such job"), - }, - _ => err_response(RequestKind::JobGet, "bad_params", "missing params"), - }, - RequestKind::JobWatch => match req.params { Some(Params::Job(p)) => { - // Streaming is handled by the caller draining frames; here - // we return the current snapshot as a single frame. - match self.jobs.snapshot(&crate::jobs::JobId(p.job_id)) { + match self.runtime.jobs().snapshot(&crate::jobs::JobId(p.job_id)) { Some(s) => Response { - kind: RequestKind::JobWatch, - result: Some(ResultBody::JobWatch(snapshot_to_frame(s))), + kind: RequestKind::JobGet, + result: Some(ResultBody::Job(snapshot_to_wire(s))), error: None, }, - None => err_response(RequestKind::JobWatch, "not_found", "no such job"), + None => err_response(RequestKind::JobGet, "not_found", "no such job"), } } - _ => err_response(RequestKind::JobWatch, "bad_params", "missing params"), + _ => err_response(RequestKind::JobGet, "bad_params", "missing params"), }, + RequestKind::JobWatch => { + // Handled in handle_conn via stream_job_watch. + err_response(RequestKind::JobWatch, "internal", "job.watch must stream") + } RequestKind::JobCancel => match req.params { Some(Params::Job(p)) => { let id = crate::jobs::JobId(p.job_id); - self.jobs.cancel(&id); - match self.jobs.snapshot(&id) { + self.runtime.jobs().cancel(&id); + match self.runtime.jobs().snapshot(&id) { Some(s) => Response { kind: RequestKind::JobCancel, result: Some(ResultBody::JobCancelled(snapshot_to_wire(s))), @@ -209,32 +409,164 @@ impl ServerInner { } _ => err_response(RequestKind::JobCancel, "bad_params", "missing params"), }, - RequestKind::Identify => Response { - kind: RequestKind::Identify, - result: Some(ResultBody::Identify), - error: None, + RequestKind::Identify => match req.params { + Some(Params::Identify(p)) => { + if self.runtime.radio_held() { + err_response( + RequestKind::Identify, + "busy", + "radio in use by a programming job", + ) + } else { + match self.runtime.registry().driver_for(&p.candidate) { + Some(d) => match self.runtime.identify(d, &p.candidate.key, p.count) { + Ok(()) => Response { + kind: RequestKind::Identify, + result: Some(ResultBody::Identify), + error: None, + }, + Err(e) => err_response( + RequestKind::Identify, + "driverError", + &e.to_string(), + ), + }, + None => err_response( + RequestKind::Identify, + "unknown_driver", + "no driver owns this candidate", + ), + } + } + } + _ => err_response(RequestKind::Identify, "bad_params", "missing params"), }, - RequestKind::LinkStatus => Response { - kind: RequestKind::LinkStatus, - result: Some(ResultBody::LinkStatus(wp_proto::LinkStatusWire { - busy: self.jobs_is_busy(), - interface: self - .cfg - .interface + RequestKind::LinkStatus => { + let cfg = self.runtime.config(); + let rfkill_blocked = wp_link::rfkill::aggregate_state() + .ok() + .flatten() + .map(|s| s.blocked()) + .unwrap_or(false); + Response { + kind: RequestKind::LinkStatus, + result: Some(ResultBody::LinkStatus(wp_proto::LinkStatusWire { + busy: self.runtime.jobs().is_busy(), + interface: cfg.interface.clone().or_else(|| { + if cfg.is_fake_radio() { + Some("fake".into()) + } else { + wp_link::first_wireless_interface().ok() + } + }), + rfkill_blocked, + })), + 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(|| wp_link::first_wireless_interface().ok()), - rfkill_blocked: false, - })), - error: None, + .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"), }, } } - - fn jobs_is_busy(&self) -> bool { - // The registry tracks one active job; busy when a non-terminal job - // exists. Approximated by checking whether any job is non-terminal. - false - } } /// Give the socket a group owner so allowlisted peers can actually open it. @@ -312,6 +644,34 @@ fn primary_gid_for_user(name: &str) -> Option { found } +fn device_info_from_probe( + driver: &str, + key: &str, + info: &serde_json::Value, +) -> wp_proto::DeviceInfoWire { + let identity = info + .get("throttleName") + .and_then(|v| v.as_str()) + .or_else(|| info.pointer("/wifi/hostname").and_then(|v| v.as_str())) + .map(str::to_string); + let firmware_revision = info + .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); + wp_proto::DeviceInfoWire { + driver: driver.into(), + key: key.into(), + firmware_revision, + identity, + battery_mv, + roster: Vec::new(), + } +} + fn snapshot_to_wire(s: crate::jobs::JobSnapshot) -> wp_proto::JobSnapshot { wp_proto::JobSnapshot { job_id: s.id.0.clone(), @@ -332,6 +692,16 @@ fn snapshot_to_frame(s: crate::jobs::JobSnapshot) -> wp_proto::JobFrame { } } +fn job_frame_to_wire(f: &crate::jobs::JobFrame) -> wp_proto::JobFrame { + wp_proto::JobFrame { + job_id: f.id.0.clone(), + state: state_to_wire(f.state), + step: f.step.clone(), + progress: f.progress, + detail: f.detail.clone(), + } +} + fn state_to_wire(s: JobState) -> wp_proto::JobStateWire { match s { JobState::Queued => wp_proto::JobStateWire::Queued, @@ -388,6 +758,7 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_defaults_to_first_allowlist_entry() { let cfg = Config { + require_auth: true, allow_users: vec!["bigfred".into(), "bigfred-wizard".into()], socket_group_user: None, ..Config::default() @@ -398,6 +769,7 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_override_wins() { let cfg = Config { + require_auth: true, allow_users: vec!["bigfred".into()], socket_group_user: Some("operators".into()), ..Config::default() @@ -408,10 +780,22 @@ bigfred:x:1000:1001:BigFred loco-server:/home/bigfred:/bin/false #[test] fn socket_group_owner_is_none_without_an_allowlist() { let cfg = Config { + require_auth: true, allow_users: Vec::new(), socket_group_user: None, ..Config::default() }; assert_eq!(cfg.socket_group_owner(), None); } + + #[test] + fn socket_group_owner_is_none_when_auth_disabled() { + let cfg = Config { + require_auth: false, + allow_users: vec!["bigfred".into()], + socket_group_user: None, + ..Config::default() + }; + assert_eq!(cfg.socket_group_owner(), None); + } } diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index 228b1f9..e5eca86 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -9,10 +9,25 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::DriverError; +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); + +/// LongFred OTA slot (`ota_0` / `ota_1`) — cap for images loaded into RAM. +pub const MAX_FIRMWARE_BYTES: u64 = 0x3C_0000; + +/// Keep at most this many terminal jobs in the registry. +const MAX_JOB_HISTORY: usize = 32; + +/// 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); @@ -100,6 +115,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. @@ -107,6 +149,7 @@ struct JobRecord { snapshot: JobSnapshot, frames: Vec, cancel: bool, + payload: Option, } /// A shared job registry. Only one job may be active at a time. @@ -132,6 +175,16 @@ impl JobRegistry { /// Try to start a job. Returns [`JobError::Busy`] when one is active. pub fn start(&self, driver: &str, key: &str) -> Result { + self.submit(driver, key, None) + } + + /// Start a job and store the payload for the worker. + pub fn submit( + &self, + driver: &str, + key: &str, + payload: Option, + ) -> Result { let mut inner = self.inner.lock(); if let Some(active) = inner.active.as_ref() { return Err(JobError::Busy(active.clone())); @@ -150,12 +203,35 @@ impl JobRegistry { }, frames: Vec::new(), cancel: false, + payload, }; inner.active = Some(id.clone()); inner.jobs.insert(id.clone(), rec); Ok(JobId(id)) } + /// 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.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. + pub fn is_busy(&self) -> bool { + self.inner.lock().active.is_some() + } + /// Push a state transition + frame for a job. pub fn transition( &self, @@ -180,16 +256,33 @@ impl JobRegistry { }); if state.is_terminal() { inner.active = None; + evict_old_jobs(&mut inner); } } } - /// Mark a job cancelled (caller request). + /// Mark a job cancelled. Does **not** free the radio slot or become + /// terminal until the worker observes the flag, aborts work, and calls + /// [`Self::transition`] to [`JobState::Cancelled`]. A second `program` + /// while the worker is still tearing down returns [`JobError::Busy`]. pub fn cancel(&self, id: &JobId) { let mut inner = self.inner.lock(); - if let Some(rec) = inner.jobs.get_mut(&id.0) { + let Some(rec) = inner.jobs.get_mut(&id.0) else { + return; + }; + if rec.snapshot.state.is_terminal() { rec.cancel = true; + return; } + rec.cancel = true; + rec.snapshot.detail = Some("cancel requested".into()); + rec.frames.push(JobFrame { + id: id.clone(), + state: rec.snapshot.state, + step: None, + progress: None, + detail: Some("cancel requested".into()), + }); } /// Whether cancellation was requested for a job. @@ -236,3 +329,61 @@ impl Default for JobRegistry { Self::new() } } + +fn evict_old_jobs(inner: &mut JobRegistryInner) { + let over = inner.jobs.len().saturating_sub(MAX_JOB_HISTORY); + if over == 0 { + return; + } + let mut terminal: Vec<(String, Instant)> = inner + .jobs + .iter() + .filter(|(id, rec)| { + rec.snapshot.state.is_terminal() && inner.active.as_deref() != Some(id.as_str()) + }) + .map(|(id, rec)| (id.clone(), rec.snapshot.created_at)) + .collect(); + terminal.sort_by_key(|(_, t)| *t); + for (id, _) in terminal.into_iter().take(over) { + inner.jobs.remove(&id); + } +} + +#[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); + } + + #[test] + fn cancel_keeps_slot_until_worker_transitions() { + let jobs = JobRegistry::new(); + let id = jobs.submit("longfred", "key", None).expect("submit"); + assert!(jobs.is_busy()); + jobs.cancel(&id); + assert!(jobs.is_cancelled(&id)); + assert!( + jobs.is_busy(), + "slot must stay held while worker tears down" + ); + let snap = jobs.snapshot(&id).expect("snap"); + assert!(!snap.state.is_terminal()); + jobs.transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + assert!(!jobs.is_busy()); + assert_eq!(jobs.snapshot(&id).unwrap().state, JobState::Cancelled); + } + + #[test] + fn second_submit_is_busy_after_cancel_before_terminal() { + let jobs = JobRegistry::new(); + let id = jobs.submit("wifred", "a", None).expect("first"); + jobs.cancel(&id); + let err = jobs.submit("wifred", "b", None).expect_err("busy"); + assert!(matches!(err, JobError::Busy(_))); + } +} diff --git a/crates/wireless-programmer/src/lib.rs b/crates/wireless-programmer/src/lib.rs new file mode 100644 index 0000000..4acf374 --- /dev/null +++ b/crates/wireless-programmer/src/lib.rs @@ -0,0 +1,12 @@ +//! Library surface for the `wireless-programmer` binary (and integration tests). + +#![forbid(unsafe_code)] +#![allow(dead_code)] + +pub mod cli; +pub mod config; +pub mod drivers; +pub mod ipc; +pub mod jobs; +pub mod runtime; +pub mod version; diff --git a/crates/wireless-programmer/src/main.rs b/crates/wireless-programmer/src/main.rs index 4809fc8..124f356 100644 --- a/crates/wireless-programmer/src/main.rs +++ b/crates/wireless-programmer/src/main.rs @@ -1,44 +1,44 @@ //! `wireless-programmer` — daemon and CLI client for BigFred device programming. -//! -//! The same binary acts both as the long-running daemon (`wireless-programmer -//! daemon`, the default when no subcommand is given) and as a one-shot client -//! of that daemon (`wireless-programmer scan`, `wireless-programmer program`, -//! ...). The client subcommands are thin wrappers over [`wp_client`]. #![forbid(unsafe_code)] -#![allow(dead_code)] - -mod cli; -mod config; -mod drivers; -mod ipc; -mod jobs; -mod version; use std::process::ExitCode; use clap::Parser; -use cli::{Cli, Command}; +use wireless_programmer::cli::{self, Cli, Command}; fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Some(Command::Daemon(mut args)) => { - // Top-level `--interface` / `--verbose` apply when the - // subcommand did not set them itself. if args.interface.is_none() { args.interface = cli.interface; } if !args.verbose { args.verbose = cli.verbose; } + if !args.require_auth { + args.require_auth = cli.require_auth; + } + if args.allow_users.is_none() { + args.allow_users = cli.allow_users; + } cli::run_daemon(args, cli.socket) } + Some(Command::Fake(mut args)) => { + if !args.verbose { + args.verbose = cli.verbose; + } + cli::run_fake(args) + } Some(command) => cli::run_client(command, cli.socket), None => cli::run_daemon( cli::DaemonArgs { verbose: cli.verbose, interface: cli.interface, + require_auth: cli.require_auth, + allow_users: cli.allow_users, + fake_webserver_port: None, }, cli.socket, ), diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs new file mode 100644 index 0000000..4e3a291 --- /dev/null +++ b/crates/wireless-programmer/src/runtime.rs @@ -0,0 +1,1219 @@ +//! Tokio runtime wrapping radio + programming worker. +//! +//! IPC stays sync (one `std::thread` per connection). Radio work and the +//! programming worker run on a multi-threaded tokio runtime. Sync handlers +//! bridge via [`RuntimeHandle::block_on`]. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use wp_core::{ + CommissioningNet, Observation, ProgramRequest, ProgressSink, RosterEntry, ThrottleServer, + Transport, WifiCredentials, +}; +use wp_link::{BoundedHttpClient, Radio, ScanResult}; +use wp_proto::{ProgramRequestWire, ReachMode}; + +use crate::config::Config; +use crate::drivers::{Driver, DriverRegistry}; +use crate::jobs::{JobId, JobRegistry, JobState}; + +/// Cached candidate from the last scan (SSID needed for Soft-AP connect). +#[derive(Debug, Clone)] +pub struct CachedCandidate { + /// Soft-AP SSID. + pub ssid: String, + /// Optional BSSID (colon hex). + pub bssid: Option, + /// Driver id. + pub driver: String, + /// Candidate key. + pub key: String, + /// Label (usually SSID). + pub label: String, + /// RSSI when known. + pub rssi: Option, + /// How this candidate was discovered. + pub mode: ReachMode, +} + +/// Shared handle used by IPC and the worker. +pub struct Runtime { + rt: tokio::runtime::Runtime, + radio: Arc>>, + cfg: Config, + registry: Arc, + jobs: JobRegistry, + tx: tokio::sync::mpsc::Sender, + /// Last scan results keyed by `(driver, key)`. + cache: Mutex>, + /// Soft-AP radio is associated for an in-flight AP job or probe. + radio_held: AtomicBool, +} + +impl Runtime { + /// Build the runtime, spawn the programming worker, and wrap `radio`. + pub fn new( + cfg: Config, + registry: DriverRegistry, + jobs: JobRegistry, + radio: Box, + ) -> Result, wp_core::DriverError> { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("wp-runtime") + .build() + .map_err(|e| wp_core::DriverError::Other(format!("tokio runtime: {e}")))?; + + let (tx, rx) = tokio::sync::mpsc::channel::(8); + let radio = Arc::new(tokio::sync::Mutex::new(radio)); + let registry = Arc::new(registry); + + let this = Arc::new(Self { + rt, + radio: Arc::clone(&radio), + cfg, + registry: Arc::clone(®istry), + jobs: jobs.clone(), + tx, + cache: Mutex::new(HashMap::new()), + radio_held: AtomicBool::new(false), + }); + + let worker = Arc::clone(&this); + this.rt.spawn(async move { + worker_loop(worker, rx).await; + }); + + Ok(this) + } + + /// Borrow the tokio runtime (e.g. to spawn the fake HTTP server). + pub fn handle(&self) -> tokio::runtime::Handle { + self.rt.handle().clone() + } + + /// Shared job registry. + pub fn jobs(&self) -> &JobRegistry { + &self.jobs + } + + /// Driver registry. + pub fn registry(&self) -> &DriverRegistry { + &self.registry + } + + /// Config snapshot. + pub fn config(&self) -> &Config { + &self.cfg + } + + /// Whether the wireless radio is held for Soft-AP work. + pub fn radio_held(&self) -> bool { + self.radio_held.load(Ordering::SeqCst) + } + + /// 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 mut out = Vec::new(); + let mut cache = self.cache.lock(); + cache.retain(|_, v| v.mode != ReachMode::Ap); + for s in results { + let obs = observation_from_scan(&s); + if let Some(c) = self.registry.identify(&obs) { + let ssid = c.label.clone(); + let cached = CachedCandidate { + ssid, + bssid: s.bssid, + driver: c.driver.clone(), + key: c.key.clone(), + label: c.label, + rssi: c.rssi, + mode: ReachMode::Ap, + }; + cache.insert((c.driver, c.key), cached.clone()); + out.push(cached); + } + } + 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, + mode: ReachMode::Lan, + }; + 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, + mode: ReachMode::Usb, + }; + 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, + mode: ReachMode::Usb, + }; + 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, + mode: ReachMode::Lan, + }; + 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 + .lock() + .get(&(driver.to_string(), key.to_string())) + .cloned() + } + + /// Queue a programming job for the worker. + pub fn submit_program( + &self, + driver: Driver, + key: &str, + request: ProgramRequestWire, + ) -> Result { + // Validate before occupying the radio slot. + let owned = OwnedRequest::from_wire(request.clone()); + let borrowed = owned.borrow(); + self.registry.validate(driver, &borrowed)?; + + 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(), + key, + "program 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 job to worker"); + 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) + } + + /// Connect to a candidate Soft-AP and probe. + pub fn probe( + &self, + driver: Driver, + 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()) + })?; + let net = self.effective_net(driver); + let radio = Arc::clone(&self.radio); + let registry = Arc::clone(&self.registry); + tracing::info!( + driver = driver.id_str(), + key, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + "probe: connecting to Soft-AP" + ); + let _hold = RadioHold::new(self); + self.rt.handle().block_on(async move { + let mut r = radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + if let Err(e) = r.connect_open(&candidate.ssid, bssid).await { + tracing::warn!( + ssid = %candidate.ssid, + error = %e, + "probe: Soft-AP connect failed" + ); + let _ = r.release().await; + return Err(e); + } + tracing::info!(ssid = %candidate.ssid, "probe: Soft-AP connect ok"); + r.set_address(net.source, net.prefix).await?; + r.link_up().await?; + + let result = { + let mut client = make_http_client(&net); + let transport = Transport::Http(&mut client); + registry.probe(driver, transport).await + }; + + match &result { + Ok(_) => tracing::info!(ssid = %candidate.ssid, "probe: done"), + Err(e) => tracing::warn!(ssid = %candidate.ssid, error = %e, "probe: failed"), + } + + let _ = r.release().await; + result + }) + } + + /// Blink a WiFred LED over the Soft-AP (`GET /flashred.html`). + pub fn identify( + &self, + driver: Driver, + key: &str, + count: Option, + ) -> Result<(), wp_core::DriverError> { + let candidate = self.cached(driver.id_str(), key).ok_or_else(|| { + 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); + let registry = Arc::clone(&self.registry); + let _hold = RadioHold::new(self); + self.rt.handle().block_on(async move { + let mut r = radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + r.connect_open(&candidate.ssid, bssid).await?; + r.set_address(net.source, net.prefix).await?; + r.link_up().await?; + let result = { + let mut client = make_http_client(&net); + let transport = Transport::Http(&mut client); + registry.blink(driver, transport, count).await + }; + let _ = r.release().await; + result + }) + } + + fn effective_net(&self, driver: Driver) -> CommissioningNet { + self.cfg + .commissioning_net_override + .unwrap_or_else(|| driver.commissioning_net()) + } +} + +fn observation_from_scan(s: &ScanResult) -> Observation { + Observation { + ssid: s.ssid.clone(), + bssid: s.bssid.clone(), + rssi: s.rssi, + extra: serde_json::Value::Null, + } +} + +fn parse_bssid(s: Option<&str>) -> Option<[u8; 6]> { + let s = s?; + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + let mut out = [0u8; 6]; + for (i, p) in parts.iter().enumerate() { + out[i] = u8::from_str_radix(p, 16).ok()?; + } + Some(out) +} + +fn make_http_client(net: &CommissioningNet) -> BoundedHttpClient { + let source = SocketAddr::from((net.source, 0)); + BoundedHttpClient::new(net.host.to_string(), net.port).with_source(source) +} + +fn make_http_client_cancel(net: &CommissioningNet, cancel: Arc) -> BoundedHttpClient { + make_http_client(net).with_cancel(cancel) +} + +struct RadioHold<'a> { + rt: &'a Runtime, +} + +impl<'a> RadioHold<'a> { + fn new(rt: &'a Runtime) -> Self { + rt.radio_held.store(true, Ordering::SeqCst); + Self { rt } + } +} + +impl Drop for RadioHold<'_> { + fn drop(&mut self) { + self.rt.radio_held.store(false, Ordering::SeqCst); + } +} + +fn spawn_cancel_watch(rt: &Runtime, id: &JobId) -> Arc { + let flag = Arc::new(AtomicBool::new(rt.jobs.is_cancelled(id))); + let jobs = rt.jobs.clone(); + let id = id.clone(); + let f = Arc::clone(&flag); + rt.handle().spawn(async move { + loop { + if jobs.is_cancelled(&id) { + f.store(true, Ordering::Relaxed); + break; + } + if jobs + .snapshot(&id) + .map(|s| s.state.is_terminal()) + .unwrap_or(true) + { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }); + flag +} + +/// Owned copy of a wire request so we can borrow into [`ProgramRequest`]. +struct OwnedRequest { + identity: String, + wifi_ssid: String, + wifi_psk: Option, + server_host: String, + server_port: u16, + server_automatic: bool, + roster: Vec, + bigfred_login: Option, + bigfred_pin: Option, + roster_mode: Option, +} + +struct OwnedRoster { + address: Option, + long_address: Option, + mode: Option, + direction: Option, + functions: Vec, +} + +impl OwnedRequest { + fn from_wire(w: ProgramRequestWire) -> Self { + Self { + identity: w.identity, + wifi_ssid: w.wifi.ssid, + wifi_psk: w.wifi.psk, + server_host: w.server.host, + server_port: w.server.port, + server_automatic: w.server.automatic.unwrap_or(false), + roster: w + .roster + .into_iter() + .map(|e| OwnedRoster { + address: e.address, + long_address: e.long_address, + mode: e.mode, + direction: e.direction, + functions: e + .functions + .into_iter() + .map(|f| wp_core::FunctionMapping { + index: f.index, + value: f.value, + }) + .collect(), + }) + .collect(), + bigfred_login: w.bigfred.as_ref().map(|b| b.login.clone()), + bigfred_pin: w.bigfred.as_ref().map(|b| b.pin.clone()), + roster_mode: w.roster_mode, + } + } + + fn borrow(&self) -> ProgramRequest<'_> { + let roster: Vec> = self + .roster + .iter() + .map(|e| RosterEntry { + address: e.address, + long_address: e.long_address, + mode: e.mode.as_deref(), + direction: e.direction, + functions: e.functions.clone(), + }) + .collect(); + let bigfred = match (&self.bigfred_login, &self.bigfred_pin) { + (Some(login), Some(pin)) => Some(wp_core::BigfredCreds { + login: login.as_str(), + pin: pin.as_str(), + }), + _ => None, + }; + ProgramRequest { + identity: &self.identity, + wifi: WifiCredentials { + ssid: &self.wifi_ssid, + psk: self.wifi_psk.as_deref(), + }, + server: ThrottleServer { + host: &self.server_host, + port: self.server_port, + automatic: self.server_automatic, + }, + roster, + bigfred, + roster_mode: self.roster_mode.as_deref(), + } + } +} + +struct JobProgressSink<'a> { + jobs: &'a JobRegistry, + id: &'a JobId, +} + +impl ProgressSink for JobProgressSink<'_> { + fn step(&mut self, step: &str) { + let state = match step { + "read" | "probe" => JobState::Probing, + "identity" | "locos" | "functions" | "server" | "wifi" | "write" => JobState::Writing, + "verify" => JobState::Verifying, + "restart" | "exit" => JobState::Restarting, + _ => JobState::Writing, + }; + tracing::info!(job_id = %self.id.0, step, ?state, "job step"); + self.jobs.transition(self.id, state, Some(step), None, None); + } + + fn progress(&mut self, progress: u8) { + let state = self + .jobs + .snapshot(self.id) + .map(|s| s.state) + .unwrap_or(JobState::Writing); + self.jobs + .transition(self.id, state, None, Some(progress), None); + } + + fn detail(&mut self, detail: &str) { + let state = self + .jobs + .snapshot(self.id) + .map(|s| s.state) + .unwrap_or(JobState::Writing); + self.jobs + .transition(self.id, state, None, None, Some(detail)); + } +} + +async fn worker_loop(rt: Arc, mut rx: tokio::sync::mpsc::Receiver) { + while let Some(id) = rx.recv().await { + run_job(&rt, id).await; + } + tracing::warn!("programming worker channel closed; exiting worker loop"); +} + +async fn run_job(rt: &Runtime, id: JobId) { + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled before start"); + if rt + .jobs + .snapshot(&id) + .map(|s| !s.state.is_terminal()) + .unwrap_or(false) + { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + } + return; + } + + 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 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 => { + tracing::error!(job_id = %id.0, "job disappeared before start"); + return; + } + }; + let Some(driver) = Driver::from_id(&snap.driver) else { + tracing::error!(job_id = %id.0, driver = %snap.driver, "unknown driver"); + rt.jobs + .transition(&id, JobState::Failed, None, None, Some("unknown driver")); + return; + }; + + let candidate = match rt.cached(&snap.driver, &snap.key) { + Some(c) => c, + None => { + tracing::error!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + "candidate not in scan cache; run scan first" + ); + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("candidate not in scan cache; run scan first"), + ); + return; + } + }; + + tracing::info!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + identity = %wire.identity, + wifi_ssid = %wire.wifi.ssid, + "job started" + ); + + let owned = OwnedRequest::from_wire(wire); + let net = rt.effective_net(driver); + + rt.jobs + .transition(&id, JobState::Joining, Some("join"), None, None); + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled before Soft-AP join"); + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + let _hold = RadioHold::new(rt); + let mut radio = rt.radio.lock().await; + let bssid = parse_bssid(candidate.bssid.as_deref()); + tracing::info!( + job_id = %id.0, + ssid = %candidate.ssid, + bssid = ?candidate.bssid, + "connecting to Soft-AP" + ); + if let Err(e) = radio.connect_open(&candidate.ssid, bssid).await { + tracing::warn!( + job_id = %id.0, + ssid = %candidate.ssid, + error = %e, + "Soft-AP connect failed" + ); + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + tracing::info!( + job_id = %id.0, + ssid = %candidate.ssid, + "Soft-AP connect ok" + ); + + tracing::info!( + job_id = %id.0, + source = %net.source, + prefix = net.prefix, + host = %net.host, + port = net.port, + "assigning on-link address" + ); + if let Err(e) = radio.set_address(net.source, net.prefix).await { + tracing::warn!( + job_id = %id.0, + source = %net.source, + error = %e, + "set_address failed" + ); + 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 { + tracing::warn!(job_id = %id.0, error = %e, "link_up failed"); + rt.jobs.transition( + &id, + JobState::Failed, + Some("join"), + None, + Some(&e.to_string()), + ); + let _ = radio.release().await; + return; + } + tracing::info!( + job_id = %id.0, + target = %format!("{}:{}", net.host, net.port), + "radio ready; starting driver program" + ); + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled after Soft-AP join"); + let _ = radio.release().await; + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + // Drop the radio lock while the sync HTTP client talks to the device — + // Soft-AP stays associated; we re-acquire only to release. + drop(radio); + + let borrowed = owned.borrow(); + let mut sink = JobProgressSink { + jobs: &rt.jobs, + id: &id, + }; + let cancel = spawn_cancel_watch(rt, &id); + let mut client = make_http_client_cancel(&net, cancel); + let transport = Transport::Http(&mut client); + let outcome = match tokio::time::timeout(crate::jobs::JOB_DEADLINE, async { + rt.registry + .program(driver, transport, &borrowed, &mut sink) + .await + }) + .await + { + Ok(inner) => inner, + Err(_) => Err(wp_core::DriverError::DeadlineElapsed("program")), + }; + + { + let mut radio = rt.radio.lock().await; + match radio.release().await { + Ok(()) => tracing::info!(job_id = %id.0, "radio released"), + Err(e) => tracing::warn!(job_id = %id.0, error = %e, "radio release failed"), + } + } + + if rt.jobs.is_cancelled(&id) { + tracing::info!(job_id = %id.0, "job cancelled after program"); + if rt + .jobs + .snapshot(&id) + .map(|s| !s.state.is_terminal()) + .unwrap_or(false) + { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + } + return; + } + + match outcome { + Ok(o) => { + tracing::info!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + restarted = o.restarted, + "job finished successfully" + ); + let detail = if o.restarted { Some("restarted") } else { None }; + rt.jobs + .transition(&id, JobState::Done, Some("done"), Some(100), detail); + } + Err(e) => { + tracing::warn!( + job_id = %id.0, + driver = %snap.driver, + key = %snap.key, + error = %e, + "job failed" + ); + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&e.to_string())); + } + } +} + +async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob) { + use std::net::Ipv4Addr; + + 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; + } + } + }; + + if job.mode != ReachMode::Usb { + if image.len() as u64 > crate::jobs::MAX_FIRMWARE_BYTES { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware image exceeds LongFred OTA slot (3.75 MiB)"), + ); + return; + } + let header_n = image.len().min(16); + match wp_link::classify_image(&job.path, &image[..header_n], image.len() as u64) { + Ok(wp_link::ImageKind::AppBin { .. }) => {} + Ok(_) => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("HTTP firmware needs a .app.bin ESP app image, not ELF or a merged dump"), + ); + return; + } + Err(e) => { + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&e)); + 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}")); + if let Ok(mut f) = std::fs::File::open(&job.path) { + let mut header = [0u8; 16]; + if let Ok(n) = std::io::Read::read(&mut f, &mut header) { + let len = f.metadata().map(|m| m.len()).unwrap_or(0); + if matches!( + wp_link::classify_image(&job.path, &header[..n], len), + Ok(wp_link::ImageKind::AppBin { .. }) + ) { + sink.detail( + "USB .app.bin writes ota_0 only; first dual-slot install needs ELF + --partition-table", + ); + } + } + } + let table = job.partition_table.clone(); + let image_path = job.path.clone(); + let label = format!("espflash {port}"); + await_blocking_with_heartbeats( + rt, + &id, + &mut sink, + &label, + Arc::clone(&cancel), + move |cancel| { + wp_link::flash_usb(&port, &image_path, table.as_deref(), Some(cancel.as_ref())) + .map(|()| wp_core::Outcome { + restarted: true, + mismatches: Vec::new(), + }) + }, + ) + .await + } + ReachMode::Lan => { + let host = job + .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, + 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 _hold = RadioHold::new(rt); + 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. +pub fn sleep_ms(ms: u64) { + std::thread::sleep(Duration::from_millis(ms)); +} diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs new file mode 100644 index 0000000..21b535d --- /dev/null +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -0,0 +1,191 @@ +//! End-to-end fake-mode tests: FakeRadio + Soft-AP HTTP mock + Runtime. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use wp_fake::{CompositeFakeDevice, FakeRadio}; +use wp_proto::{ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire}; + +use wireless_programmer::config::Config; +use wireless_programmer::drivers::{Driver, DriverRegistry}; +use wireless_programmer::jobs::{JobRegistry, JobState}; +use wireless_programmer::runtime::Runtime; + +fn temp_socket() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "wp-fake-test-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p +} + +fn setup_runtime() -> Arc { + let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, 0)); + let bootstrap = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let device = Arc::new(tokio::sync::Mutex::new(CompositeFakeDevice::all())); + let local = bootstrap.block_on(async { + let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); + let local = listener.local_addr().unwrap(); + let device = Arc::clone(&device); + tokio::spawn(async move { + let _ = wp_fake::FakeHttpServer::serve(listener, device).await; + }); + local + }); + // Keep the accept loop alive for the duration of the test process. + std::mem::forget(bootstrap); + + 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())); + + let radio = Box::new(FakeRadio::one_per_driver()); + Runtime::new(cfg, DriverRegistry::new(), JobRegistry::new(), radio).expect("runtime") +} + +fn wifred_request() -> ProgramRequestWire { + ProgramRequestWire { + identity: "122145".into(), + wifi: WifiCredentialsWire { + ssid: "club-wifi".into(), + psk: Some("secret".into()), + }, + server: ThrottleServerWire { + host: "bigfred.local".into(), + port: 12090, + automatic: Some(false), + }, + roster: vec![RosterEntryWire { + address: Some(3), + long_address: Some(false), + mode: Some("128".into()), + direction: Some(0), + functions: Vec::new(), + }], + bigfred: None, + roster_mode: None, + } +} + +fn longfred_request() -> ProgramRequestWire { + ProgramRequestWire { + identity: "pilot1".into(), + wifi: WifiCredentialsWire { + ssid: "club-wifi".into(), + psk: Some("secret".into()), + }, + server: ThrottleServerWire { + host: "unused.local".into(), + port: 12090, + automatic: Some(false), + }, + roster: vec![RosterEntryWire { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }], + bigfred: Some(wp_proto::BigfredCredsWire { + login: "ops".into(), + pin: "1234".into(), + }), + roster_mode: Some("static".into()), + } +} + +fn wait_terminal(rt: &Runtime, id: &wireless_programmer::jobs::JobId) -> JobState { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + if let Some(s) = rt.jobs().snapshot(id) { + if s.state.is_terminal() { + return s.state; + } + } + if std::time::Instant::now() > deadline { + panic!("job did not reach terminal state"); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn fake_scan_returns_one_candidate_per_driver() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + assert_eq!(found.len(), 2); + assert!(found.iter().any(|c| c.driver == "wifred")); + assert!(found.iter().any(|c| c.driver == "longfred")); +} + +#[test] +fn fake_program_wifred_reaches_done() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "wifred").expect("wifred"); + let id = rt + .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) + ); +} + +#[test] +fn fake_program_longfred_reaches_done() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found + .iter() + .find(|c| c.driver == "longfred") + .expect("longfred"); + let id = rt + .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) + ); +} + +#[test] +fn fake_probe_wifred() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "wifred").expect("wifred"); + let info = rt.probe(Driver::WiFred, &c.key).expect("probe"); + assert_eq!( + info.get("structureVersion").and_then(|v| v.as_str()), + Some("1") + ); +} + +#[test] +fn fake_identify_wifred() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "wifred").expect("wifred"); + rt.identify(Driver::WiFred, &c.key, Some(3)) + .expect("identify"); +} 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-client/tests/client_test.rs b/crates/wp-client/tests/client_test.rs index 629212e..38bb4b9 100644 --- a/crates/wp-client/tests/client_test.rs +++ b/crates/wp-client/tests/client_test.rs @@ -181,7 +181,7 @@ fn not_found_and_unknown_codes_map_distinctly() { fn a_mismatched_response_kind_is_rejected() { let daemon = FakeDaemon::spawn(reply_once(|_| Response { kind: RequestKind::Scan, - result: Some(ResultBody::Hello(hello_result())), + result: Some(ResultBody::Scan(Vec::new())), error: None, })); 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-core/src/driver.rs b/crates/wp-core/src/driver.rs index 777df76..6d87b7e 100644 --- a/crates/wp-core/src/driver.rs +++ b/crates/wp-core/src/driver.rs @@ -57,7 +57,7 @@ pub struct ScanFilters { } /// Sink for progress updates during a programming job. -pub trait ProgressSink { +pub trait ProgressSink: Send { /// Report a step transition. fn step(&mut self, step: &str); /// Report progress 0..=100, when meaningful. diff --git a/crates/wp-core/src/request.rs b/crates/wp-core/src/request.rs index a002a30..845e31a 100644 --- a/crates/wp-core/src/request.rs +++ b/crates/wp-core/src/request.rs @@ -11,9 +11,9 @@ pub struct WifiCredentials<'a> { /// wiThrottle server endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ThrottleServer { +pub struct ThrottleServer<'a> { /// Hostname or IP. - pub host: &'static str, + pub host: &'a str, /// TCP port. pub port: u16, /// Discover via mDNS instead of a fixed host. @@ -62,7 +62,7 @@ pub struct ProgramRequest<'a> { /// WiFi network the device should join after programming. pub wifi: WifiCredentials<'a>, /// wiThrottle server the device should connect to. - pub server: ThrottleServer, + pub server: ThrottleServer<'a>, /// DCC vehicle list (capped by the driver's `max_roster_slots`). pub roster: Vec>, /// Optional BigFred login+PIN (LongFred and similar). diff --git a/crates/wp-core/src/transport.rs b/crates/wp-core/src/transport.rs index 7ca8d25..ab4795c 100644 --- a/crates/wp-core/src/transport.rs +++ b/crates/wp-core/src/transport.rs @@ -9,7 +9,7 @@ use std::io; /// /// Implementations are expected to be bounded: a deadline, a maximum response /// body size, and a bounded retry count. -pub trait HttpClient { +pub trait HttpClient: Send { /// Issue an HTTP request to `path` (path begins with `/`) and return the body. /// /// `body` is an optional `(content_type, bytes)` pair for methods that @@ -39,7 +39,7 @@ pub trait HttpClient { } /// A bidirectional byte stream for serial devices. -pub trait ByteStream { +pub trait ByteStream: Send { /// Read up to `buf.len()` bytes into `buf`. /// /// # Errors 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 7f2c5bc..046d565 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -26,7 +26,7 @@ pub use constants::{ Direction, FunctionInfo, CONFIG_AP_PORT, CONFIG_HOST, CONFIG_SOURCE_ADDR, MAX_FUNCTION, MAX_ROSTER_SLOTS, STRUCTURE_VERSION, WIFI_CONFIG_SSID_PREFIX, }; -pub use xml::{DeviceConfig, LocoConfig}; +pub use xml::{parse, DeviceConfig, FunctionEntry, LocoConfig, LocoServerConfig, NetworkConfig}; /// The WiFred driver. #[derive(Debug, Default)] @@ -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/Cargo.toml b/crates/wp-fake/Cargo.toml new file mode 100644 index 0000000..0f5f5fe --- /dev/null +++ b/crates/wp-fake/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "wp-fake" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Fake radio and Soft-AP HTTP device mocks for wireless-programmer" + +[lib] +name = "wp_fake" +path = "src/lib.rs" + +[dependencies] +wp-core = { path = "../wp-core" } +wp-link = { path = "../wp-link" } +wp-drivers = { path = "../wp-drivers" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["net", "io-util", "sync", "macros", "rt", "time"] } +quick-xml = { version = "0.36", features = ["serialize"] } +log = "0.4" +parking_lot = "0.12" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/wp-fake/src/composite.rs b/crates/wp-fake/src/composite.rs new file mode 100644 index 0000000..a0118dc --- /dev/null +++ b/crates/wp-fake/src/composite.rs @@ -0,0 +1,47 @@ +//! Composite fake that multiplexes several [`FakeDevice`]s. + +use crate::device::{not_found, FakeDevice, FakeRequest, FakeResponse}; +use crate::longfred::LongFredFake; +use crate::wifred::WifredFake; + +/// Tries each inner device and returns the first non-404 response. +pub struct CompositeFakeDevice { + devices: Vec>, +} + +impl CompositeFakeDevice { + /// Build an empty composite. + #[must_use] + pub fn new(devices: Vec>) -> Self { + Self { devices } + } + + /// WiFred + LongFred mocks. + #[must_use] + pub fn all() -> Self { + Self::new(vec![ + Box::new(WifredFake::new()), + Box::new(LongFredFake::new()), + ]) + } +} + +impl FakeDevice for CompositeFakeDevice { + fn driver_id(&self) -> &'static str { + "composite" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + for device in &mut self.devices { + let resp = device.handle(FakeRequest { + method: req.method, + path: req.path, + body: req.body, + }); + if resp.status != 404 { + return resp; + } + } + not_found() + } +} diff --git a/crates/wp-fake/src/device.rs b/crates/wp-fake/src/device.rs new file mode 100644 index 0000000..6b3d483 --- /dev/null +++ b/crates/wp-fake/src/device.rs @@ -0,0 +1,70 @@ +//! Minimal fake Soft-AP HTTP device contract. + +/// An inbound HTTP request presented to a [`FakeDevice`]. +pub struct FakeRequest<'a> { + /// HTTP method (e.g. `"GET"`). + pub method: &'a str, + /// Request path including query string (e.g. `/index.html?loco=1`). + pub path: &'a str, + /// Optional request body. + pub body: Option<&'a [u8]>, +} + +/// An outbound HTTP response from a [`FakeDevice`]. +pub struct FakeResponse { + /// HTTP status code. + pub status: u16, + /// `Content-Type` header value. + pub content_type: &'static str, + /// Response body bytes. + pub body: Vec, +} + +/// Build a `200` text/plain response. +#[must_use] +pub fn ok_text(body: impl Into) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "text/plain", + body: body.into().into_bytes(), + } +} + +/// Build a `200` text/xml (or HTML-compatible) response. +#[must_use] +pub fn ok_xml(body: impl Into>) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "text/html", + body: body.into(), + } +} + +/// Build a `200` application/json response. +#[must_use] +pub fn ok_json(body: impl Into>) -> FakeResponse { + FakeResponse { + status: 200, + content_type: "application/json", + body: body.into(), + } +} + +/// Build a `404` text/plain response. +#[must_use] +pub fn not_found() -> FakeResponse { + FakeResponse { + status: 404, + content_type: "text/plain", + body: b"not found".to_vec(), + } +} + +/// A mock Soft-AP HTTP device. +pub trait FakeDevice: Send { + /// Stable driver id string (`"wifred"`, `"longfred"`, …). + fn driver_id(&self) -> &'static str; + + /// Handle one HTTP request. + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse; +} diff --git a/crates/wp-fake/src/lib.rs b/crates/wp-fake/src/lib.rs new file mode 100644 index 0000000..b81cc63 --- /dev/null +++ b/crates/wp-fake/src/lib.rs @@ -0,0 +1,17 @@ +//! Fake radio and Soft-AP HTTP device mocks for wireless-programmer tests. + +#![forbid(unsafe_code)] + +mod composite; +mod device; +mod longfred; +mod radio; +mod server; +mod wifred; + +pub use composite::CompositeFakeDevice; +pub use device::{not_found, ok_json, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; +pub use longfred::LongFredFake; +pub use radio::FakeRadio; +pub use server::{bind_and_serve, FakeHttpServer}; +pub use wifred::WifredFake; diff --git a/crates/wp-fake/src/longfred.rs b/crates/wp-fake/src/longfred.rs new file mode 100644 index 0000000..645f283 --- /dev/null +++ b/crates/wp-fake/src/longfred.rs @@ -0,0 +1,241 @@ +//! LongFred Soft-AP HTTP mock. + +use serde_json::{json, Value}; + +use crate::device::{not_found, ok_json, ok_text, FakeDevice, FakeRequest, FakeResponse}; + +/// Fake LongFred programming-mode HTTP device. +pub struct LongFredFake { + /// GET-shaped settings document. + pub settings: Value, + /// Whether programming mode is still active. + pub programming_mode: bool, +} + +impl LongFredFake { + /// Default factory settings in programming mode. + #[must_use] + pub fn new() -> Self { + Self { + settings: json!({ + "wifi": { "hostname": "", "networks": [] }, + "roster": { "mode": "static", "entries": [] }, + "bigfred": { "login": "", "pin_set": false }, + "programming_mode": true + }), + programming_mode: true, + } + } + + fn apply_put(&mut self, body: &Value) { + // wifi.ssid → push into wifi.networks; keep hostname from wifi.hostname + if let Some(wifi) = body.get("wifi") { + if let Some(hostname) = wifi.get("hostname").and_then(Value::as_str) { + if let Some(obj) = self.settings.get_mut("wifi").and_then(Value::as_object_mut) { + obj.insert("hostname".into(), json!(hostname)); + } + } + if let Some(ssid) = wifi.get("ssid").and_then(Value::as_str) { + let networks = self + .settings + .pointer_mut("/wifi/networks") + .and_then(Value::as_array_mut); + if let Some(arr) = networks { + if !arr.iter().any(|n| n.as_str() == Some(ssid)) { + arr.push(json!(ssid)); + } + } + } + } + + 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) + { + 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) + { + 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) + { + obj.insert("entries".into(), Value::Array(roster.clone())); + } + } + } +} + +impl Default for LongFredFake { + fn default() -> Self { + Self::new() + } +} + +impl FakeDevice for LongFredFake { + fn driver_id(&self) -> &'static str { + "longfred" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + let path = req.path.split('?').next().unwrap_or(req.path); + + match (req.method, path) { + ("GET", "/api/v1/settings") => { + let body = serde_json::to_vec(&self.settings).unwrap_or_default(); + ok_json(body) + } + ("PUT", "/api/v1/settings") => { + let raw = req.body.unwrap_or(b"{}"); + match serde_json::from_slice::(raw) { + Ok(body) => { + self.apply_put(&body); + ok_json(b"{}".to_vec()) + } + Err(_) => FakeResponse { + status: 400, + content_type: "text/plain", + body: b"bad json".to_vec(), + }, + } + } + ("POST", "/api/v1/programming-mode/off") => { + self.programming_mode = false; + if let Some(obj) = self.settings.as_object_mut() { + obj.insert("programming_mode".into(), json!(false)); + } + 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(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_core::{BigfredCreds, ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials}; + use wp_drivers::longfred::{build_settings_put, verify}; + + fn base_req<'a>() -> ProgramRequest<'a> { + ProgramRequest { + identity: "pilot1", + wifi: WifiCredentials { + ssid: "club-wifi", + psk: Some("secret"), + }, + server: ThrottleServer { + host: "unused.local", + port: 12090, + automatic: false, + }, + roster: vec![ + RosterEntry { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }, + RosterEntry { + address: Some(128), + long_address: Some(true), + mode: None, + direction: None, + functions: Vec::new(), + }, + ], + bigfred: Some(BigfredCreds { + login: "ops", + pin: "1234", + }), + roster_mode: Some("static"), + } + } + + #[test] + fn put_to_get_round_trip_verifies() { + let mut fake = LongFredFake::new(); + let req = base_req(); + let put = build_settings_put(&req); + let body = serde_json::to_vec(&put).expect("serialize"); + + let resp = fake.handle(FakeRequest { + method: "PUT", + path: "/api/v1/settings", + body: Some(&body), + }); + assert_eq!(resp.status, 200); + + let get = fake.handle(FakeRequest { + method: "GET", + path: "/api/v1/settings", + body: None, + }); + assert_eq!(get.status, 200); + let settings: Value = serde_json::from_slice(&get.body).expect("json"); + let mismatches = verify(&settings, &req); + assert!( + mismatches.is_empty(), + "expected verify to pass, got {mismatches:?}; settings={settings}" + ); + } + + #[test] + fn programming_mode_off() { + let mut fake = LongFredFake::new(); + assert!(fake.programming_mode); + let resp = fake.handle(FakeRequest { + method: "POST", + path: "/api/v1/programming-mode/off", + body: None, + }); + assert_eq!(resp.status, 200); + 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-fake/src/radio.rs b/crates/wp-fake/src/radio.rs new file mode 100644 index 0000000..26921c7 --- /dev/null +++ b/crates/wp-fake/src/radio.rs @@ -0,0 +1,81 @@ +//! In-memory [`wp_link::Radio`] for tests. + +use parking_lot::Mutex; +use wp_link::{Radio, RadioFut, ScanResult}; + +/// Fake radio that returns canned scan results and records calls. +pub struct FakeRadio { + scan_results: Vec, + calls: Mutex>, +} + +impl FakeRadio { + /// Construct with explicit scan results. + #[must_use] + pub fn new(results: Vec) -> Self { + Self { + scan_results: results, + calls: Mutex::new(Vec::new()), + } + } + + /// One Soft-AP scan hit per known driver prefix. + #[must_use] + pub fn one_per_driver() -> Self { + Self::new(vec![ + ScanResult { + ssid: Some(format!( + "{}deadbe", + wp_drivers::wifred::WIFI_CONFIG_SSID_PREFIX + )), + bssid: Some("de:ad:be:ef:00:01".into()), + rssi: Some(-42), + }, + ScanResult { + ssid: Some(format!( + "{}_deadbe", + wp_drivers::longfred::WIFI_CONFIG_SSID_PREFIX + )), + bssid: Some("de:ad:be:ef:00:02".into()), + rssi: Some(-42), + }, + ]) + } + + /// Recorded method names (`scan`, `connect_open`, …). + pub fn calls(&self) -> Vec { + self.calls.lock().clone() + } + + fn record(&self, name: &str) { + self.calls.lock().push(name.to_string()); + } +} + +impl Radio for FakeRadio { + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec> { + self.record("scan"); + let results: Vec<_> = self.scan_results.iter().take(max).cloned().collect(); + Box::pin(async move { Ok(results) }) + } + + fn connect_open(&mut self, _ssid: &str, _bssid: Option<[u8; 6]>) -> RadioFut<'_, ()> { + self.record("connect_open"); + Box::pin(async move { Ok(()) }) + } + + fn set_address(&mut self, _addr: std::net::Ipv4Addr, _prefix_len: u8) -> RadioFut<'_, ()> { + self.record("set_address"); + Box::pin(async move { Ok(()) }) + } + + fn link_up(&mut self) -> RadioFut<'_, ()> { + self.record("link_up"); + Box::pin(async move { Ok(()) }) + } + + fn release(&mut self) -> RadioFut<'_, ()> { + self.record("release"); + Box::pin(async move { Ok(()) }) + } +} diff --git a/crates/wp-fake/src/server.rs b/crates/wp-fake/src/server.rs new file mode 100644 index 0000000..145727f --- /dev/null +++ b/crates/wp-fake/src/server.rs @@ -0,0 +1,156 @@ +//! Minimal HTTP/1.1 server for [`FakeDevice`](crate::FakeDevice) mocks. + +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +use crate::device::{FakeDevice, FakeRequest}; + +/// Namespace for the fake Soft-AP HTTP server. +pub struct FakeHttpServer; + +impl FakeHttpServer { + /// Accept connections and serve `device` until the listener fails. + pub async fn serve( + listener: TcpListener, + device: Arc>, + ) -> io::Result<()> { + loop { + let (stream, _) = listener.accept().await?; + let device = Arc::clone(&device); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, device).await { + log::debug!("wp-fake connection error: {e}"); + } + }); + } + } +} + +/// Bind `addr` (port `0` allowed), log the local address, spawn the accept +/// loop, and return the bound address. +pub async fn bind_and_serve( + addr: SocketAddr, + device: Arc>, +) -> io::Result { + let listener = TcpListener::bind(addr).await?; + let local = listener.local_addr()?; + log::info!("wp-fake listening on {local}"); + let device_clone = Arc::clone(&device); + tokio::spawn(async move { + if let Err(e) = FakeHttpServer::serve(listener, device_clone).await { + log::error!("wp-fake server stopped: {e}"); + } + }); + Ok(local) +} + +async fn handle_connection( + mut stream: TcpStream, + device: Arc>, +) -> io::Result<()> { + let mut buf = Vec::with_capacity(1024); + let header_end = loop { + let mut chunk = [0u8; 512]; + let n = stream.read(&mut chunk).await?; + if n == 0 { + return Ok(()); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = find_header_end(&buf) { + break pos; + } + if buf.len() > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "HTTP headers too large", + )); + } + }; + + let header = std::str::from_utf8(&buf[..header_end]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let (method, path, content_length) = parse_request_line_and_headers(header)?; + + let body_start = header_end + 4; + while buf.len() < body_start + content_length { + let mut chunk = [0u8; 512]; + let n = stream.read(&mut chunk).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + + let body = if content_length > 0 { + Some(&buf[body_start..body_start + content_length.min(buf.len() - body_start)]) + } else { + None + }; + + let response = { + let mut guard = device.lock().await; + guard.handle(FakeRequest { + method: &method, + path: &path, + body, + }) + }; + + write_response(&mut stream, &response).await +} + +fn find_header_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn parse_request_line_and_headers(header: &str) -> io::Result<(String, String, usize)> { + let mut lines = header.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty request"))?; + let mut parts = request_line.split_whitespace(); + let method = parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing method"))? + .to_string(); + let path = parts + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing path"))? + .to_string(); + // HTTP version ignored. + + let mut content_length = 0usize; + for line in lines { + let lower = line.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("content-length:") { + content_length = rest.trim().parse().unwrap_or(0); + } + } + Ok((method, path, content_length)) +} + +async fn write_response( + stream: &mut TcpStream, + response: &crate::device::FakeResponse, +) -> io::Result<()> { + let reason = match response.status { + 200 => "OK", + 404 => "Not Found", + _ => "Error", + }; + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.status, + reason, + response.content_type, + response.body.len() + ); + stream.write_all(head.as_bytes()).await?; + stream.write_all(&response.body).await?; + stream.flush().await +} diff --git a/crates/wp-fake/src/wifred.rs b/crates/wp-fake/src/wifred.rs new file mode 100644 index 0000000..f5c3fac --- /dev/null +++ b/crates/wp-fake/src/wifred.rs @@ -0,0 +1,452 @@ +//! WiFred Soft-AP HTTP mock. + +use wp_drivers::wifred::{DeviceConfig, FunctionEntry, LocoConfig, NetworkConfig}; + +use crate::device::{not_found, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; + +/// Fake WiFred config-mode HTTP device. +pub struct WifredFake { + /// Current device configuration (GET XML shape). + pub cfg: DeviceConfig, + /// Set when `/restart.html` is hit. + pub restarted: bool, + /// Active loco slot index (0-based) from `loco=N`. + pub active_loco: Option, +} + +impl WifredFake { + /// Default factory state: structure version 1, four empty loco slots. + #[must_use] + pub fn new() -> Self { + Self { + cfg: DeviceConfig { + structure_version: Some("1".into()), + throttle_name: None, + firmware_revision: None, + battery_mv: None, + locos: (0..4) + .map(|_| LocoConfig { + address: -1, + ..Default::default() + }) + .collect(), + networks: Vec::new(), + loco_server: None, + }, + restarted: false, + active_loco: None, + } + } + + /// Serialize `cfg` to WiFred-compatible XML. + #[must_use] + pub fn serialize_xml(&self) -> Vec { + serialize_xml(&self.cfg) + } + + fn apply_query(&mut self, query: &str) { + let mut pending_ssid: Option = None; + let mut pending_key: Option = None; + + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (raw_key, raw_val) = match pair.split_once('=') { + Some((k, v)) => (k, v), + None => (pair, ""), + }; + let key = percent_decode(raw_key); + let value = percent_decode(raw_val); + + match key.as_str() { + "throttleName" => { + self.cfg.throttle_name = Some(value); + } + "loco" => { + if let Ok(n) = value.parse::() { + if n >= 1 { + let idx = n - 1; + while self.cfg.locos.len() <= idx { + self.cfg.locos.push(LocoConfig { + address: -1, + ..Default::default() + }); + } + self.active_loco = Some(idx); + } + } + } + "loco.address" => { + if let Some(loco) = self.active_loco_mut() { + loco.address = value.parse().unwrap_or(-1); + } + } + "loco.mode" => { + if let Some(loco) = self.active_loco_mut() { + loco.mode = Some(value); + } + } + "loco.direction" => { + if let Some(loco) = self.active_loco_mut() { + loco.direction = value.parse().ok(); + } + } + "loco.longAddress" => { + if value == "on" { + if let Some(loco) = self.active_loco_mut() { + loco.long_address = Some(true); + } + } + } + "loco.serverName" => { + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .name = value; + } + "loco.serverPort" => { + let port = value.parse().unwrap_or(0); + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .port = port; + } + "loco.automatic" => { + if value == "on" { + self.cfg + .loco_server + .get_or_insert_with(Default::default) + .automatic = true; + } + } + "remove" => { + self.cfg.networks.retain(|n| n.ssid != value); + } + "wifiSSID" => { + pending_ssid = Some(value); + } + "wifiKEY" => { + pending_key = Some(value); + } + other if is_function_key(other) => { + let index: u8 = other[1..].parse().unwrap_or(0); + let fval: u8 = value.parse().unwrap_or(0); + if let Some(loco) = self.active_loco_mut() { + if let Some(existing) = loco.functions.iter_mut().find(|f| f.index == index) + { + existing.value = fval; + } else { + loco.functions.push(FunctionEntry { index, value: fval }); + } + } + } + _ => {} + } + } + + if let Some(ssid) = pending_ssid { + upsert_network(&mut self.cfg.networks, ssid, pending_key); + } + } + + fn active_loco_mut(&mut self) -> Option<&mut LocoConfig> { + let idx = self.active_loco?; + self.cfg.locos.get_mut(idx) + } +} + +impl Default for WifredFake { + fn default() -> Self { + Self::new() + } +} + +impl FakeDevice for WifredFake { + fn driver_id(&self) -> &'static str { + "wifred" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + if req.method != "GET" { + return not_found(); + } + let path = req.path; + if path.starts_with("/api/getConfigXML") { + return ok_xml(self.serialize_xml()); + } + if path.starts_with("/restart.html") { + self.restarted = true; + return ok_text("ok"); + } + if path.starts_with("/flashred.html") { + return ok_text("flash"); + } + if path.starts_with("/index.html") { + if let Some(q) = path.split_once('?').map(|(_, q)| q) { + self.apply_query(q); + } + return ok_text("ok"); + } + not_found() + } +} + +fn is_function_key(key: &str) -> bool { + let mut chars = key.chars(); + if chars.next() != Some('f') { + return false; + } + let rest: String = chars.collect(); + !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) +} + +fn upsert_network(networks: &mut Vec, ssid: String, key: Option) { + if let Some(existing) = networks.iter_mut().find(|n| n.ssid == ssid) { + existing.enabled = true; + if key.is_some() { + existing.key = key; + } + } else { + networks.push(NetworkConfig { + ssid, + key, + enabled: true, + }); + } +} + +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let h1 = from_hex(bytes[i + 1]); + let h2 = from_hex(bytes[i + 2]); + if let (Some(a), Some(b)) = (h1, h2) { + out.push((a << 4) | b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn from_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Emit XML tags that [`wp_drivers::wifred::parse`] understands. +pub fn serialize_xml(cfg: &DeviceConfig) -> Vec { + let mut s = String::from("\n\n"); + if let Some(v) = &cfg.structure_version { + push_empty(&mut s, "structurVersion", v); + } + if let Some(v) = &cfg.throttle_name { + push_empty(&mut s, "throttleName", v); + } + if let Some(v) = &cfg.firmware_revision { + push_empty(&mut s, "firmwareRevision", v); + } + if let Some(mv) = cfg.battery_mv { + push_empty(&mut s, "batteryVoltage", &mv.to_string()); + } + + s.push_str("\n"); + for (i, loco) in cfg.locos.iter().enumerate() { + let id = loco.id.unwrap_or((i + 1) as u8); + s.push_str(&format!(" \n")); + push_empty(&mut s, "DCCadress", &loco.address.to_string()); + if let Some(mode) = &loco.mode { + push_empty(&mut s, "Mode", mode); + } else { + push_empty(&mut s, "Mode", ""); + } + if let Some(dir) = loco.direction { + push_empty(&mut s, "Direction", &dir.to_string()); + } + if let Some(long) = loco.long_address { + push_empty(&mut s, "LongAdress", if long { "1" } else { "0" }); + } + s.push_str(" \n"); + for f in &loco.functions { + s.push_str(&format!( + " \n", + f.index, f.value + )); + } + s.push_str(" \n"); + s.push_str(" \n"); + } + s.push_str("\n"); + + s.push_str("\n"); + for net in &cfg.networks { + s.push_str(" \n"); + push_empty(&mut s, "SSID", &net.ssid); + if let Some(key) = &net.key { + push_empty(&mut s, "Key", key); + } + push_empty(&mut s, "Enabled", if net.enabled { "1" } else { "0" }); + s.push_str(" \n"); + } + s.push_str("\n"); + + if let Some(srv) = &cfg.loco_server { + s.push_str("\n"); + push_empty(&mut s, "ServerName", &srv.name); + push_empty(&mut s, "Port", &srv.port.to_string()); + push_empty(&mut s, "Automatic", if srv.automatic { "1" } else { "0" }); + s.push_str("\n"); + } + + s.push_str("\n"); + s.into_bytes() +} + +fn push_empty(out: &mut String, tag: &str, value: &str) { + out.push_str(&format!("<{tag} value=\"{}\"/>\n", xml_escape(value))); +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_drivers::wifred::{parse, LocoServerConfig}; + + #[test] + fn serialize_round_trip() { + let mut fake = WifredFake::new(); + fake.cfg.throttle_name = Some("122145".into()); + fake.cfg.firmware_revision = Some("2022-10-16".into()); + fake.cfg.battery_mv = Some(3850); + fake.cfg.locos[0] = LocoConfig { + id: Some(1), + address: 3, + mode: Some("128".into()), + direction: Some(0), + long_address: Some(false), + functions: vec![ + FunctionEntry { index: 0, value: 0 }, + FunctionEntry { index: 1, value: 4 }, + ], + }; + fake.cfg.networks.push(NetworkConfig { + ssid: "bigfred2".into(), + key: Some("secret-pass".into()), + enabled: true, + }); + fake.cfg.loco_server = Some(LocoServerConfig { + name: "bigfred.local".into(), + port: 12090, + automatic: false, + }); + + let xml = fake.serialize_xml(); + let parsed = parse(&xml).expect("parse"); + assert_eq!(parsed.structure_version.as_deref(), Some("1")); + assert_eq!(parsed.throttle_name.as_deref(), Some("122145")); + assert_eq!(parsed.battery_mv, Some(3850)); + assert_eq!(parsed.locos[0].address, 3); + assert_eq!(parsed.locos[0].mode.as_deref(), Some("128")); + assert_eq!(parsed.locos[0].functions.len(), 2); + assert_eq!(parsed.networks[0].ssid, "bigfred2"); + assert_eq!(parsed.networks[0].key.as_deref(), Some("secret-pass")); + let srv = parsed.loco_server.expect("server"); + assert_eq!(srv.name, "bigfred.local"); + assert_eq!(srv.port, 12090); + } + + #[test] + fn program_sequence_mutations() { + let mut fake = WifredFake::new(); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?throttleName=pilot1", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco=1&loco.address=3&loco.mode=128&loco.direction=0&loco.longAddress=on", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco=1&f0=0&f1=4", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?loco.serverName=bigfred.local&loco.serverPort=12090", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/index.html?wifiSSID=club-wifi&wifiKEY=secret", + body: None, + }); + let _ = fake.handle(FakeRequest { + method: "GET", + path: "/restart.html", + body: None, + }); + + assert_eq!(fake.cfg.throttle_name.as_deref(), Some("pilot1")); + assert_eq!(fake.cfg.locos[0].address, 3); + assert_eq!(fake.cfg.locos[0].mode.as_deref(), Some("128")); + assert_eq!(fake.cfg.locos[0].long_address, Some(true)); + assert_eq!(fake.cfg.locos[0].functions.len(), 2); + assert_eq!( + fake.cfg.loco_server.as_ref().map(|s| s.name.as_str()), + Some("bigfred.local") + ); + assert_eq!(fake.cfg.networks.len(), 1); + assert_eq!(fake.cfg.networks[0].ssid, "club-wifi"); + assert!(fake.restarted); + + let xml = fake.serialize_xml(); + let parsed = parse(&xml).expect("parse"); + assert_eq!(parsed.throttle_name.as_deref(), Some("pilot1")); + assert_eq!(parsed.locos[0].address, 3); + assert!(parsed.networks.iter().any(|n| n.ssid == "club-wifi")); + } + + #[test] + fn percent_decode_plus_and_hex() { + assert_eq!(percent_decode("a+b%20c"), "a b c"); + assert_eq!(percent_decode("bigfred%2Elocal"), "bigfred.local"); + } +} 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 6802cd3..430d25a 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -3,13 +3,21 @@ #![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, resolve_wireless_interface, Nl80211Radio, - Radio, ScanResult, + first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, + resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, }; pub use rfkill::{aggregate_state, RfkillState}; diff --git a/crates/wp-link/src/mdns.rs b/crates/wp-link/src/mdns.rs new file mode 100644 index 0000000..4bafda7 --- /dev/null +++ b/crates/wp-link/src/mdns.rs @@ -0,0 +1,267 @@ +//! Minimal mDNS query for `_longfred-ota._tcp.local`. +//! +//! LongFred STA OTA does not answer PTR queries. It sends unsolicited +//! announcements to `224.0.0.251:5353` every 2 s while the Firmware update +//! menu is open. Discovery therefore **joins the multicast group and binds +//! 5353** so those packets are received. + +use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket}; +use std::time::{Duration, Instant}; + +use socket2::{Domain, Protocol, Socket, Type}; + +/// 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, +} + +fn mdns_listener() -> std::io::Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + socket.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT).into())?; + socket.join_multicast_v4(&MDNS_GROUP, &Ipv4Addr::UNSPECIFIED)?; + socket.set_multicast_ttl_v4(1)?; + let sock = UdpSocket::from(socket); + sock.set_read_timeout(Some(Duration::from_millis(200)))?; + Ok(sock) +} + +/// Send a PTR query and collect A/SRV answers / unsolicited announcements +/// for [`OTA_HTTP_SERVICE`]. +/// +/// # Errors +/// +/// Returns [`std::io::Error`] on socket failure (including inability to bind +/// UDP 5353). +pub fn discover_ota_hosts(wait: Duration) -> std::io::Result> { + let sock = mdns_listener()?; + let q = ptr_query(OTA_HTTP_SERVICE); + let _ = 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))) +} + +fn skip_questions(pkt: &[u8], mut off: usize, qd: u16) -> Option { + for _ in 0..qd { + let (_, nend) = read_name(pkt, off)?; + off = nend.checked_add(4)?; // TYPE + CLASS + } + Some(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 qd = be16(pkt, 4).unwrap_or(0); + 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 Some(mut off) = skip_questions(pkt, 12, qd) else { + return out; + }; + 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 +} + +/// Build an unsolicited OTA announcement matching LongFred firmware +/// (`build_ota_announce`): 0 questions, PTR + SRV + A. +pub fn encode_ota_announce(hostname: &str, ipv4: Ipv4Addr, port: u16) -> Vec { + let mut n = Vec::new(); + n.extend_from_slice(&[0, 0, 0x84, 0, 0, 0, 0, 3, 0, 0, 0, 0]); + put_name(&mut n, &["_longfred-ota", "_tcp", "local"]); + n.extend_from_slice(&[0, 12, 0, 1, 0, 0, 0, 120]); + let instance = [hostname, "_longfred-ota", "_tcp", "local"]; + let instance_len = name_len(&instance); + n.extend_from_slice(&u16::try_from(instance_len).unwrap_or(0).to_be_bytes()); + put_name(&mut n, &instance); + put_name(&mut n, &[hostname, "_longfred-ota", "_tcp", "local"]); + n.extend_from_slice(&[0, 33, 0, 1, 0, 0, 0, 120]); + let target = [hostname, "local"]; + let target_len = 6 + name_len(&target); + n.extend_from_slice(&u16::try_from(target_len).unwrap_or(0).to_be_bytes()); + n.extend_from_slice(&[0, 0, 0, 0]); + n.extend_from_slice(&port.to_be_bytes()); + put_name(&mut n, &target); + put_name(&mut n, &target); + n.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 120, 0, 4]); + n.extend_from_slice(&ipv4.octets()); + n +} + +fn name_len(labels: &[&str]) -> usize { + labels.iter().map(|l| 1 + l.len()).sum::() + 1 +} + +fn put_name(buf: &mut Vec, labels: &[&str]) { + for lab in labels { + buf.push(u8::try_from(lab.len()).unwrap_or(0)); + buf.extend_from_slice(lab.as_bytes()); + } + buf.push(0); +} + +#[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()); + } + + #[test] + fn parse_longfred_unsolicited_announce() { + let pkt = encode_ota_announce("pilot1", Ipv4Addr::new(192, 168, 1, 40), 80); + let hosts = parse_ota_hosts(&pkt); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].ipv4, Ipv4Addr::new(192, 168, 1, 40)); + assert_eq!(hosts[0].port, 80); + assert_eq!(hosts[0].hostname, "pilot1"); + } + + #[test] + fn parse_skips_question_section() { + let announce = encode_ota_announce("pilot1", Ipv4Addr::new(10, 0, 0, 9), 80); + // Prepend a query header with QDCOUNT=1 and one question, then the + // original answers with ANCOUNT preserved from `announce`. + let mut pkt = vec![0, 0, 0x84, 0, 0, 1]; // flags + QD=1 + pkt.extend_from_slice(&announce[6..12]); // AN/NS/AR from announce + put_name(&mut pkt, &["_longfred-ota", "_tcp", "local"]); + pkt.extend_from_slice(&[0, 12, 0, 1]); // PTR IN + pkt.extend_from_slice(&announce[12..]); + let hosts = parse_ota_hosts(&pkt); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].ipv4, Ipv4Addr::new(10, 0, 0, 9)); + } +} diff --git a/crates/wp-link/src/radio.rs b/crates/wp-link/src/radio.rs index 5ca669d..c5519e3 100644 --- a/crates/wp-link/src/radio.rs +++ b/crates/wp-link/src/radio.rs @@ -6,7 +6,9 @@ //! [`wp_core::HttpClient`] to the driver. On every exit path the radio is //! released: disconnect and address removal. +use std::future::Future; use std::path::Path; +use std::pin::Pin; use wp_core::DriverError; @@ -21,33 +23,28 @@ pub struct ScanResult { pub rssi: Option, } +/// Boxed future returned by [`Radio`] methods (dyn-compatible). +pub type RadioFut<'a, T> = Pin> + Send + 'a>>; + /// The async radio contract. Implementations use nl80211 + rtnetlink. -pub trait Radio { +/// +/// Methods return boxed futures so the trait is dyn-compatible +/// (`Box` in the daemon runtime). +pub trait Radio: Send { /// Trigger a scan and return up to `max` results. - fn scan( - &mut self, - max: usize, - ) -> impl std::future::Future, DriverError>>; + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec>; /// Associate to an open AP identified by SSID (and optional BSSID hint). - fn connect_open( - &mut self, - ssid: &str, - bssid: Option<[u8; 6]>, - ) -> impl std::future::Future>; + fn connect_open(&mut self, ssid: &str, bssid: Option<[u8; 6]>) -> RadioFut<'_, ()>; /// Assign `addr/prefix_len` to the wireless interface (on-link route only). - fn set_address( - &mut self, - addr: std::net::Ipv4Addr, - prefix_len: u8, - ) -> impl std::future::Future>; + fn set_address(&mut self, addr: std::net::Ipv4Addr, prefix_len: u8) -> RadioFut<'_, ()>; /// Bring the link up. - fn link_up(&mut self) -> impl std::future::Future>; + fn link_up(&mut self) -> RadioFut<'_, ()>; /// Disconnect and remove the assigned address, releasing the radio. - fn release(&mut self) -> impl std::future::Future>; + fn release(&mut self) -> RadioFut<'_, ()>; } /// Select the first wireless interface by scanning `/sys/class/net/*/wireless`. @@ -130,20 +127,105 @@ fn interface_index(name: &str) -> Result { .map_err(|e| DriverError::Other(format!("bad ifindex for {name}: {e}"))) } +/// Format a MAC as lowercase colon-separated hex. +fn format_bssid(mac: &[u8; 6]) -> String { + format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ) +} + +/// Extract an SSID from raw 802.11 information elements (TLV: id, len, data). +fn ssid_from_ies(ies: &[u8]) -> Option { + let mut i = 0; + while i + 1 < ies.len() { + let id = ies[i]; + let len = usize::from(ies[i + 1]); + if i + 2 + len > ies.len() { + break; + } + if id == 0 { + let bytes = &ies[i + 2..i + 2 + len]; + if bytes.is_empty() { + return None; + } + return Some(String::from_utf8_lossy(bytes).into_owned()); + } + i += 2 + len; + } + None +} + +/// Parse one BSS info vector into a [`ScanResult`]. +pub fn parse_bss_infos(bss: &[wl_nl80211::Nl80211BssInfo]) -> Option { + use wl_nl80211::Nl80211BssInfo; + + let mut ssid = None; + let mut bssid = None; + let mut rssi = None; + + for info in bss { + match info { + Nl80211BssInfo::Bssid(mac) => { + bssid = Some(format_bssid(mac)); + } + Nl80211BssInfo::SignalMbm(mbm) => { + rssi = Some(mbm / 100); + } + Nl80211BssInfo::RawInformationElements(ies) + | Nl80211BssInfo::RawBeaconInformationElements(ies) + | Nl80211BssInfo::RawProbeResponseInformationElements(ies) + if ssid.is_none() => + { + ssid = ssid_from_ies(ies); + } + _ => {} + } + } + + if ssid.is_none() && bssid.is_none() { + return None; + } + Some(ScanResult { ssid, bssid, rssi }) +} + +/// Parse a dump message's attributes into a [`ScanResult`]. +pub fn parse_scan_attrs(attrs: &[wl_nl80211::Nl80211Attr]) -> Option { + for attr in attrs { + if let wl_nl80211::Nl80211Attr::Bss(bss) = attr { + return parse_bss_infos(bss); + } + } + None +} + /// `wl-nl80211` + `rtnetlink` backed radio. /// /// Construct with [`Nl80211Radio::new`] or [`Nl80211Radio::with_interface`]; /// requires `CAP_NET_ADMIN` and `CAP_NET_RAW`. All operations are async and /// run on a tokio runtime. -/// -/// The nl80211 scan/connect and rtnetlink addressing paths require a real -/// wireless adapter to exercise end-to-end; they are kept minimal here and -/// documented for hardware validation. pub struct Nl80211Radio { iface: String, if_index: u32, } +/// Passive scan dwell after the trigger ACK (2.4 GHz, ~13 channels). +const SCAN_SETTLE: std::time::Duration = std::time::Duration::from_millis(2500); +/// Hard cap on a scan trigger + dump. +const SCAN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(8); +/// Association / connect command cap. +const ASSOCIATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); +/// Pause after CONNECT so the kernel finishes association before HTTP. +const ASSOCIATE_SETTLE: std::time::Duration = std::time::Duration::from_millis(800); + +async fn drain_netlink(mut stream: S) +where + S: futures::stream::TryStream + Unpin, +{ + use futures::stream::TryStreamExt; + while let Ok(Some(_)) = stream.try_next().await {} +} + impl Nl80211Radio { /// Bind to the first wireless interface. /// @@ -182,123 +264,147 @@ impl Nl80211Radio { } impl Radio for Nl80211Radio { - async fn scan(&mut self, max: usize) -> Result, DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::Nl80211Scan; - - let (connection, handle, _) = wl_nl80211::new_connection() - .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; - tokio::spawn(connection); - - // Trigger a passive scan, then dump the cached results. - let attrs = Nl80211Scan::new(self.if_index).passive(true).build(); - let mut trigger = handle.scan().trigger(attrs).execute().await; - while trigger.try_next().await.is_ok() { - // drain acks - } - // Give the kernel a moment to populate the cache. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let mut dump = handle.scan().dump(self.if_index).execute().await; - let results = Vec::new(); - while let Ok(_msg) = dump.try_next().await { - if results.len() >= max { - break; - } - } - Ok(results) + fn scan(&mut self, max: usize) -> RadioFut<'_, Vec> { + let if_index = self.if_index; + Box::pin(async move { + use futures::stream::TryStreamExt; + use wl_nl80211::Nl80211Scan; + + tokio::time::timeout(SCAN_DEADLINE, async { + let (connection, handle, _) = wl_nl80211::new_connection() + .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; + tokio::spawn(connection); + + let attrs = Nl80211Scan::new(if_index).passive(true).build(); + let trigger = handle.scan().trigger(attrs).execute().await; + drain_netlink(trigger).await; + tokio::time::sleep(SCAN_SETTLE).await; + + let mut dump = handle.scan().dump(if_index).execute().await; + let mut results = Vec::new(); + while let Ok(Some(msg)) = dump.try_next().await { + if results.len() >= max { + break; + } + if let Some(r) = parse_scan_attrs(&msg.payload.attributes) { + results.push(r); + } + } + Ok(results) + }) + .await + .map_err(|_| DriverError::DeadlineElapsed("scan"))? + }) } - async fn connect_open( - &mut self, - ssid: &str, - bssid: Option<[u8; 6]>, - ) -> Result<(), DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::{Nl80211AuthType, Nl80211Connect}; - - let (connection, handle, _) = wl_nl80211::new_connection() - .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; - tokio::spawn(connection); - - let mut builder = Nl80211Connect::new(self.if_index) - .ssid(ssid) - .auth_type(Nl80211AuthType::OpenSystem) - .privacy(false); - if let Some(mac) = bssid { - builder = builder.mac(mac); - } - let attrs = builder.build(); + fn connect_open(&mut self, ssid: &str, bssid: Option<[u8; 6]>) -> RadioFut<'_, ()> { + let if_index = self.if_index; + let ssid = ssid.to_string(); + Box::pin(async move { + use wl_nl80211::{Nl80211AuthType, Nl80211Connect}; - let mut stream = handle.connection().connect(attrs).execute().await; - while stream.try_next().await.is_ok() { - // drain acks - } - Ok(()) - } + let (connection, handle, _) = wl_nl80211::new_connection() + .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; + tokio::spawn(connection); - async fn set_address( - &mut self, - addr: std::net::Ipv4Addr, - prefix_len: u8, - ) -> Result<(), DriverError> { - use rtnetlink::new_connection; - - let (connection, handle, _) = new_connection() - .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; - tokio::spawn(connection); - - handle - .address() - .add(self.if_index, std::net::IpAddr::V4(addr), prefix_len) - .execute() + let mut builder = Nl80211Connect::new(if_index) + .ssid(&ssid) + .auth_type(Nl80211AuthType::OpenSystem) + .privacy(false); + if let Some(mac) = bssid { + builder = builder.mac(mac); + } + let attrs = builder.build(); + + tokio::time::timeout(ASSOCIATE_DEADLINE, async { + let stream = handle.connection().connect(attrs).execute().await; + drain_netlink(stream).await; + tokio::time::sleep(ASSOCIATE_SETTLE).await; + Ok::<(), DriverError>(()) + }) .await - .map_err(|e| DriverError::Other(format!("address add: {e}"))) + .map_err(|_| DriverError::AssociationTimedOut)? + }) } - async fn link_up(&mut self) -> Result<(), DriverError> { - use rtnetlink::{new_connection, LinkUnspec}; - - let (connection, handle, _) = new_connection() - .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; - tokio::spawn(connection); - let msg = LinkUnspec::new_with_index(self.if_index).up().build(); - handle - .link() - .set(msg) - .execute() - .await - .map_err(|e| DriverError::Other(format!("link up: {e}"))) + fn set_address(&mut self, addr: std::net::Ipv4Addr, prefix_len: u8) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use rtnetlink::new_connection; + + let (connection, handle, _) = new_connection() + .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; + tokio::spawn(connection); + + handle + .address() + .add(if_index, std::net::IpAddr::V4(addr), prefix_len) + .execute() + .await + .or_else(|e| { + let msg = e.to_string(); + if msg.contains("exists") + || msg.contains("EEXIST") + || msg.contains("File exists") + { + Ok(()) + } else { + Err(DriverError::Other(format!("address add: {e}"))) + } + }) + }) } - async fn release(&mut self) -> Result<(), DriverError> { - use futures::stream::TryStreamExt; - use wl_nl80211::Nl80211Disconnect; + fn link_up(&mut self) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use rtnetlink::{new_connection, LinkUnspec}; - // Best-effort disconnect; report only hard failures. - if let Ok((connection, handle, _)) = wl_nl80211::new_connection() { + let (connection, handle, _) = new_connection() + .map_err(|e| DriverError::Other(format!("rtnetlink connection: {e}")))?; tokio::spawn(connection); - let attrs = Nl80211Disconnect::new(self.if_index).build(); - let mut stream = handle.connection().disconnect(attrs).execute().await; - let _ = stream.try_next().await; - } + let msg = LinkUnspec::new_with_index(if_index).up().build(); + handle + .link() + .set(msg) + .execute() + .await + .map_err(|e| DriverError::Other(format!("link up: {e}"))) + }) + } - // Best-effort link down; the interface staying up is harmless (no - // default route, no address left after the kernel clears it on - // disconnect), but bringing it down is tidy. - use rtnetlink::{new_connection, LinkUnspec}; - if let Ok((connection, handle, _)) = new_connection() { - tokio::spawn(connection); - let msg = LinkUnspec::new_with_index(self.if_index).down().build(); - let _ = handle.link().set(msg).execute().await; - } - Ok(()) + fn release(&mut self) -> RadioFut<'_, ()> { + let if_index = self.if_index; + Box::pin(async move { + use futures::stream::TryStreamExt; + use wl_nl80211::Nl80211Disconnect; + + // Best-effort disconnect; report only hard failures. + if let Ok((connection, handle, _)) = wl_nl80211::new_connection() { + tokio::spawn(connection); + let attrs = Nl80211Disconnect::new(if_index).build(); + let mut stream = handle.connection().disconnect(attrs).execute().await; + let _ = stream.try_next().await; + } + + // Best-effort link down; the interface staying up is harmless (no + // default route, no address left after the kernel clears it on + // disconnect), but bringing it down is tidy. + use rtnetlink::{new_connection, LinkUnspec}; + if let Ok((connection, handle, _)) = new_connection() { + tokio::spawn(connection); + let msg = LinkUnspec::new_with_index(if_index).down().build(); + let _ = handle.link().set(msg).execute().await; + } + Ok(()) + }) } } #[cfg(test)] mod tests { use super::*; + use wl_nl80211::Nl80211BssInfo; #[test] fn resolve_rejects_empty_preferred() { @@ -346,4 +452,43 @@ mod tests { assert_eq!(resolved, first); assert!(is_wireless_interface(&first)); } + + #[test] + fn ssid_from_ies_reads_test_wifi() { + // IE: id=0, len=9, "Test-WIFI" + let ies = [ + 0u8, 9, b'T', b'e', b's', b't', b'-', b'W', b'I', b'F', b'I', 1, 8, 130, 132, 139, 150, + 12, 18, 24, 36, + ]; + assert_eq!(ssid_from_ies(&ies).as_deref(), Some("Test-WIFI")); + } + + #[test] + fn parse_bss_infos_from_fixture() { + let bss = vec![ + Nl80211BssInfo::Bssid([214, 178, 106, 168, 188, 177]), + Nl80211BssInfo::RawInformationElements(vec![ + 0, 9, 84, 101, 115, 116, 45, 87, 73, 70, 73, 1, 8, 130, 132, 139, 150, 12, 18, 24, + 36, + ]), + Nl80211BssInfo::SignalMbm(-3000), + ]; + let r = parse_bss_infos(&bss).expect("parsed"); + assert_eq!(r.ssid.as_deref(), Some("Test-WIFI")); + assert_eq!(r.bssid.as_deref(), Some("d6:b2:6a:a8:bc:b1")); + assert_eq!(r.rssi, Some(-30)); + } + + #[test] + fn parse_scan_attrs_finds_bss() { + let attrs = vec![wl_nl80211::Nl80211Attr::Bss(vec![ + Nl80211BssInfo::Bssid([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]), + Nl80211BssInfo::RawInformationElements(vec![0, 4, b't', b'e', b's', b't']), + Nl80211BssInfo::SignalMbm(-5500), + ])]; + let r = parse_scan_attrs(&attrs).expect("parsed"); + assert_eq!(r.ssid.as_deref(), Some("test")); + assert_eq!(r.bssid.as_deref(), Some("aa:bb:cc:dd:ee:ff")); + assert_eq!(r.rssi, Some(-55)); + } } diff --git a/crates/wp-proto/src/results.rs b/crates/wp-proto/src/results.rs index a51491d..2146563 100644 --- a/crates/wp-proto/src/results.rs +++ b/crates/wp-proto/src/results.rs @@ -5,8 +5,7 @@ // --------------------------------------------------------------------------- /// Successful response bodies. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ResultBody { /// `hello` response. Hello(HelloResult), @@ -16,6 +15,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 +68,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, @@ -88,7 +92,7 @@ pub struct CommissioningNetWire { /// Identity format constraints. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "type", rename_all = "camelCase")] pub enum IdentityFormatWire { /// Exactly `len` decimal digits. Digits { diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index 2fce687..4f5cd83 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -5,30 +5,27 @@ // --------------------------------------------------------------------------- /// Top-level request envelope. `type` selects the method; `params` carries -/// the arguments. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +/// the arguments as a **flat** object (not an internally tagged enum), matching +/// `docs/api.md` and the Go client. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Request { /// Method selector. - #[serde(rename = "type")] pub kind: RequestKind, /// Method parameters. - #[serde(skip_serializing_if = "Option::is_none")] pub params: Option, } /// Top-level response envelope. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +/// +/// `result` is the inner body (array, object, or omitted), not a tagged +/// `{ "scan": ... }` wrapper — Go unmarshals it into a concrete struct. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { /// Method selector mirrored from the request. - #[serde(rename = "type")] pub kind: RequestKind, /// Result on success. - #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, /// Error on failure. - #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -65,20 +62,25 @@ pub enum RequestKind { /// `program`: start a programming job, returns `job_id`. Program, /// `job.get`: snapshot a job's state. + #[serde(rename = "job.get")] JobGet, /// `job.watch`: stream job progress frames until terminal. + #[serde(rename = "job.watch")] JobWatch, /// `job.cancel`: request cancellation of a running job. + #[serde(rename = "job.cancel")] JobCancel, /// `identify`: blink the device LED so an operator can find it. Identify, /// `link.status`: report radio/link state. + #[serde(rename = "link.status")] LinkStatus, + /// `updateFirmware`: upload an app image over HTTP (Soft-AP or LAN). + UpdateFirmware, } /// Method parameters. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Params { /// Arguments for [`RequestKind::Program`]. Program(ProgramParams), @@ -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")] @@ -228,3 +281,257 @@ pub struct FunctionMappingWire { /// Driver-specific mapping value. pub value: u8, } + +// --------------------------------------------------------------------------- +// Flat JSON envelopes (docs/api.md + Go client) +// --------------------------------------------------------------------------- + +#[derive(serde::Serialize, serde::Deserialize)] +struct EnvelopeDto { + #[serde(rename = "type")] + kind: RequestKind, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn params_to_value(params: &Params) -> Result, serde_json::Error> { + match params { + Params::None => Ok(None), + Params::Program(p) => serde_json::to_value(p).map(Some), + Params::Probe(p) => serde_json::to_value(p).map(Some), + Params::Job(p) => serde_json::to_value(p).map(Some), + Params::Identify(p) => serde_json::to_value(p).map(Some), + Params::Scan(p) => serde_json::to_value(p).map(Some), + Params::UpdateFirmware(p) => serde_json::to_value(p).map(Some), + } +} + +fn params_from_value( + kind: RequestKind, + value: Option, +) -> Result, serde_json::Error> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + match kind { + RequestKind::Hello | RequestKind::LinkStatus => Ok(Some(Params::None)), + RequestKind::Scan => Ok(Some(Params::Scan(serde_json::from_value(value)?))), + RequestKind::Probe => Ok(Some(Params::Probe(serde_json::from_value(value)?))), + RequestKind::Program => Ok(Some(Params::Program(serde_json::from_value(value)?))), + RequestKind::JobGet | RequestKind::JobWatch | RequestKind::JobCancel => { + Ok(Some(Params::Job(serde_json::from_value(value)?))) + } + RequestKind::Identify => Ok(Some(Params::Identify(serde_json::from_value(value)?))), + RequestKind::UpdateFirmware => { + Ok(Some(Params::UpdateFirmware(serde_json::from_value(value)?))) + } + } +} + +fn result_to_value( + result: &crate::ResultBody, +) -> Result, serde_json::Error> { + use crate::ResultBody; + match result { + ResultBody::Hello(v) => serde_json::to_value(v).map(Some), + ResultBody::Scan(v) => serde_json::to_value(v).map(Some), + ResultBody::Probe(v) => serde_json::to_value(v).map(Some), + ResultBody::Program(v) | ResultBody::UpdateFirmware(v) => serde_json::to_value(v).map(Some), + ResultBody::Job(v) | ResultBody::JobCancelled(v) => serde_json::to_value(v).map(Some), + ResultBody::JobWatch(v) => serde_json::to_value(v).map(Some), + ResultBody::Identify => Ok(None), + ResultBody::LinkStatus(v) => serde_json::to_value(v).map(Some), + } +} + +fn result_from_value( + kind: RequestKind, + value: Option, +) -> Result, serde_json::Error> { + use crate::ResultBody; + let Some(value) = value else { + return Ok(match kind { + RequestKind::Identify => Some(ResultBody::Identify), + _ => None, + }); + }; + if value.is_null() { + return Ok(None); + } + Ok(Some(match kind { + RequestKind::Hello => ResultBody::Hello(serde_json::from_value(value)?), + RequestKind::Scan => ResultBody::Scan(serde_json::from_value(value)?), + RequestKind::Probe => ResultBody::Probe(serde_json::from_value(value)?), + RequestKind::Program => ResultBody::Program(serde_json::from_value(value)?), + RequestKind::UpdateFirmware => ResultBody::UpdateFirmware(serde_json::from_value(value)?), + RequestKind::JobGet => ResultBody::Job(serde_json::from_value(value)?), + RequestKind::JobWatch => ResultBody::JobWatch(serde_json::from_value(value)?), + RequestKind::JobCancel => ResultBody::JobCancelled(serde_json::from_value(value)?), + RequestKind::Identify => ResultBody::Identify, + RequestKind::LinkStatus => ResultBody::LinkStatus(serde_json::from_value(value)?), + })) +} + +impl serde::Serialize for Request { + fn serialize(&self, serializer: S) -> Result { + let params = match &self.params { + None => None, + Some(p) => params_to_value(p).map_err(serde::ser::Error::custom)?, + }; + EnvelopeDto { + kind: self.kind, + params, + result: None, + error: None, + } + .serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Request { + fn deserialize>(deserializer: D) -> Result { + let dto = EnvelopeDto::deserialize(deserializer)?; + Ok(Self { + kind: dto.kind, + params: params_from_value(dto.kind, dto.params).map_err(serde::de::Error::custom)?, + }) + } +} + +impl serde::Serialize for Response { + fn serialize(&self, serializer: S) -> Result { + let result = match &self.result { + None => None, + Some(r) => result_to_value(r).map_err(serde::ser::Error::custom)?, + }; + EnvelopeDto { + kind: self.kind, + params: None, + result, + error: self.error.clone(), + } + .serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Response { + fn deserialize>(deserializer: D) -> Result { + let dto = EnvelopeDto::deserialize(deserializer)?; + Ok(Self { + kind: dto.kind, + result: result_from_value(dto.kind, dto.result).map_err(serde::de::Error::custom)?, + error: dto.error, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{HelloResult, ProgramResult, ResultBody}; + + #[test] + fn update_firmware_params_are_flat() { + let req = Request { + kind: RequestKind::UpdateFirmware, + params: Some(Params::UpdateFirmware(UpdateFirmwareParams { + mode: ReachMode::Ap, + candidate: Some(CandidateRef { + driver: "longfred".into(), + key: "aa:bb".into(), + }), + path: "/tmp/x.app.bin".into(), + host: None, + port: None, + partition_table: None, + })), + }; + let json = serde_json::to_value(&req).expect("ser"); + assert_eq!(json["type"], "updateFirmware"); + assert_eq!(json["params"]["mode"], "ap"); + assert_eq!(json["params"]["path"], "/tmp/x.app.bin"); + assert_eq!(json["params"]["candidate"]["driver"], "longfred"); + assert!(json["params"].get("updateFirmware").is_none()); + + let back: Request = serde_json::from_value(json).expect("de"); + assert_eq!(back.kind, RequestKind::UpdateFirmware); + match back.params { + Some(Params::UpdateFirmware(p)) => assert_eq!(p.path, "/tmp/x.app.bin"), + other => panic!("expected UpdateFirmware, got {other:?}"), + } + } + + #[test] + fn go_dotted_method_names_round_trip() { + let req: Request = + serde_json::from_str(r#"{"type":"job.get","params":{"jobId":"job-1"}}"#).expect("de"); + assert_eq!(req.kind, RequestKind::JobGet); + assert_eq!(serde_json::to_value(&req).unwrap()["type"], "job.get"); + + let watch: Request = + serde_json::from_str(r#"{"type":"job.watch","params":{"jobId":"job-1"}}"#).unwrap(); + assert_eq!(watch.kind, RequestKind::JobWatch); + + let link: Request = serde_json::from_str(r#"{"type":"link.status"}"#).unwrap(); + assert_eq!(link.kind, RequestKind::LinkStatus); + } + + #[test] + fn scan_result_is_a_bare_array() { + let resp = Response { + kind: RequestKind::Scan, + result: Some(ResultBody::Scan(Vec::new())), + error: None, + }; + let json = serde_json::to_value(&resp).expect("ser"); + assert_eq!(json["type"], "scan"); + assert!(json["result"].is_array()); + assert!(json["result"].get("scan").is_none()); + } + + #[test] + fn hello_result_is_a_bare_object() { + let resp = Response { + kind: RequestKind::Hello, + result: Some(ResultBody::Hello(HelloResult { + version: "0.1.0".into(), + commit: None, + drivers: Vec::new(), + })), + error: None, + }; + let json = serde_json::to_value(&resp).expect("ser"); + assert_eq!(json["result"]["version"], "0.1.0"); + assert!(json["result"].get("hello").is_none()); + } + + #[test] + fn program_result_job_id_is_at_result_root() { + let resp = Response { + kind: RequestKind::Program, + result: Some(ResultBody::Program(ProgramResult { + job_id: "job-1".into(), + })), + error: None, + }; + let json = serde_json::to_value(&resp).expect("ser"); + assert_eq!(json["result"]["jobId"], "job-1"); + } + + #[test] + fn go_flat_scan_lan_params() { + let req: Request = + serde_json::from_str(r#"{"type":"scan","params":{"mode":"lan"}}"#).unwrap(); + match req.params { + Some(Params::Scan(p)) => assert_eq!(p.mode, ReachMode::Lan), + other => panic!("{other:?}"), + } + } +} diff --git a/docs/api.md b/docs/api.md index b6cb389..54aff56 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,12 @@ 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` sets a cancel flag, kills an in-flight +`espflash` child, and aborts the firmware HTTP POST. The job stays +non-terminal and the radio slot stays occupied until the worker finishes +tearing down; a second `program` in that window returns `busy`. ### `identify` @@ -125,22 +177,27 @@ throttle. For WiFred this maps to `GET /flashred.html?count=N`. ## Permissions -The socket is `0660` and peer credentials are checked via `SO_PEERCRED` -against an allowlist (default `bigfred`, `bigfred-wizard`). Override the -allowlist with `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login -names, replaces the default). Only those users may issue commands. - -The allowlist is only reachable if the socket has a group the peers belong -to: with `0660` and no group owner, a non-root client is refused with -`EACCES` at `connect(2)`, before the daemon can inspect its credentials. So -after binding, the daemon chowns the socket to the primary group of the first -allowlist entry — on BigFred OS that makes it `root:bigfred 0660`, which the -`bigfred` service can open. `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` selects a -different login name whose primary group should own it. When the user cannot -be resolved, or the daemon lacks the privilege to chown, it warns and leaves -the socket owner-only rather than refusing to start; this keeps a -non-privileged development run usable, and the warning is the signal that -peers will not get in. +Peer authentication is **off by default**. The socket is then `0666` and any +local process may connect — convenient for development (`make dev`). + +Enable authentication with `--require-auth` or +`WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`. Then the socket is `0660` and peer +credentials are checked via `SO_PEERCRED` against an allowlist (default +`bigfred`, `bigfred-wizard`). Override the allowlist with `--allow-users` +or `WIRELESS_PROGRAMMER_ALLOW_USERS` (comma-separated login names). Only +those users may issue commands. + +With auth on, the allowlist is only reachable if the socket has a group the +peers belong to: with `0660` and no group owner, a non-root client is refused +with `EACCES` at `connect(2)`, before the daemon can inspect its credentials. +So after binding, the daemon chowns the socket to the primary group of the +first allowlist entry — on BigFred OS that makes it `root:bigfred 0660`, which +the `bigfred` service can open. `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` +selects a different login name whose primary group should own it. When the +user cannot be resolved, or the daemon lacks the privilege to chown, it +warns and leaves the socket owner-only rather than refusing to start; this +keeps a non-privileged development run usable, and the warning is the signal +that peers will not get in. ## CLI @@ -151,9 +208,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 8743f41..2ffbdcc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,22 +9,40 @@ 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 + fake Standalone Soft-AP HTTP mock for one driver (no daemon) Options: --socket Override the daemon socket path (every subcommand) - -i, --interface Wireless interface for the daemon (e.g. wlan0) + -i, --interface Wireless interface for the daemon (e.g. wlan0); + use `fake` for in-process FakeRadio + Soft-AP mock + --require-auth Enforce SO_PEERCRED allowlist (daemon only; off by default) + --allow-users Comma-separated allowlist (implies --require-auth) -v, --verbose Verbose logging (daemon only) -h, --help Print help -V, --version Print version ``` +### `daemon --interface fake` + +Runs the full IPC daemon with `FakeRadio` (scan returns one WiFred and one +LongFred candidate) and an in-process Soft-AP HTTP mock on +`127.0.0.1:` (default port 8070; override with +`--fake-webserver-port` / `WIRELESS_PROGRAMMER_FAKE_WEB_PORT`). Peer auth is +forced off. Useful for developing `bigfred-wizard` without WiFi hardware. + +### `fake --driver wifred|longfred` + +Starts **only** the Soft-AP HTTP mock for the chosen driver (no radio, no +IPC). Default bind `127.0.0.1:8070`. + ## Socket resolution Client subcommands connect to the daemon socket, resolved in this order: @@ -34,18 +52,21 @@ Client subcommands connect to the daemon socket, resolved in this order: 3. `$DATA_DIR/run/wireless-programmer/wireless-programmer.sock`; 4. `/data/run/wireless-programmer/wireless-programmer.sock`. -The daemon creates the parent directory and binds the socket with mode -`0660`. Peers are checked via `SO_PEERCRED` against an allowlist (default -`bigfred`, `bigfred-wizard`); override it with -`WIRELESS_PROGRAMMER_ALLOW_USERS=alice,bob` (comma-separated login names). - -Because the mode is `0660`, the socket also needs a group owner, or a -non-root client is refused by the filesystem before `SO_PEERCRED` is ever -consulted. On startup the daemon chowns the socket to the primary group of -the first allowlist entry (so `bigfred` by default); set +The daemon creates the parent directory and binds the socket. **Peer +authentication is off by default**: the socket is `0666` and any local +process may connect. Enable auth with `--require-auth` or +`WIRELESS_PROGRAMMER_REQUIRE_AUTH=1`; then the socket is `0660` and peers +are checked via `SO_PEERCRED` against an allowlist (default `bigfred`, +`bigfred-wizard`, override with `--allow-users` / +`WIRELESS_PROGRAMMER_ALLOW_USERS`). + +When auth is on, the socket also needs a group owner, or a non-root client +is refused by the filesystem before `SO_PEERCRED` is ever consulted. On +startup the daemon chowns the socket to the primary group of the first +allowlist entry (so `bigfred` by default); set `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` to choose a different login name whose primary group should own it. If that user does not exist, or the -daemon is not privileged enough to chown, it logs a warning and leaves the +daemon is not privileged enough to chown, it warns and leaves the socket owner-only — useful on a development machine, fatal for peers. ## Wireless interface @@ -67,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 @@ -76,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 @@ -96,6 +127,46 @@ 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` requests cancellation: it +kills the `espflash` child and aborts an in-flight firmware POST, but +the radio slot stays busy until the worker reaches a terminal state. + ## Programming workflow `program` starts a job, then opens a `job.watch` stream and prints progress @@ -219,10 +290,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 @@ -256,8 +330,9 @@ remain machine-parseable. |----------|---------| | `BIGFRED_DATA_DIR` | Data root (default `/data`); socket is `/run/wireless-programmer/wireless-programmer.sock` | | `DATA_DIR` | Fallback data root | -| `WIRELESS_PROGRAMMER_ALLOW_USERS` | Comma-separated peer allowlist (daemon only) | -| `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` | Login name whose primary group owns the socket (daemon only; defaults to the first allowlist entry) | +| `WIRELESS_PROGRAMMER_REQUIRE_AUTH` | Enable peer auth (`1`/`true`/`yes`/`on`); default off | +| `WIRELESS_PROGRAMMER_ALLOW_USERS` | Comma-separated peer allowlist (used when auth is on; default `bigfred,bigfred-wizard`) | +| `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` | Login name whose primary group owns the socket (daemon only; defaults to the first allowlist entry when auth is on) | | `WIRELESS_PROGRAMMER_INTERFACE` | Wireless interface name for the daemon (e.g. `wlan0`); overridden by `--interface` | | `WIRELESS_PROGRAMMER_GIT_COMMIT` | Git commit baked into the `hello` response (build-time) | | `WIRELESS_PROGRAMMER_BUILD_TIME` | UTC build timestamp baked into version metadata (build-time, optional) | 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 739493c..f3937e8 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -46,12 +46,13 @@ c := &client.Client{ `Socket` defaults to `DefaultSocket` when empty; `Timeout` defaults to 10s when zero. Override `Dial` in tests to point at an in-memory listener. -The socket is mode `0660`, owned by the primary group of the daemon's first -allowlisted user (`bigfred` by default), so the calling process must be that -user or in that group. A `permission denied` from `Dial` means the caller is -outside the group — the daemon's `SO_PEERCRED` allowlist never gets a chance to -run, so widening it does not help. See the permissions section of -[`api.md`](api.md). +The socket is mode `0666` when peer auth is off (the default). With +`--require-auth` it is `0660`, owned by the primary group of the daemon's +first allowlisted user (`bigfred` by default), so the calling process must +be that user or in that group. A `permission denied` from `Dial` means the +caller is outside the group — the daemon's `SO_PEERCRED` allowlist never +gets a chance to run, so widening it does not help. See the permissions +section of [`api.md`](api.md). ## Methods @@ -62,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` | @@ -128,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