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