From 824c24d78d98ad57f7fe8bef1f0773e14fd4321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:45:56 +0200 Subject: [PATCH 1/3] feat(rb23xx): upload firmware over WPA2 Soft-AP RailBOX reboots after POST /upload, so treat RST-after-write as success and join the decoder AP with WPA2-PSK instead of open association. Co-authored-by: Cursor --- CHANGELOG.md | 8 + Cargo.lock | 99 +++++++++ README.md | 8 +- crates/wireless-programmer/src/cli/fake.rs | 5 +- crates/wireless-programmer/src/cli/mod.rs | 2 +- crates/wireless-programmer/src/drivers.rs | 41 +++- crates/wireless-programmer/src/jobs.rs | 3 +- crates/wireless-programmer/src/runtime.rs | 144 +++++++++--- .../tests/fake_mode_test.rs | 134 +++++++++++- crates/wp-drivers/Cargo.toml | 2 +- crates/wp-drivers/src/lib.rs | 2 + crates/wp-drivers/src/rb23xx/constants.rs | 39 ++++ crates/wp-drivers/src/rb23xx/discovery.rs | 26 +++ crates/wp-drivers/src/rb23xx/mod.rs | 173 +++++++++++++++ crates/wp-drivers/tests/rb23xx_discovery.rs | 56 +++++ crates/wp-drivers/tests/rb23xx_write.rs | 130 +++++++++++ crates/wp-fake/src/composite.rs | 4 +- crates/wp-fake/src/device.rs | 17 ++ crates/wp-fake/src/lib.rs | 6 +- crates/wp-fake/src/longfred.rs | 2 + crates/wp-fake/src/radio.rs | 15 ++ crates/wp-fake/src/rb23xx.rs | 52 +++++ crates/wp-fake/src/server.rs | 4 + crates/wp-link/Cargo.toml | 2 + crates/wp-link/src/http.rs | 104 ++++++++- crates/wp-link/src/lib.rs | 2 +- crates/wp-link/src/radio.rs | 206 ++++++++++++------ docs/api.md | 35 ++- docs/cli.md | 41 ++-- docs/drivers/rb23xx.md | 76 +++++++ 30 files changed, 1297 insertions(+), 141 deletions(-) create mode 100644 crates/wp-drivers/src/rb23xx/constants.rs create mode 100644 crates/wp-drivers/src/rb23xx/discovery.rs create mode 100644 crates/wp-drivers/src/rb23xx/mod.rs create mode 100644 crates/wp-drivers/tests/rb23xx_discovery.rs create mode 100644 crates/wp-drivers/tests/rb23xx_write.rs create mode 100644 crates/wp-fake/src/rb23xx.rs create mode 100644 docs/drivers/rb23xx.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ffb3b5..72b1939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **RB23xx driver** — Soft-AP discovery (`RB2300_*` / `RB2310_*` / `RB2302_*`), + WPA2-PSK join (factory password, never logged), and firmware `.bin` upload + via `POST /upload?p=/{filename}` matching the `rb` CLI. Max 5 MiB, 120 s + deadline, no retries. A TCP reset after a full write is success (decoder + reboot). Soft-AP only. + ### Changed - LongFred Soft-AP commissioning addresses are now `192.168.4.1` / source diff --git a/Cargo.lock b/Cargo.lock index aa0a5c9..aeed182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.12.1" @@ -149,6 +158,36 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -267,6 +306,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "genetlink" version = "0.2.7" @@ -287,6 +336,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "humantime" version = "2.4.0" @@ -609,6 +667,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -751,6 +819,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -804,6 +883,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -984,6 +1069,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1020,6 +1111,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1142,9 +1239,11 @@ dependencies = [ "futures", "libc", "log", + "pbkdf2", "rtnetlink", "serde", "serde_json", + "sha1", "socket2", "thiserror 2.0.20", "tokio", diff --git a/README.md b/README.md index 814a2c9..0e3bd5f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ 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/, longfred/ — Soft-AP programming drivers + wp-drivers/ wifred/, longfred/, rb23xx/ — 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 @@ -45,10 +45,12 @@ wireless-programmer daemon --interface fake --verbose # 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 +wireless-programmer fake --driver rb23xx ``` -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`. +With `--interface fake`, scan always returns one WiFred, one LongFred, and +one RB23xx candidate; programming talks to an in-process HTTP mock on +`127.0.0.1`. ## Memory profile diff --git a/crates/wireless-programmer/src/cli/fake.rs b/crates/wireless-programmer/src/cli/fake.rs index d31a359..5fdc1c2 100644 --- a/crates/wireless-programmer/src/cli/fake.rs +++ b/crates/wireless-programmer/src/cli/fake.rs @@ -11,7 +11,7 @@ use super::{init_tracing, LogLevel}; /// `fake` arguments — runs only the Soft-AP HTTP mock (no daemon / radio / IPC). #[derive(Debug, Parser)] pub struct FakeArgs { - /// Driver to emulate (`wifred` | `longfred`). + /// Driver to emulate (`wifred` | `longfred` | `rb23xx`). #[arg(long)] pub driver: String, @@ -38,8 +38,9 @@ pub fn run_fake(args: FakeArgs) -> ExitCode { 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())), + "rb23xx" => Arc::new(tokio::sync::Mutex::new(wp_fake::Rb23xxFake::new())), other => { - tracing::error!("unknown driver {other:?}; expected wifred or longfred"); + tracing::error!("unknown driver {other:?}; expected wifred, longfred, or rb23xx"); return ExitCode::FAILURE; } }; diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index f05cac5..adfffc3 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -193,7 +193,7 @@ pub struct UpdateFirmwareArgs { /// 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). + /// Path to a firmware image (LongFred `.app.bin` / ELF, or RB23xx `.bin`). #[arg(long)] pub file: PathBuf, /// Do not stream job progress after starting the job. diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index 907fb88..495760e 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -9,7 +9,7 @@ use wp_core::{ CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, Observation, Outcome, ProgramRequest, ProgressSink, Transport, }; -use wp_drivers::{FredDriver, LongFredDriver, WiFredDriver}; +use wp_drivers::{FredDriver, LongFredDriver, Rb23xxDriver, WiFredDriver}; /// All registered drivers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,6 +20,8 @@ pub enum Driver { LongFred, /// Digitrax FRED via Z21 LAN LocoNet dispatch. Fred, + /// RailBOX RB23xx decoder Soft-AP firmware upload. + Rb23xx, } impl Driver { @@ -29,6 +31,7 @@ impl Driver { Driver::WiFred => "wifred", Driver::LongFred => "longfred", Driver::Fred => "fred", + Driver::Rb23xx => "rb23xx", } } @@ -38,6 +41,7 @@ impl Driver { Driver::WiFred => "NewHeiko WiFred", Driver::LongFred => "LongFred", Driver::Fred => "Digitrax FRED", + Driver::Rb23xx => "RailBOX RB23xx", } } @@ -57,6 +61,18 @@ impl Driver { source: Ipv4Addr::UNSPECIFIED, prefix: 0, }, + Driver::Rb23xx => wp_drivers::rb23xx::commissioning_net(), + } + } + + /// WPA2-PSK for the device Soft-AP, when it is not open. + /// + /// The passphrase is never logged and is not advertised on `hello`. + #[must_use] + pub fn softap_psk(self) -> Option<&'static str> { + match self { + Driver::Rb23xx => Some(wp_drivers::rb23xx::SOFTAP_PSK), + Driver::WiFred | Driver::LongFred | Driver::Fred => None, } } @@ -66,6 +82,7 @@ impl Driver { "wifred" => Some(Driver::WiFred), "longfred" => Some(Driver::LongFred), "fred" => Some(Driver::Fred), + "rb23xx" => Some(Driver::Rb23xx), _ => None, } } @@ -77,6 +94,7 @@ pub struct DriverRegistry { wifred: WiFredDriver, longfred: LongFredDriver, fred: FredDriver, + rb23xx: Rb23xxDriver, } impl DriverRegistry { @@ -86,6 +104,7 @@ impl DriverRegistry { wifred: WiFredDriver::new(), longfred: LongFredDriver::new(), fred: FredDriver::new(), + rb23xx: Rb23xxDriver::new(), } } @@ -95,6 +114,7 @@ impl DriverRegistry { (Driver::WiFred, self.wifred.capabilities()), (Driver::LongFred, self.longfred.capabilities()), (Driver::Fred, self.fred.capabilities()), + (Driver::Rb23xx, self.rb23xx.capabilities()), ] } @@ -120,6 +140,7 @@ impl DriverRegistry { self.longfred .identify(obs) .or_else(|| self.wifred.identify(obs)) + .or_else(|| self.rb23xx.identify(obs)) } /// Validate a request against the driver's capabilities. @@ -132,6 +153,7 @@ impl DriverRegistry { Driver::WiFred => self.wifred.validate(req), Driver::LongFred => self.longfred.validate(req), Driver::Fred => self.fred.validate(req), + Driver::Rb23xx => self.rb23xx.validate(req), } } @@ -145,6 +167,7 @@ impl DriverRegistry { Driver::WiFred => self.wifred.probe(transport).await, Driver::LongFred => self.longfred.probe(transport).await, Driver::Fred => self.fred.probe(transport).await, + Driver::Rb23xx => self.rb23xx.probe(transport).await, } } @@ -160,6 +183,7 @@ impl DriverRegistry { Driver::WiFred => self.wifred.program(transport, req, progress).await, Driver::LongFred => self.longfred.program(transport, req, progress).await, Driver::Fred => self.fred.program(transport, req, progress).await, + Driver::Rb23xx => self.rb23xx.program(transport, req, progress).await, } } @@ -169,6 +193,7 @@ impl DriverRegistry { Driver::WiFred => self.wifred.capabilities().supports_firmware_update, Driver::LongFred => self.longfred.capabilities().supports_firmware_update, Driver::Fred => false, + Driver::Rb23xx => self.rb23xx.capabilities().supports_firmware_update, } } @@ -178,6 +203,7 @@ impl DriverRegistry { driver: Driver, transport: Transport<'_>, image: &[u8], + filename: &str, progress: &mut dyn ProgressSink, ) -> Result { match driver { @@ -192,6 +218,11 @@ impl DriverRegistry { Driver::Fred => Err(DriverError::Other( "firmware update is not supported".into(), )), + Driver::Rb23xx => { + self.rb23xx + .update_firmware(transport, image, filename, progress) + .await + } } } @@ -223,6 +254,9 @@ impl DriverRegistry { Driver::Fred => Err(DriverError::Other( "FRED has no LED identify over Z21 LAN".into(), )), + Driver::Rb23xx => Err(DriverError::Other( + "RB23xx has no LED identify over Soft-AP".into(), + )), } } @@ -235,6 +269,11 @@ impl DriverRegistry { pub fn longfred(&self) -> &LongFredDriver { &self.longfred } + + /// Borrow the RB23xx driver. + pub fn rb23xx(&self) -> &Rb23xxDriver { + &self.rb23xx + } } impl Default for DriverRegistry { diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index e5eca86..bf7c8f7 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -14,10 +14,11 @@ use wp_proto::{ProgramRequestWire, ReachMode}; /// Overall job deadline. pub const JOB_DEADLINE: Duration = Duration::from_secs(120); -/// Firmware POST deadline (matches LongFred HTTP timeout). +/// Firmware POST deadline (LongFred HTTP OTA and RB23xx `.bin` upload). pub const FIRMWARE_DEADLINE: Duration = Duration::from_secs(120); /// LongFred OTA slot (`ota_0` / `ota_1`) — cap for images loaded into RAM. +/// RB23xx uses [`wp_drivers::rb23xx::MAX_FIRMWARE_BYTES`] (5 MiB) instead. pub const MAX_FIRMWARE_BYTES: u64 = 0x3C_0000; /// Keep at most this many terminal jobs in the registry. diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index b3264ab..c9fbebf 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -179,7 +179,7 @@ impl Runtime { if out.is_empty() && !results.is_empty() { tracing::info!( raw = results.len(), - "scan finished: radio saw APs, none matched longfred_prog / wiFred-config" + "scan finished: radio saw APs, none matched longfred_prog / wiFred-config / RB23xx" ); } Ok(out) @@ -422,7 +422,9 @@ impl Runtime { 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 { + if let Err(e) = + radio_join(r.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await + { tracing::warn!( ssid = %candidate.ssid, error = %e, @@ -470,7 +472,7 @@ impl Runtime { 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?; + radio_join(r.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await?; r.set_address(net.source, net.prefix).await?; r.link_up().await?; r.prepare_softap(net.source, net.host).await?; @@ -500,6 +502,18 @@ fn observation_from_scan(s: &ScanResult) -> Observation { } } +async fn radio_join( + radio: &mut dyn Radio, + ssid: &str, + bssid: Option<[u8; 6]>, + psk: Option<&str>, +) -> Result<(), wp_core::DriverError> { + match psk { + Some(psk) => radio.connect_psk(ssid, bssid, psk).await, + None => radio.connect_open(ssid, bssid).await, + } +} + fn parse_bssid(s: Option<&str>) -> Option<[u8; 6]> { let s = s?; let parts: Vec<&str> = s.split(':').collect(); @@ -941,7 +955,7 @@ async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { bssid = ?candidate.bssid, "connecting to Soft-AP" ); - if let Err(e) = radio.connect_open(&candidate.ssid, bssid).await { + if let Err(e) = radio_join(radio.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await { tracing::warn!( job_id = %id.0, ssid = %candidate.ssid, @@ -1114,6 +1128,43 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob return; }; + if driver == Driver::Rb23xx && job.mode != ReachMode::Ap { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("RB23xx firmware update is Soft-AP only"), + ); + return; + } + + let max_bytes = match driver { + Driver::Rb23xx => wp_drivers::rb23xx::MAX_FIRMWARE_BYTES, + _ => crate::jobs::MAX_FIRMWARE_BYTES, + }; + if job.mode != ReachMode::Usb { + if let Ok(meta) = std::fs::metadata(&job.path) { + if meta.len() > max_bytes { + let detail = if driver == Driver::Rb23xx { + "firmware image exceeds 5 MiB" + } else { + "firmware image exceeds LongFred OTA slot (3.75 MiB)" + }; + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(detail)); + return; + } + } + } + + let filename = job + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("firmware.bin") + .to_string(); + let image = if job.mode == ReachMode::Usb { Vec::new() } else { @@ -1143,33 +1194,51 @@ 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; + match driver { + Driver::Rb23xx => { + if image.len() as u64 > wp_drivers::rb23xx::MAX_FIRMWARE_BYTES { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware image exceeds 5 MiB"), + ); + return; + } } - Err(e) => { - rt.jobs - .transition(&id, JobState::Failed, None, None, Some(&e)); - return; + _ => { + 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; + } + } } } } @@ -1259,6 +1328,7 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob &mut sink, driver, image, + filename.clone(), &host, 80, None, @@ -1297,7 +1367,9 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob 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 { + if let Err(e) = + radio_join(radio.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await + { rt.jobs.transition( &id, JobState::Failed, @@ -1352,6 +1424,7 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob &mut sink, driver, image, + filename, &net.host.to_string(), net.port, Some(SocketAddr::from((net.source, 0))), @@ -1377,6 +1450,7 @@ async fn firmware_http_with_heartbeats( sink: &mut JobProgressSink<'_>, driver: Driver, image: Vec, + filename: String, host: &str, port: u16, source: Option, @@ -1385,18 +1459,20 @@ async fn firmware_http_with_heartbeats( ) -> Result { let tokio_h = rt.handle(); let registry = Arc::clone(&rt.registry); + let reset_ok = driver == Driver::Rb23xx; let client = make_firmware_http_client( host, port, source, device.as_deref(), Some(Arc::clone(&cancel)), + reset_ok, ); 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)) + tokio_h.block_on(registry.update_firmware(driver, transport, &image, &filename, &mut nop)) }) .await } @@ -1477,10 +1553,12 @@ fn make_firmware_http_client( source: Option, device: Option<&str>, cancel: Option>, + success_on_reset_after_write: bool, ) -> BoundedHttpClient { let mut c = BoundedHttpClient::new(host, port) .with_deadline(crate::jobs::FIRMWARE_DEADLINE) - .with_retries(0); + .with_retries(0) + .with_success_on_reset_after_write(success_on_reset_after_write); if let Some(src) = source { c = c.with_source(src); } diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs index abbeee4..1760a77 100644 --- a/crates/wireless-programmer/tests/fake_mode_test.rs +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -5,11 +5,13 @@ use std::sync::Arc; use std::time::Duration; use wp_fake::{CompositeFakeDevice, FakeRadio, FakeZ21, FakeZ21Mode}; -use wp_proto::{ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire}; +use wp_proto::{ + ProgramRequestWire, ReachMode, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire, +}; use wireless_programmer::config::Config; use wireless_programmer::drivers::{Driver, DriverRegistry}; -use wireless_programmer::jobs::{JobRegistry, JobState}; +use wireless_programmer::jobs::{FirmwareJob, JobRegistry, JobState}; use wireless_programmer::runtime::Runtime; fn temp_socket() -> std::path::PathBuf { @@ -144,9 +146,10 @@ fn wait_terminal(rt: &Runtime, id: &wireless_programmer::jobs::JobId) -> JobStat fn fake_scan_returns_one_candidate_per_driver() { let rt = setup_runtime(); let found = rt.scan().expect("scan"); - assert_eq!(found.len(), 2); + assert_eq!(found.len(), 3); assert!(found.iter().any(|c| c.driver == "wifred")); assert!(found.iter().any(|c| c.driver == "longfred")); + assert!(found.iter().any(|c| c.driver == "rb23xx")); } #[test] @@ -275,3 +278,128 @@ fn fake_program_fred_no_ack_reaches_done() { Some("noAck") ); } + +#[test] +fn fake_probe_rb23xx() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "rb23xx").expect("rb23xx"); + let info = rt.probe(Driver::Rb23xx, &c.key).expect("probe"); + assert_eq!(info.get("ok").and_then(|v| v.as_bool()), Some(true)); +} + +#[test] +fn fake_update_firmware_rb23xx_reaches_done() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "rb23xx").expect("rb23xx"); + let path = std::env::temp_dir().join(format!( + "wp-rb23xx-fw-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"rb-firmware-bytes").unwrap(); + let id = rt + .submit_firmware( + Driver::Rb23xx, + &c.key, + FirmwareJob { + mode: ReachMode::Ap, + path: path.clone(), + host: None, + port: None, + partition_table: None, + }, + ) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let _ = std::fs::remove_file(&path); + assert_eq!( + state, + JobState::Done, + "detail={:?}", + rt.jobs().snapshot(&id) + ); +} + +#[test] +fn fake_update_firmware_rb23xx_rejects_oversize() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "rb23xx").expect("rb23xx"); + let path = std::env::temp_dir().join(format!( + "wp-rb23xx-big-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let oversize = usize::try_from(wp_drivers::rb23xx::MAX_FIRMWARE_BYTES).unwrap() + 1; + std::fs::write(&path, vec![0u8; oversize]).unwrap(); + let id = rt + .submit_firmware( + Driver::Rb23xx, + &c.key, + FirmwareJob { + mode: ReachMode::Ap, + path: path.clone(), + host: None, + port: None, + partition_table: None, + }, + ) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + let _ = std::fs::remove_file(&path); + assert_eq!(state, JobState::Failed, "detail={snap:?}"); + assert!( + snap.as_ref() + .and_then(|s| s.detail.as_deref()) + .is_some_and(|d| d.contains("5 MiB")), + "{snap:?}" + ); +} + +#[test] +fn fake_update_firmware_rb23xx_rejects_lan() { + let rt = setup_runtime(); + let found = rt.scan().expect("scan"); + let c = found.iter().find(|c| c.driver == "rb23xx").expect("rb23xx"); + let path = std::env::temp_dir().join(format!( + "wp-rb23xx-lan-{}-{}.bin", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"rb-firmware-bytes").unwrap(); + let id = rt + .submit_firmware( + Driver::Rb23xx, + &c.key, + FirmwareJob { + mode: ReachMode::Lan, + path: path.clone(), + host: Some("192.168.4.1".into()), + port: None, + partition_table: None, + }, + ) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + let _ = std::fs::remove_file(&path); + assert_eq!(state, JobState::Failed, "detail={snap:?}"); + assert!( + snap.as_ref() + .and_then(|s| s.detail.as_deref()) + .is_some_and(|d| d.contains("Soft-AP")), + "{snap:?}" + ); +} diff --git a/crates/wp-drivers/Cargo.toml b/crates/wp-drivers/Cargo.toml index 8f668cb..d9a5fb7 100644 --- a/crates/wp-drivers/Cargo.toml +++ b/crates/wp-drivers/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true repository.workspace = true -description = "Device driver implementations for wireless-programmer (WiFred, LongFred, FRED)" +description = "Device driver implementations for wireless-programmer (WiFred, LongFred, FRED, RB23xx)" [lib] name = "wp_drivers" diff --git a/crates/wp-drivers/src/lib.rs b/crates/wp-drivers/src/lib.rs index e1ca0de..21dff11 100644 --- a/crates/wp-drivers/src/lib.rs +++ b/crates/wp-drivers/src/lib.rs @@ -4,8 +4,10 @@ pub mod fred; pub mod longfred; +pub mod rb23xx; pub mod wifred; pub use fred::FredDriver; pub use longfred::LongFredDriver; +pub use rb23xx::Rb23xxDriver; pub use wifred::{Direction, FunctionInfo, WiFredDriver}; diff --git a/crates/wp-drivers/src/rb23xx/constants.rs b/crates/wp-drivers/src/rb23xx/constants.rs new file mode 100644 index 0000000..54b9d5d --- /dev/null +++ b/crates/wp-drivers/src/rb23xx/constants.rs @@ -0,0 +1,39 @@ +//! RailBOX RB23xx Soft-AP constants. + +#![allow(dead_code)] + +use std::net::Ipv4Addr; + +/// SSID prefixes of the decoder Soft-AP (`RB2300_XXXXX`, `RB2310_…`, `RB2302_…`). +pub const WIFI_CONFIG_SSID_PREFIXES: &[&str] = &["RB2300_", "RB2310_", "RB2302_"]; + +/// Factory Soft-AP passphrase. Never logged. +pub const SOFTAP_PSK: &str = "000000000"; + +/// Config AP HTTP port. +pub const CONFIG_AP_PORT: u16 = 80; + +/// Config AP address (ESP-IDF Soft-AP default). +pub const CONFIG_HOST: Ipv4Addr = Ipv4Addr::new(192, 168, 4, 1); + +/// Source address the daemon assigns to the wireless interface. +pub const CONFIG_SOURCE: Ipv4Addr = Ipv4Addr::new(192, 168, 4, 2); + +/// On-link prefix length for the config AP subnet. +pub const CONFIG_PREFIX_LEN: u8 = 24; + +/// Firmware POST cap (5 MiB). Distinct from the LongFred OTA slot. +pub const MAX_FIRMWARE_BYTES: u64 = 5 * 1024 * 1024; + +/// Root listing used to confirm the file browser is up. +pub const LIST_PATH: &str = "/?p=/"; + +/// Upload content type. The decoder expects this header with a raw body +/// (no multipart boundary), matching the `rb` CLI. +pub const UPLOAD_CONTENT_TYPE: &str = "multipart/form-data"; + +/// RB23xx does not store a throttle roster over HTTP. +pub const MAX_ROSTER_SLOTS: u8 = 0; + +/// RB23xx does not write function maps over HTTP. +pub const MAX_FUNCTION: u8 = 0; diff --git a/crates/wp-drivers/src/rb23xx/discovery.rs b/crates/wp-drivers/src/rb23xx/discovery.rs new file mode 100644 index 0000000..dddcc18 --- /dev/null +++ b/crates/wp-drivers/src/rb23xx/discovery.rs @@ -0,0 +1,26 @@ +//! RailBOX RB23xx scan/discovery. + +use wp_core::{DeviceCandidate, Observation}; + +use crate::rb23xx::constants::WIFI_CONFIG_SSID_PREFIXES; + +/// Claim a raw scan observation as an RB23xx candidate. +/// +/// The decoder Soft-AP SSID is `RB2300_XXXXX` (also `RB2310_` / `RB2302_`). +/// Match on those prefixes; the BSSID is the stable candidate key. +pub fn identify(obs: &Observation) -> Option { + let ssid = obs.ssid.as_ref()?; + if !WIFI_CONFIG_SSID_PREFIXES + .iter() + .any(|prefix| ssid.starts_with(prefix)) + { + return None; + } + let key = obs.bssid.clone().unwrap_or_else(|| ssid.clone()); + Some(DeviceCandidate { + driver: "rb23xx".into(), + key, + label: ssid.clone(), + rssi: obs.rssi, + }) +} diff --git a/crates/wp-drivers/src/rb23xx/mod.rs b/crates/wp-drivers/src/rb23xx/mod.rs new file mode 100644 index 0000000..0d2455b --- /dev/null +++ b/crates/wp-drivers/src/rb23xx/mod.rs @@ -0,0 +1,173 @@ +//! RailBOX RB23xx Soft-AP firmware driver. +//! +//! Implements [`wp_core::DeviceDriver`] for RailBOX RB2300 / RB2310 sound +//! decoders. With F28 on, the decoder raises a WPA2-PSK Soft-AP named +//! `RB2300_XXXXX` (password [`constants::SOFTAP_PSK`]) at `192.168.4.1/24` +//! and serves a file-browser: +//! +//! - `GET /?p=/` +//! - `POST /upload?p=/{filename}` (`Content-Type: multipart/form-data`, raw body) +//! +//! Firmware `.bin` is uploaded to the decoder root (not a sound-pack slot). +//! The decoder reboots when the image is stored, so a TCP RST after a full +//! write is success. Roster programming is not supported over this HTTP API. + +mod constants; +mod discovery; + +use wp_core::{ + CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, DriverId, + IdentityFormat, Observation, Outcome, ProgressSink, ScanFilters, Transport, +}; +use wp_link::percent_encode; + +pub use constants::{ + CONFIG_AP_PORT, CONFIG_HOST, CONFIG_PREFIX_LEN, CONFIG_SOURCE, MAX_FIRMWARE_BYTES, + MAX_FUNCTION, MAX_ROSTER_SLOTS, SOFTAP_PSK, UPLOAD_CONTENT_TYPE, WIFI_CONFIG_SSID_PREFIXES, +}; +pub use discovery::identify; + +use constants::LIST_PATH; + +/// The RB23xx driver. +#[derive(Debug, Default)] +pub struct Rb23xxDriver; + +impl Rb23xxDriver { + /// Construct a new driver instance. + pub const fn new() -> Self { + Self + } +} + +const ID: DriverId = DriverId::new("rb23xx"); + +impl DeviceDriver for Rb23xxDriver { + fn id(&self) -> DriverId { + ID + } + + fn name(&self) -> &'static str { + "RailBOX RB23xx" + } + + fn capabilities(&self) -> DriverCapabilities { + DriverCapabilities { + max_roster_slots: MAX_ROSTER_SLOTS, + max_function_index: MAX_FUNCTION, + identity_format: IdentityFormat::Any, + supports_throttle_server: false, + commissioning: wp_core::CommissioningKind::SoftAp, + supports_firmware_update: true, + commissioning_net: Some(CommissioningNet { + host: CONFIG_HOST, + port: CONFIG_AP_PORT, + source: CONFIG_SOURCE, + prefix: CONFIG_PREFIX_LEN, + }), + } + } + + fn scan_filters(&self) -> ScanFilters { + ScanFilters { + ssid_prefixes: WIFI_CONFIG_SSID_PREFIXES + .iter() + .map(|s| (*s).to_string()) + .collect(), + } + } + + fn identify(&self, obs: &Observation) -> Option { + discovery::identify(obs) + } + + fn validate(&self, _req: &wp_core::ProgramRequest<'_>) -> Result<(), wp_core::ValidationError> { + Ok(()) + } + + async fn probe(&self, transport: Transport<'_>) -> Result { + let client = http_client(transport)?; + let body = client + .get(LIST_PATH) + .map_err(|e| DriverError::Http(e.to_string()))?; + Ok(serde_json::json!({ + "ok": true, + "bytes": body.len(), + })) + } + + async fn program( + &self, + _transport: Transport<'_>, + _req: &wp_core::ProgramRequest<'_>, + _progress: &mut dyn ProgressSink, + ) -> Result { + Err(DriverError::Other( + "rb23xx does not support program; use updateFirmware to upload a .bin".into(), + )) + } +} + +impl Rb23xxDriver { + /// POST a firmware `.bin` to the decoder file browser. + /// + /// `filename` is the basename placed in `/?p=/{filename}`. The HTTP client + /// should treat a TCP reset after a full write as success (decoder reboot). + /// + /// # Errors + /// + /// Returns [`DriverError`] when the HTTP POST fails for a reason other + /// than a post-write connection reset. + pub async fn update_firmware( + &self, + transport: Transport<'_>, + image: &[u8], + filename: &str, + progress: &mut dyn ProgressSink, + ) -> Result { + let client = http_client(transport)?; + let name = std::path::Path::new(filename) + .file_name() + .and_then(|n| n.to_str()) + .filter(|n| !n.is_empty()) + .unwrap_or("firmware.bin"); + let path = format!("/upload?p=/{}", percent_encode(name)); + progress.step("write"); + progress.detail(&format!("{} bytes", image.len())); + client + .request("POST", &path, Some((UPLOAD_CONTENT_TYPE, image))) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::Interrupted { + DriverError::Cancelled + } else { + DriverError::Http(e.to_string()) + } + })?; + progress.step("restart"); + Ok(Outcome { + restarted: true, + mismatches: Vec::new(), + }) + } +} + +/// Extract the HTTP client from a [`Transport`]. +fn http_client(transport: Transport<'_>) -> Result<&mut dyn wp_core::HttpClient, DriverError> { + match transport { + Transport::Http(c) => Ok(c), + Transport::Bytes(_) => Err(DriverError::Other( + "rb23xx driver requires an HTTP transport".into(), + )), + } +} + +/// Soft-AP addressing helpers for callers that prefer constants over capabilities. +#[must_use] +pub fn commissioning_net() -> CommissioningNet { + CommissioningNet { + host: CONFIG_HOST, + port: CONFIG_AP_PORT, + source: CONFIG_SOURCE, + prefix: CONFIG_PREFIX_LEN, + } +} diff --git a/crates/wp-drivers/tests/rb23xx_discovery.rs b/crates/wp-drivers/tests/rb23xx_discovery.rs new file mode 100644 index 0000000..90f8ffa --- /dev/null +++ b/crates/wp-drivers/tests/rb23xx_discovery.rs @@ -0,0 +1,56 @@ +//! RB23xx Soft-AP discovery / identify tests. + +use wp_core::Observation; +use wp_drivers::rb23xx::identify; + +#[test] +fn identify_matches_rb2300_prefix() { + let obs = Observation { + ssid: Some("RB2300_A1B2C".into()), + bssid: Some("aa:bb:cc:dd:ee:ff".into()), + rssi: Some(-42), + extra: serde_json::Value::Null, + }; + let c = identify(&obs).expect("claimed"); + assert_eq!(c.driver, "rb23xx"); + assert_eq!(c.key, "aa:bb:cc:dd:ee:ff"); + assert_eq!(c.label, "RB2300_A1B2C"); + assert_eq!(c.rssi, Some(-42)); +} + +#[test] +fn identify_matches_rb2310_and_rb2302() { + for ssid in ["RB2310_FFFFF", "RB2302_12345"] { + let obs = Observation { + ssid: Some(ssid.into()), + bssid: None, + rssi: None, + extra: serde_json::Value::Null, + }; + let c = identify(&obs).expect(ssid); + assert_eq!(c.driver, "rb23xx"); + assert_eq!(c.key, ssid); + } +} + +#[test] +fn identify_rejects_unrelated_ssid() { + let obs = Observation { + ssid: Some("longfred_prog_a1b2c3".into()), + bssid: None, + rssi: None, + extra: serde_json::Value::Null, + }; + assert!(identify(&obs).is_none()); +} + +#[test] +fn identify_rejects_missing_ssid() { + let obs = Observation { + ssid: None, + bssid: Some("aa:bb:cc:dd:ee:ff".into()), + rssi: None, + extra: serde_json::Value::Null, + }; + assert!(identify(&obs).is_none()); +} diff --git a/crates/wp-drivers/tests/rb23xx_write.rs b/crates/wp-drivers/tests/rb23xx_write.rs new file mode 100644 index 0000000..d39dbfa --- /dev/null +++ b/crates/wp-drivers/tests/rb23xx_write.rs @@ -0,0 +1,130 @@ +//! Recording fake HTTP client + firmware POST tests for the RB23xx driver. + +use std::io; + +use wp_core::{ + DeviceDriver, HttpClient, ProgramRequest, RosterEntry, ThrottleServer, Transport, + WifiCredentials, +}; +use wp_drivers::Rb23xxDriver; + +struct RecordedRequest { + method: String, + path: String, + content_type: Option, + body: Option>, +} + +struct FakeHttp { + requests: Vec, + responses: std::collections::VecDeque>>, +} + +impl HttpClient for FakeHttp { + fn request( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result> { + self.requests.push(RecordedRequest { + method: method.to_string(), + path: path.to_string(), + content_type: body.map(|(ct, _)| ct.to_string()), + body: body.map(|(_, b)| b.to_vec()), + }); + self.responses.pop_front().unwrap_or_else(|| Ok(Vec::new())) + } +} + +#[tokio::test] +async fn update_firmware_posts_raw_bin_like_rb() { + let mut fake = FakeHttp { + requests: Vec::new(), + responses: [Ok(b"Uploaded successfully".to_vec())].into(), + }; + let image = b"rb-firmware-image".to_vec(); + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let outcome = Rb23xxDriver::new() + .update_firmware( + transport, + &image, + "/data/fw/RB_Sound_1.15.1.bin", + &mut progress, + ) + .await + .expect("firmware"); + assert!(outcome.restarted); + assert_eq!(fake.requests.len(), 1); + assert_eq!(fake.requests[0].method, "POST"); + assert_eq!(fake.requests[0].path, "/upload?p=/RB_Sound_1.15.1.bin"); + assert_eq!( + fake.requests[0].content_type.as_deref(), + Some("multipart/form-data") + ); + assert_eq!(fake.requests[0].body.as_ref().unwrap(), &image); +} + +#[tokio::test] +async fn update_firmware_treats_empty_ok_as_restart() { + let mut fake = FakeHttp { + requests: Vec::new(), + responses: [Ok(Vec::new())].into(), + }; + let image = vec![1, 2, 3]; + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let outcome = Rb23xxDriver::new() + .update_firmware(transport, &image, "firmware.bin", &mut progress) + .await + .expect("reboot"); + assert!(outcome.restarted); +} + +#[tokio::test] +async fn probe_gets_root_listing() { + let mut fake = FakeHttp { + requests: Vec::new(), + responses: [Ok(b"files".to_vec())].into(), + }; + let transport = Transport::Http(&mut fake); + let info = Rb23xxDriver::new().probe(transport).await.expect("probe"); + assert_eq!( + info.get("ok").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!(fake.requests[0].method, "GET"); + assert_eq!(fake.requests[0].path, "/?p=/"); +} + +#[tokio::test] +async fn program_is_unsupported() { + let mut fake = FakeHttp { + requests: Vec::new(), + responses: std::collections::VecDeque::new(), + }; + let req = ProgramRequest { + identity: "", + wifi: WifiCredentials { + ssid: "x", + psk: None, + }, + server: ThrottleServer { + host: "", + port: 0, + automatic: false, + }, + roster: Vec::>::new(), + bigfred: None, + roster_mode: None, + }; + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let err = Rb23xxDriver::new() + .program(transport, &req, &mut progress) + .await + .expect_err("unsupported"); + assert!(err.to_string().contains("updateFirmware")); + assert!(fake.requests.is_empty()); +} diff --git a/crates/wp-fake/src/composite.rs b/crates/wp-fake/src/composite.rs index a0118dc..53370b5 100644 --- a/crates/wp-fake/src/composite.rs +++ b/crates/wp-fake/src/composite.rs @@ -2,6 +2,7 @@ use crate::device::{not_found, FakeDevice, FakeRequest, FakeResponse}; use crate::longfred::LongFredFake; +use crate::rb23xx::Rb23xxFake; use crate::wifred::WifredFake; /// Tries each inner device and returns the first non-404 response. @@ -16,12 +17,13 @@ impl CompositeFakeDevice { Self { devices } } - /// WiFred + LongFred mocks. + /// WiFred + LongFred + RB23xx mocks. #[must_use] pub fn all() -> Self { Self::new(vec![ Box::new(WifredFake::new()), Box::new(LongFredFake::new()), + Box::new(Rb23xxFake::new()), ]) } } diff --git a/crates/wp-fake/src/device.rs b/crates/wp-fake/src/device.rs index 6b3d483..c111d4a 100644 --- a/crates/wp-fake/src/device.rs +++ b/crates/wp-fake/src/device.rs @@ -18,6 +18,8 @@ pub struct FakeResponse { pub content_type: &'static str, /// Response body bytes. pub body: Vec, + /// Close the socket without writing a reply (decoder reboot after POST). + pub drop_without_reply: bool, } /// Build a `200` text/plain response. @@ -27,6 +29,7 @@ pub fn ok_text(body: impl Into) -> FakeResponse { status: 200, content_type: "text/plain", body: body.into().into_bytes(), + drop_without_reply: false, } } @@ -37,6 +40,7 @@ pub fn ok_xml(body: impl Into>) -> FakeResponse { status: 200, content_type: "text/html", body: body.into(), + drop_without_reply: false, } } @@ -47,6 +51,7 @@ pub fn ok_json(body: impl Into>) -> FakeResponse { status: 200, content_type: "application/json", body: body.into(), + drop_without_reply: false, } } @@ -57,6 +62,18 @@ pub fn not_found() -> FakeResponse { status: 404, content_type: "text/plain", body: b"not found".to_vec(), + drop_without_reply: false, + } +} + +/// Close after reading the request, with no HTTP response. +#[must_use] +pub fn drop_without_reply() -> FakeResponse { + FakeResponse { + status: 200, + content_type: "text/plain", + body: Vec::new(), + drop_without_reply: true, } } diff --git a/crates/wp-fake/src/lib.rs b/crates/wp-fake/src/lib.rs index 45951e0..c924feb 100644 --- a/crates/wp-fake/src/lib.rs +++ b/crates/wp-fake/src/lib.rs @@ -6,14 +6,18 @@ mod composite; mod device; mod longfred; mod radio; +mod rb23xx; mod server; mod wifred; mod z21; pub use composite::CompositeFakeDevice; -pub use device::{not_found, ok_json, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; +pub use device::{ + drop_without_reply, not_found, ok_json, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse, +}; pub use longfred::LongFredFake; pub use radio::FakeRadio; +pub use rb23xx::Rb23xxFake; pub use server::{bind_and_serve, FakeHttpServer}; pub use wifred::WifredFake; pub use z21::{FakeZ21, FakeZ21Mode}; diff --git a/crates/wp-fake/src/longfred.rs b/crates/wp-fake/src/longfred.rs index 645f283..2caf759 100644 --- a/crates/wp-fake/src/longfred.rs +++ b/crates/wp-fake/src/longfred.rs @@ -111,6 +111,7 @@ impl FakeDevice for LongFredFake { status: 400, content_type: "text/plain", body: b"bad json".to_vec(), + drop_without_reply: false, }, } } @@ -128,6 +129,7 @@ impl FakeDevice for LongFredFake { status: 400, content_type: "text/plain", body: b"empty image".to_vec(), + drop_without_reply: false, } } else { ok_json(b"{\"ok\":true}".to_vec()) diff --git a/crates/wp-fake/src/radio.rs b/crates/wp-fake/src/radio.rs index b850311..df1d7b1 100644 --- a/crates/wp-fake/src/radio.rs +++ b/crates/wp-fake/src/radio.rs @@ -39,6 +39,11 @@ impl FakeRadio { bssid: Some("de:ad:be:ef:00:02".into()), rssi: Some(-42), }, + ScanResult { + ssid: Some("RB2300_deadbe".into()), + bssid: Some("de:ad:be:ef:00:03".into()), + rssi: Some(-42), + }, ]) } @@ -64,6 +69,16 @@ impl Radio for FakeRadio { Box::pin(async move { Ok(()) }) } + fn connect_psk( + &mut self, + _ssid: &str, + _bssid: Option<[u8; 6]>, + _psk: &str, + ) -> RadioFut<'_, ()> { + self.record("connect_psk"); + 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(()) }) diff --git a/crates/wp-fake/src/rb23xx.rs b/crates/wp-fake/src/rb23xx.rs new file mode 100644 index 0000000..9805fae --- /dev/null +++ b/crates/wp-fake/src/rb23xx.rs @@ -0,0 +1,52 @@ +//! RailBOX RB23xx Soft-AP HTTP mock. + +use crate::device::{drop_without_reply, not_found, ok_xml, FakeDevice, FakeRequest, FakeResponse}; + +/// Fake RB23xx file-browser HTTP device. +pub struct Rb23xxFake { + /// Last uploaded firmware size, when a POST succeeded. + pub last_upload_bytes: Option, +} + +impl Rb23xxFake { + /// Empty file browser. + #[must_use] + pub fn new() -> Self { + Self { + last_upload_bytes: None, + } + } +} + +impl Default for Rb23xxFake { + fn default() -> Self { + Self::new() + } +} + +impl FakeDevice for Rb23xxFake { + fn driver_id(&self) -> &'static str { + "rb23xx" + } + + fn handle(&mut self, req: FakeRequest<'_>) -> FakeResponse { + let (path, query) = req.path.split_once('?').unwrap_or((req.path, "")); + match (req.method, path) { + ("GET", "/") if query.starts_with("p=/") => ok_xml(b"files".to_vec()), + ("POST", "/upload") => { + let n = req.body.map(<[u8]>::len).unwrap_or(0); + if n == 0 { + return FakeResponse { + status: 400, + content_type: "text/plain", + body: b"empty".to_vec(), + drop_without_reply: false, + }; + } + self.last_upload_bytes = Some(n); + drop_without_reply() + } + _ => not_found(), + } + } +} diff --git a/crates/wp-fake/src/server.rs b/crates/wp-fake/src/server.rs index 145727f..ea8ffc1 100644 --- a/crates/wp-fake/src/server.rs +++ b/crates/wp-fake/src/server.rs @@ -101,6 +101,10 @@ async fn handle_connection( }) }; + if response.drop_without_reply { + return Ok(()); + } + write_response(&mut stream, &response).await } diff --git a/crates/wp-link/Cargo.toml b/crates/wp-link/Cargo.toml index 62e29dd..17a65c9 100644 --- a/crates/wp-link/Cargo.toml +++ b/crates/wp-link/Cargo.toml @@ -27,6 +27,8 @@ wl-nl80211 = { version = "0.5", default-features = false, features = ["tokio_soc rtnetlink = "0.21" tokio = { version = "1", features = ["net", "time", "io-util", "rt"] } futures = "0.3" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +sha1 = "0.10" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/wp-link/src/http.rs b/crates/wp-link/src/http.rs index 193fe29..0230793 100644 --- a/crates/wp-link/src/http.rs +++ b/crates/wp-link/src/http.rs @@ -61,6 +61,9 @@ pub struct BoundedHttpClient { max_body: usize, /// When set, long reads/writes abort with [`io::ErrorKind::Interrupted`]. cancel: Option>, + /// After the request body is fully written, a TCP RST / FIN without a + /// complete HTTP response is treated as success (device reboot). + success_on_reset_after_write: bool, } impl BoundedHttpClient { @@ -77,6 +80,7 @@ impl BoundedHttpClient { retry_delay: RETRY_DELAY, max_body: MAX_BODY_BYTES, cancel: None, + success_on_reset_after_write: false, } } @@ -120,6 +124,15 @@ impl BoundedHttpClient { self } + /// Treat a TCP RST or close after a fully written request body as success. + /// + /// RailBOX reboots as soon as a firmware `.bin` is stored, so the HTTP + /// response never arrives. A RST **during** the write is still a failure. + pub fn with_success_on_reset_after_write(mut self, enable: bool) -> Self { + self.success_on_reset_after_write = enable; + self + } + /// Issue a single request, returning the raw body. fn request_once( &mut self, @@ -218,7 +231,19 @@ impl BoundedHttpClient { }; stream.set_read_timeout(Some(slice))?; match stream.read(&mut chunk) { - Ok(0) => break, + Ok(0) => { + if http_message_complete(&buf) { + break; + } + if self.success_on_reset_after_write { + log::debug!( + "read EOF after {} bytes with no complete HTTP response; treating as reboot", + buf.len() + ); + return Ok(Vec::new()); + } + break; + } Ok(n) => buf.extend_from_slice(&chunk[..n]), Err(e) if matches!( @@ -242,7 +267,14 @@ impl BoundedHttpClient { "read deadline elapsed", )); } - Err(e) if e.kind() == io::ErrorKind::ConnectionReset => { + Err(e) + if matches!( + e.kind(), + io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::UnexpectedEof + ) => + { // LongFred (embassy-net) calls abort() after the response, // which is a TCP RST. Linux then errors the next read even // when the full HTTP message is already in `buf`. @@ -254,6 +286,13 @@ impl BoundedHttpClient { if http_message_complete(&buf) { break; } + if self.success_on_reset_after_write { + log::debug!( + "incomplete HTTP after write treated as reboot ({})", + e.kind() + ); + return Ok(Vec::new()); + } return Err(io::Error::new(e.kind(), format!("read: {e}"))); } Err(e) => { @@ -752,6 +791,67 @@ mod tests { let _ = server.join(); } + #[test] + fn request_treats_close_after_write_as_success_when_flagged() { + 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; 512]; + let _ = s.read(&mut buf); + // Close without an HTTP response — decoder reboot after the POST. + drop(s); + }); + let mut c = BoundedHttpClient::new(addr.ip().to_string(), addr.port()) + .with_deadline(Duration::from_secs(5)) + .with_retries(0) + .with_success_on_reset_after_write(true); + let body = b"firmware-bytes"; + let got = c + .request( + "POST", + "/upload?p=/fw.bin", + Some(("multipart/form-data", body)), + ) + .expect("reboot close is success"); + assert!(got.is_empty()); + let _ = server.join(); + } + + #[test] + fn request_still_fails_on_close_after_write_without_flag() { + 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; 512]; + let _ = s.read(&mut buf); + drop(s); + }); + let mut c = BoundedHttpClient::new(addr.ip().to_string(), addr.port()) + .with_deadline(Duration::from_secs(5)) + .with_retries(0); + let body = b"firmware-bytes"; + let err = c + .request( + "POST", + "/upload?p=/fw.bin", + Some(("multipart/form-data", body)), + ) + .expect_err("incomplete response"); + assert!( + matches!( + err.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::InvalidData + ), + "{err:?}" + ); + let _ = server.join(); + } + // A trivial in-memory HttpClient for driver tests. #[derive(Default)] pub struct FakeHttp { diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 4bf63c1..3d3824d 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -27,7 +27,7 @@ pub use netcfg::{ }; pub use radio::{ first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, - resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, + resolve_wireless_interface, wpa2_pmk, Nl80211Radio, Radio, RadioFut, ScanResult, }; pub use rfkill::{aggregate_state, RfkillState}; pub use z21::{discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT}; diff --git a/crates/wp-link/src/radio.rs b/crates/wp-link/src/radio.rs index ee4cda3..7e59676 100644 --- a/crates/wp-link/src/radio.rs +++ b/crates/wp-link/src/radio.rs @@ -1,10 +1,11 @@ //! Radio control: nl80211 scan/connect + rtnetlink addressing. //! -//! The daemon owns the radio. It associates to a device AP (open, no PSK), -//! assigns an on-link address with **no default route** (so the hub's -//! Ethernet default gateway is never hijacked), then hands a sync -//! [`wp_core::HttpClient`] to the driver. On every exit path the radio is -//! released: disconnect and address removal. +//! The daemon owns the radio. It associates to a device AP (open, or +//! WPA2-PSK when the driver supplies a passphrase), assigns an on-link +//! address with **no default route** (so the hub's Ethernet default gateway +//! is never hijacked), then hands a sync [`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; @@ -49,6 +50,9 @@ pub trait Radio: Send { /// Associate to an open AP identified by SSID (and optional BSSID hint). fn connect_open(&mut self, ssid: &str, bssid: Option<[u8; 6]>) -> RadioFut<'_, ()>; + /// Associate to a WPA2-PSK AP. The passphrase is never logged. + fn connect_psk(&mut self, ssid: &str, bssid: Option<[u8; 6]>, psk: &str) -> 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<'_, ()>; @@ -333,23 +337,54 @@ async fn wait_associated(iface: &str, deadline: std::time::Duration) -> bool { } } -/// Issue one NL80211_CMD_CONNECT for an open (no PSK) AP. +/// IEEE 802.11 WPA2-PSK: PBKDF2-HMAC-SHA1, 4096 rounds, 32-byte PMK. /// -/// Errors from the command are surfaced rather than drained: a rejected -/// CONNECT used to look like a successful association and only showed up -/// later as an HTTP failure. +/// The passphrase is not logged; callers pass it only into nl80211. +#[must_use] +pub fn wpa2_pmk(ssid: &str, passphrase: &str) -> [u8; 32] { + let mut pmk = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(passphrase.as_bytes(), ssid.as_bytes(), 4096, &mut pmk); + pmk +} + +/// Issue one NL80211_CMD_CONNECT. +/// +/// Open APs use OpenSystem with `privacy=false`. WPA2-PSK APs pass a PMK +/// derived from `psk` so firmware that offloads the 4-way handshake can +/// complete association. Errors from the command are surfaced rather than +/// drained: a rejected CONNECT used to look like a successful association +/// and only showed up later as an HTTP failure. async fn connect_once( handle: &wl_nl80211::Nl80211Handle, if_index: u32, ssid: &str, bssid: Option<[u8; 6]>, + psk: Option<&str>, ) -> Result<(), DriverError> { - use wl_nl80211::{Nl80211AuthType, Nl80211Connect}; + use wl_nl80211::{ + Nl80211AkmSuite, Nl80211Attr, Nl80211AuthType, Nl80211CipherSuite, Nl80211Connect, + Nl80211KeyAttr, Nl80211KeyType, Nl80211WpaVersions, + }; - let mut builder = Nl80211Connect::new(if_index) - .ssid(ssid) - .auth_type(Nl80211AuthType::OpenSystem) - .privacy(false); + let mut builder = Nl80211Connect::new(if_index).ssid(ssid); + if let Some(passphrase) = psk { + let pmk = wpa2_pmk(ssid, passphrase); + builder = builder + .auth_type(Nl80211AuthType::OpenSystem) + .privacy(true) + .wpa_versions(Nl80211WpaVersions::WPA2) + .ciphers_pairwise(vec![Nl80211CipherSuite::Ccmp128]) + .cipher_group(Nl80211CipherSuite::Ccmp128) + .akm_suites(vec![Nl80211AkmSuite::Psk]) + .replace(Nl80211Attr::Key(vec![ + Nl80211KeyAttr::Type(Nl80211KeyType::Pmk), + Nl80211KeyAttr::Data(pmk.to_vec()), + ])); + } else { + builder = builder + .auth_type(Nl80211AuthType::OpenSystem) + .privacy(false); + } if let Some(mac) = bssid { builder = builder.mac(mac); } @@ -357,6 +392,70 @@ async fn connect_once( await_nl80211(stream, "nl80211 connect").await } +/// Bring the link up, CONNECT, wait for carrier; rescan and retry once. +async fn associate( + iface: String, + if_index: u32, + ssid: String, + bssid: Option<[u8; 6]>, + psk: Option, +) -> Result<(), DriverError> { + // `release` puts the link down, so without this a second job + // would try to associate on a down interface and silently fail. + set_link_up(if_index).await?; + log_rfkill(); + log::debug!( + "connect: {ssid} on {iface} (up={} operstate={} carrier={:?} psk={})", + iface_is_up(&iface), + iface_operstate(&iface), + iface_carrier(&iface), + if psk.is_some() { "yes" } else { "no" } + ); + + let (connection, handle, _) = wl_nl80211::new_connection() + .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; + tokio::spawn(connection); + + match connect_once(&handle, if_index, &ssid, bssid, psk.as_deref()).await { + Ok(()) => { + log::debug!("connect: CONNECT accepted for {ssid}"); + if wait_associated(&iface, ASSOCIATE_DEADLINE).await { + tokio::time::sleep(ASSOCIATE_SETTLE).await; + return Ok(()); + } + log::warn!("connect: {ssid} accepted but never gained carrier"); + } + Err(e) => log::warn!("connect: CONNECT rejected for {ssid}: {e}"), + } + + // The kernel drops its BSS cache when the link goes down, and + // CONNECT needs the AP in that cache. Re-scan and try once more + // so a stale cache does not fail the job. + log::warn!( + "connect: retrying {ssid} after a fresh scan (operstate={} carrier={:?})", + iface_operstate(&iface), + iface_carrier(&iface) + ); + let found = run_scan(&iface, if_index, 64).await?; + if !found + .iter() + .any(|r| r.ssid.as_deref() == Some(ssid.as_str())) + { + log::warn!("connect: {ssid} is not in the rescan results"); + } + connect_once(&handle, if_index, &ssid, bssid, psk.as_deref()).await?; + if !wait_associated(&iface, ASSOCIATE_DEADLINE).await { + log::warn!( + "connect: {ssid} still not associated (operstate={} carrier={:?})", + iface_operstate(&iface), + iface_carrier(&iface) + ); + return Err(DriverError::AssociationTimedOut); + } + tokio::time::sleep(ASSOCIATE_SETTLE).await; + Ok(()) +} + fn log_rfkill() { match crate::rfkill::aggregate_state() { Ok(Some(s)) if s.blocked() => { @@ -605,61 +704,15 @@ impl Radio for Nl80211Radio { let if_index = self.if_index; let iface = self.iface.clone(); let ssid = ssid.to_string(); - Box::pin(async move { - // `release` puts the link down, so without this a second job - // would try to associate on a down interface and silently fail. - set_link_up(if_index).await?; - log_rfkill(); - log::debug!( - "connect: {ssid} on {iface} (up={} operstate={} carrier={:?})", - iface_is_up(&iface), - iface_operstate(&iface), - iface_carrier(&iface) - ); - - let (connection, handle, _) = wl_nl80211::new_connection() - .map_err(|e| DriverError::Other(format!("nl80211 connection: {e}")))?; - tokio::spawn(connection); - - match connect_once(&handle, if_index, &ssid, bssid).await { - Ok(()) => { - log::debug!("connect: CONNECT accepted for {ssid}"); - if wait_associated(&iface, ASSOCIATE_DEADLINE).await { - tokio::time::sleep(ASSOCIATE_SETTLE).await; - return Ok(()); - } - log::warn!("connect: {ssid} accepted but never gained carrier"); - } - Err(e) => log::warn!("connect: CONNECT rejected for {ssid}: {e}"), - } + Box::pin(async move { associate(iface, if_index, ssid, bssid, None).await }) + } - // The kernel drops its BSS cache when the link goes down, and - // CONNECT needs the AP in that cache. Re-scan and try once more - // so a stale cache does not fail the job. - log::warn!( - "connect: retrying {ssid} after a fresh scan (operstate={} carrier={:?})", - iface_operstate(&iface), - iface_carrier(&iface) - ); - let found = run_scan(&iface, if_index, 64).await?; - if !found - .iter() - .any(|r| r.ssid.as_deref() == Some(ssid.as_str())) - { - log::warn!("connect: {ssid} is not in the rescan results"); - } - connect_once(&handle, if_index, &ssid, bssid).await?; - if !wait_associated(&iface, ASSOCIATE_DEADLINE).await { - log::warn!( - "connect: {ssid} still not associated (operstate={} carrier={:?})", - iface_operstate(&iface), - iface_carrier(&iface) - ); - return Err(DriverError::AssociationTimedOut); - } - tokio::time::sleep(ASSOCIATE_SETTLE).await; - Ok(()) - }) + fn connect_psk(&mut self, ssid: &str, bssid: Option<[u8; 6]>, psk: &str) -> RadioFut<'_, ()> { + let if_index = self.if_index; + let iface = self.iface.clone(); + let ssid = ssid.to_string(); + let psk = psk.to_string(); + Box::pin(async move { associate(iface, if_index, ssid, bssid, Some(psk)).await }) } fn set_address(&mut self, addr: std::net::Ipv4Addr, prefix_len: u8) -> RadioFut<'_, ()> { @@ -901,4 +954,21 @@ mod tests { assert!(iface_is_up("lo")); assert!(!iface_is_up("does-not-exist")); } + + #[test] + fn wpa2_pmk_matches_ieee_test_vector() { + // IEEE 802.11-2012 Annex J (passphrase "password", SSID "IEEE"). + let pmk = wpa2_pmk("IEEE", "password"); + let expected = hex_32("f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e"); + assert_eq!(pmk, expected); + } + + fn hex_32(s: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let byte = u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap(); + out[i] = byte; + } + out + } } diff --git a/docs/api.md b/docs/api.md index 806bd03..e907608 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,6 +59,7 @@ Soft-AP (`ap`) triggers an **active** nl80211 scan (wildcard probe, like - WiFred: every AP whose SSID starts with `wiFred-config` - LongFred: every AP whose SSID starts with `longfred_prog` +- RB23xx: every AP whose SSID starts with `RB2300_`, `RB2310_`, or `RB2302_` LAN (`lan`) does not use the radio. It queries mDNS for `_longfred-ota._tcp.local` and returns LongFred candidates whose `key` is @@ -82,12 +83,14 @@ A prior `scan` is not required when `candidate.key` is `host:port`. Starts a firmware-upload job. The image path is on the hub filesystem. `mode` is `"ap"`, `"lan"`, or `"usb"`. -- **AP**: join the LongFred Soft-AP like `program`, then - `POST /api/v1/firmware` with `application/octet-stream` (`.app.bin` only). - The HTTP transfer has a 120 s deadline and is not retried. After a successful - reboot the device stays in programming mode. +- **AP**: join the device Soft-AP like `program`, then POST the image. + LongFred: `POST /api/v1/firmware` with `application/octet-stream` (`.app.bin` only). + After a successful reboot the device stays in programming mode. + RB23xx: `POST /upload?p=/{basename}` with `multipart/form-data` and a raw + `.bin` body (max 5 MiB). A TCP reset after the write is success (decoder + reboot). RB23xx is Soft-AP only. - **LAN**: no radio. HTTP to `candidate.key` (an IPv4 from `scan` with - `mode: "lan"`) or `params.host`. The throttle must have HTTP OTA + `mode: "lan"`) or `params.host`. LongFred only. 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 @@ -100,7 +103,8 @@ Starts a firmware-upload job. The image path is on the hub filesystem. A driver with `supportsFirmwareUpdate: false` (WiFred) returns `driverError`. A second job while another job is active returns `busy` -(LAN and USB jobs do not take the radio). +(LAN and USB jobs do not take the radio). The HTTP transfer has a 120 s +deadline and is not retried. ```jsonc { @@ -113,11 +117,23 @@ A driver with `supportsFirmwareUpdate: false` (WiFred) returns } ``` +```jsonc +{ + "type": "updateFirmware", + "params": { + "mode": "ap", + "candidate": { "driver": "rb23xx", "key": "AA:BB:CC:DD:EE:01" }, + "path": "/data/firmware/RB_Sound_1.15.1.bin" + } +} +``` + ### `probe` Reads a single candidate's device info over the radio (associate → HTTP GET → parse → release). For WiFred this is `/api/getConfigXML`; for LongFred it -is `/api/v1/settings` (JSON, including `device.variant` when present). +is `/api/v1/settings` (JSON, including `device.variant` when present). For +RB23xx this is `GET /?p=/` (file browser listing). ### `program` @@ -147,8 +163,9 @@ request body is supplied by the caller (`bigfred`/`bigfred-wizard`), keeping ``` See [`drivers/wifred.md`](drivers/wifred.md), -[`drivers/longfred.md`](drivers/longfred.md), and -[`drivers/fred.md`](drivers/fred.md) for per-driver write sequences. +[`drivers/longfred.md`](drivers/longfred.md), +[`drivers/fred.md`](drivers/fred.md), and +[`drivers/rb23xx.md`](drivers/rb23xx.md) for per-driver write sequences. The job runs through the state machine: `queued → joining → probing → writing → verifying → restarting → done`. Progress is observable via diff --git a/docs/cli.md b/docs/cli.md index b430db0..8d183f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,13 +36,13 @@ Options: ### `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 +Runs the full IPC daemon with `FakeRadio` (scan returns one WiFred, one +LongFred, and one RB23xx 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` +### `fake --driver wifred|longfred|rb23xx` Starts **only** the Soft-AP HTTP mock for the chosen driver (no radio, no IPC). Default bind `127.0.0.1:8070`. @@ -137,7 +137,8 @@ wireless-programmer scan --json | jq '.[] | select(.rssi != null) | .key' Human `scan` prints `no candidates found` when the daemon returned an empty list. That is success, not `scan_failed`: the radio scan ran, but no SSID -started with `longfred_prog` or `wiFred-config`. Soft-AP discovery uses an +started with `longfred_prog`, `wiFred-config`, or `RB2300_` / `RB2310_` / +`RB2302_`. Soft-AP discovery uses an **active** nl80211 scan (wildcard probe, like `iw scan`), brings `wlan0` up, and waits for `NEW_SCAN_RESULTS`. `--log-level debug` on the **daemon** logs every raw BSS; at `info`, a scan that saw APs but no matching prefix says so @@ -147,17 +148,25 @@ daemon with it. ## Firmware update -`update-firmware` uploads a LongFred image. Soft-AP and LAN POST -`.app.bin` to `POST /api/v1/firmware` (120 s, not retried). USB runs -`espflash` on a serial port (ELF, merged `.bin`, or `.app.bin`). WiFred -does not support firmware upload. +`update-firmware` uploads a device image. Soft-AP HTTP has a 120 s deadline +and is not retried. -Use `--mode ap` after putting the throttle into Soft-AP programming mode -(8-second chord). Use `--mode lan` when the throttle is already on the -layout Wi‑Fi and the operator has opened **Firmware update** in the Extras -menu (HTTP is enabled only while that screen is shown). Use `--mode usb` -with the throttle on a USB-UART (or native USB-Serial-JTAG) cable; -`espflash` must be on `PATH`. +LongFred: Soft-AP and LAN POST `.app.bin` to `POST /api/v1/firmware`. USB +runs `espflash` on a serial port (ELF, merged `.bin`, or `.app.bin`). + +RB23xx: Soft-AP only. POST the vendor `.bin` to +`POST /upload?p=/{basename}` (`multipart/form-data`, raw body, max 5 MiB). +A TCP close/RST after the write is success (decoder reboot). Enable F28 on +the locomotive first, then join `RB2300_XXXXX`. + +WiFred does not support firmware upload. + +Use `--mode ap` after putting a LongFred throttle into Soft-AP programming +mode (8-second chord), or after F28 on an RB23xx decoder. Use `--mode lan` +when a LongFred is already on the layout Wi‑Fi and the operator has opened +**Firmware update** in the Extras menu (HTTP is enabled only while that +screen is shown). Use `--mode usb` with a LongFred 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. @@ -176,6 +185,10 @@ 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 + +# RB23xx decoder Soft-AP (F28 on; SSID RB2300_*). +wireless-programmer update-firmware --mode ap --driver rb23xx \ + --key AA:BB:CC:DD:EE:03 --file RB_Sound_1.15.1.bin ``` Like `program`, the command watches the job by default; `--no-watch` diff --git a/docs/drivers/rb23xx.md b/docs/drivers/rb23xx.md new file mode 100644 index 0000000..97be949 --- /dev/null +++ b/docs/drivers/rb23xx.md @@ -0,0 +1,76 @@ +# RB23xx driver + +Implements [`wp_core::DeviceDriver`] for RailBOX **RB2300 / RB2310 / RB2302** +sound decoders in Wi-Fi file-browser mode. + +## Commissioning model + +With **F28** on, the decoder raises a **WPA2-PSK** Soft-AP named +`RB2300_XXXXX` (also `RB2310_` / `RB2302_`). The factory password is +`000000000` (never logged). The AP uses `192.168.4.1/24`. The daemon +assigns `192.168.4.2/24` on the wireless interface (**no default route**), +hands a sync `HttpClient` to the driver, and releases the radio on every +exit path. + +| Field | Value | +|----------|----------------| +| `host` | `192.168.4.1` | +| `port` | `80` | +| `source` | `192.168.4.2` | +| `prefix` | `24` | + +Candidate identity: SSID prefixes above, stable key = BSSID. + +Turning F28 on is out of scope for this driver. + +## Capabilities + +| Field | Value | +|--------------------------|---------------------------------| +| `maxRosterSlots` | 0 | +| `maxFunctionIndex` | 0 | +| `identityFormat` | `Any` | +| `supportsThrottleServer` | false | +| `supportsFirmwareUpdate` | true | +| `commissioning` | `SoftAp` | +| `commissioningNet` | `192.168.4.1` / source `.2` /24 | + +`program` is not supported. Use `updateFirmware`. + +## Read-back / probe + +`GET /?p=/` confirms the file browser is up. Probe returns +`{ "ok": true, "bytes": }`. + +## Firmware update + +Same HTTP contract as the `rb` CLI sound upload, aimed at the decoder +**root** (not a sound-pack slot): + +``` +POST /upload?p=/{basename.bin} +Content-Type: multipart/form-data +Content-Length: + + +``` + +The Content-Type has **no multipart boundary**; the body is the file. Do +not rewrite this as a proper multipart form. + +- Soft-AP only (`mode: "ap"`). LAN and USB return a driver error. +- Cap **5 MiB**. Deadline **120 s**, not retried. +- Success is HTTP 2xx **or** a TCP RST / close after the body is fully + written (the decoder reboots). A RST during the write is a failure. + +```bash +wireless-programmer scan +wireless-programmer update-firmware --mode ap --driver rb23xx \ + --key AA:BB:CC:DD:EE:01 --file RB_Sound_1.15.1.bin +``` + +## Testing + +Covered by `rb23xx_discovery.rs`, `rb23xx_write.rs`, HTTP close-after-write +tests in `wp-link`, and fake-mode `updateFirmware` in +`crates/wireless-programmer/tests/fake_mode_test.rs`. From 02066cdb8e1cdba3fedded1097ee9ea237e6a21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:15:29 +0200 Subject: [PATCH 2/3] refactor(firmware): read limits and modes from driver capabilities Shared job code no longer matches on driver id; each driver advertises max bytes, allowed reach modes, ESP image policy, and RST-after-write via capabilities.firmware and hello exposes the same fields. Co-authored-by: Cursor --- CHANGELOG.md | 3 + crates/wireless-programmer/src/drivers.rs | 17 +- crates/wireless-programmer/src/jobs.rs | 6 +- crates/wireless-programmer/src/runtime.rs | 99 ++++++----- crates/wp-core/src/capabilities.rs | 179 +++++++++++++++++++- crates/wp-core/src/driver.rs | 4 + crates/wp-core/src/lib.rs | 3 +- crates/wp-drivers/src/fred.rs | 2 +- crates/wp-drivers/src/longfred/constants.rs | 3 + crates/wp-drivers/src/longfred/mod.rs | 14 +- crates/wp-drivers/src/rb23xx/mod.rs | 11 +- crates/wp-drivers/src/wifred/mod.rs | 2 +- crates/wp-proto/src/results.rs | 9 + docs/api.md | 5 +- docs/drivers/longfred.md | 3 + docs/drivers/rb23xx.md | 3 + docs/go-client.md | 2 +- go/client/client.go | 17 +- 18 files changed, 298 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b1939..42717f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Firmware job limits (max bytes, allowed reach modes, ESP `.app.bin` + requirement, RST-after-write) come from driver `capabilities.firmware` + instead of per-driver branches in shared runtime code. - LongFred Soft-AP commissioning addresses are now `192.168.4.1` / source `192.168.4.2` (ESP-IDF default, same as WiFred). This no longer overlaps the BigFred hub LAN (`192.168.0.0/24`). Requires matching LongFred diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index 495760e..f76aae3 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -187,16 +187,21 @@ impl DriverRegistry { } } - /// Whether this driver can upload firmware over HTTP. - pub fn supports_firmware_update(&self, driver: Driver) -> bool { + /// Capabilities advertised via `hello` and used by shared job code. + pub fn capabilities(&self, driver: Driver) -> DriverCapabilities { match driver { - Driver::WiFred => self.wifred.capabilities().supports_firmware_update, - Driver::LongFred => self.longfred.capabilities().supports_firmware_update, - Driver::Fred => false, - Driver::Rb23xx => self.rb23xx.capabilities().supports_firmware_update, + Driver::WiFred => self.wifred.capabilities(), + Driver::LongFred => self.longfred.capabilities(), + Driver::Fred => self.fred.capabilities(), + Driver::Rb23xx => self.rb23xx.capabilities(), } } + /// Whether this driver can upload firmware. + pub fn supports_firmware_update(&self, driver: Driver) -> bool { + self.capabilities(driver).firmware.is_some() + } + /// Upload firmware over the supplied transport. pub async fn update_firmware( &self, diff --git a/crates/wireless-programmer/src/jobs.rs b/crates/wireless-programmer/src/jobs.rs index bf7c8f7..05dec40 100644 --- a/crates/wireless-programmer/src/jobs.rs +++ b/crates/wireless-programmer/src/jobs.rs @@ -14,13 +14,9 @@ use wp_proto::{ProgramRequestWire, ReachMode}; /// Overall job deadline. pub const JOB_DEADLINE: Duration = Duration::from_secs(120); -/// Firmware POST deadline (LongFred HTTP OTA and RB23xx `.bin` upload). +/// Firmware POST deadline (HTTP OTA and RB23xx `.bin` upload). pub const FIRMWARE_DEADLINE: Duration = Duration::from_secs(120); -/// LongFred OTA slot (`ota_0` / `ota_1`) — cap for images loaded into RAM. -/// RB23xx uses [`wp_drivers::rb23xx::MAX_FIRMWARE_BYTES`] (5 MiB) instead. -pub const MAX_FIRMWARE_BYTES: u64 = 0x3C_0000; - /// Keep at most this many terminal jobs in the registry. const MAX_JOB_HISTORY: usize = 32; diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index c9fbebf..5aa6c25 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -12,8 +12,8 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::{ - CommissioningNet, Observation, ProgramRequest, ProgressSink, RosterEntry, ThrottleServer, - Transport, WifiCredentials, + CommissioningNet, FirmwareReach, Observation, ProgramRequest, ProgressSink, RosterEntry, + ThrottleServer, Transport, WifiCredentials, }; use wp_link::{BoundedHttpClient, Radio, ScanResult}; use wp_proto::{ProgramRequestWire, ReachMode}; @@ -1115,6 +1115,15 @@ async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { } } +fn firmware_reach(mode: ReachMode) -> FirmwareReach { + match mode { + ReachMode::Ap => FirmwareReach::Ap, + ReachMode::Lan => FirmwareReach::Lan, + ReachMode::Usb => FirmwareReach::Usb, + ReachMode::Z21 => FirmwareReach::Unsupported, + } +} + async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob) { use std::net::Ipv4Addr; @@ -1128,31 +1137,33 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob return; }; - if driver == Driver::Rb23xx && job.mode != ReachMode::Ap { + let Some(fw) = rt.registry.capabilities(driver).firmware else { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware update is not supported"), + ); + return; + }; + if !fw.allows(firmware_reach(job.mode)) { rt.jobs.transition( &id, JobState::Failed, None, None, - Some("RB23xx firmware update is Soft-AP only"), + Some(fw.mode_rejected_detail()), ); return; } - let max_bytes = match driver { - Driver::Rb23xx => wp_drivers::rb23xx::MAX_FIRMWARE_BYTES, - _ => crate::jobs::MAX_FIRMWARE_BYTES, - }; if job.mode != ReachMode::Usb { if let Ok(meta) = std::fs::metadata(&job.path) { - if meta.len() > max_bytes { - let detail = if driver == Driver::Rb23xx { - "firmware image exceeds 5 MiB" - } else { - "firmware image exceeds LongFred OTA slot (3.75 MiB)" - }; + if meta.len() > fw.max_bytes { + let detail = fw.too_large_detail(); rt.jobs - .transition(&id, JobState::Failed, None, None, Some(detail)); + .transition(&id, JobState::Failed, None, None, Some(&detail)); return; } } @@ -1194,51 +1205,33 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob }; if job.mode != ReachMode::Usb { - match driver { - Driver::Rb23xx => { - if image.len() as u64 > wp_drivers::rb23xx::MAX_FIRMWARE_BYTES { + if image.len() as u64 > fw.max_bytes { + let detail = fw.too_large_detail(); + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&detail)); + return; + } + if fw.require_esp_app_bin { + 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("firmware image exceeds 5 MiB"), + Some( + "HTTP firmware needs a .app.bin ESP app image, not ELF or a merged dump", + ), ); return; } - } - _ => { - 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)"), - ); + Err(e) => { + rt.jobs + .transition(&id, JobState::Failed, None, None, Some(&e)); 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; - } - } } } } @@ -1459,7 +1452,11 @@ async fn firmware_http_with_heartbeats( ) -> Result { let tokio_h = rt.handle(); let registry = Arc::clone(&rt.registry); - let reset_ok = driver == Driver::Rb23xx; + let reset_ok = rt + .registry + .capabilities(driver) + .firmware + .is_some_and(|fw| fw.success_on_reset_after_write); let client = make_firmware_http_client( host, port, diff --git a/crates/wp-core/src/capabilities.rs b/crates/wp-core/src/capabilities.rs index 41d24c5..d1be273 100644 --- a/crates/wp-core/src/capabilities.rs +++ b/crates/wp-core/src/capabilities.rs @@ -2,7 +2,9 @@ use std::net::Ipv4Addr; -use wp_proto::{CapabilitiesWire, CommissioningKindWire, CommissioningNetWire, IdentityFormatWire}; +use wp_proto::{ + CapabilitiesWire, CommissioningKindWire, CommissioningNetWire, IdentityFormatWire, ReachMode, +}; /// Stable identifier for a driver implementation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -112,6 +114,112 @@ impl From for IdentityFormatWire { } } +/// Reach path for `updateFirmware`, independent of the IPC `ReachMode` tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FirmwareReach { + /// Soft-AP HTTP upload. + Ap, + /// Layout LAN HTTP upload. + Lan, + /// USB serial (`espflash`). + Usb, + /// Not a firmware path (e.g. Z21). + Unsupported, +} + +/// Which `updateFirmware` reach paths a driver accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FirmwareModes { + /// Soft-AP HTTP upload. + pub ap: bool, + /// Layout LAN HTTP upload. + pub lan: bool, + /// USB serial (`espflash`). + pub usb: bool, +} + +impl FirmwareModes { + /// Soft-AP only (e.g. RailBOX file-browser). + pub const AP: Self = Self { + ap: true, + lan: false, + usb: false, + }; + + /// Soft-AP, LAN HTTP OTA, and USB (e.g. LongFred). + pub const AP_LAN_USB: Self = Self { + ap: true, + lan: true, + usb: true, + }; + + /// Whether `reach` is an accepted firmware path. + #[must_use] + pub fn allows(self, reach: FirmwareReach) -> bool { + match reach { + FirmwareReach::Ap => self.ap, + FirmwareReach::Lan => self.lan, + FirmwareReach::Usb => self.usb, + FirmwareReach::Unsupported => false, + } + } + + fn to_reach_modes(self) -> Vec { + let mut modes = Vec::new(); + if self.ap { + modes.push(ReachMode::Ap); + } + if self.lan { + modes.push(ReachMode::Lan); + } + if self.usb { + modes.push(ReachMode::Usb); + } + modes + } +} + +/// Firmware-upload policy. Present when the driver supports `updateFirmware`. +/// +/// Shared job code reads these fields instead of matching on a driver id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FirmwareCapabilities { + /// Maximum image size loaded into RAM for HTTP upload. + pub max_bytes: u64, + /// Human-readable cap used in error strings (e.g. `"5 MiB"`). + pub max_bytes_label: &'static str, + /// Accepted `updateFirmware` reach paths. + pub modes: FirmwareModes, + /// HTTP images must be an ESP `.app.bin` (magic `0xE9`). + pub require_esp_app_bin: bool, + /// TCP RST/EOF after a full request write is success (device reboot). + pub success_on_reset_after_write: bool, +} + +impl FirmwareCapabilities { + /// Whether `reach` is an accepted firmware path. + #[must_use] + pub fn allows(self, reach: FirmwareReach) -> bool { + self.modes.allows(reach) + } + + /// Error detail when the image is larger than [`Self::max_bytes`]. + #[must_use] + pub fn too_large_detail(self) -> String { + format!("firmware image exceeds {}", self.max_bytes_label) + } + + /// Error detail when the requested reach path is not in [`Self::modes`]. + #[must_use] + pub fn mode_rejected_detail(self) -> &'static str { + if self.modes == FirmwareModes::AP { + "firmware update is Soft-AP only" + } else { + "firmware update is not supported in this mode" + } + } +} + /// What a driver can do, advertised to callers via `hello`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DriverCapabilities { @@ -125,11 +233,11 @@ 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, + /// Firmware-upload policy, when the driver supports `updateFirmware`. + pub firmware: Option, } impl From for CapabilitiesWire { @@ -140,8 +248,14 @@ 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, + supports_firmware_update: c.firmware.is_some(), commissioning_net: c.commissioning_net.map(Into::into), + max_firmware_bytes: c.firmware.map(|f| f.max_bytes), + firmware_modes: c + .firmware + .map(|f| f.modes.to_reach_modes()) + .unwrap_or_default(), + firmware_require_esp_app_bin: c.firmware.is_some_and(|f| f.require_esp_app_bin), } } } @@ -175,4 +289,61 @@ mod tests { assert!(fmt.matches("")); assert!(fmt.matches("anything goes? no: still matches")); } + + fn firmware_caps() -> FirmwareCapabilities { + FirmwareCapabilities { + max_bytes: 5 * 1024 * 1024, + max_bytes_label: "5 MiB", + modes: FirmwareModes::AP, + require_esp_app_bin: false, + success_on_reset_after_write: true, + } + } + + #[test] + fn firmware_modes_ap_only_rejects_lan_and_usb() { + let modes = FirmwareModes::AP; + assert!(modes.allows(FirmwareReach::Ap)); + assert!(!modes.allows(FirmwareReach::Lan)); + assert!(!modes.allows(FirmwareReach::Usb)); + assert!(!modes.allows(FirmwareReach::Unsupported)); + } + + #[test] + fn firmware_caps_drive_error_details_and_hello_wire() { + let fw = firmware_caps(); + assert_eq!(fw.too_large_detail(), "firmware image exceeds 5 MiB"); + assert_eq!(fw.mode_rejected_detail(), "firmware update is Soft-AP only"); + + let wire = CapabilitiesWire::from(DriverCapabilities { + max_roster_slots: 0, + max_function_index: 0, + identity_format: IdentityFormat::Any, + supports_throttle_server: false, + commissioning: CommissioningKind::SoftAp, + commissioning_net: None, + firmware: Some(fw), + }); + assert!(wire.supports_firmware_update); + assert_eq!(wire.max_firmware_bytes, Some(fw.max_bytes)); + assert_eq!(wire.firmware_modes, vec![ReachMode::Ap]); + assert!(!wire.firmware_require_esp_app_bin); + } + + #[test] + fn hello_omits_firmware_policy_when_unsupported() { + let wire = CapabilitiesWire::from(DriverCapabilities { + max_roster_slots: 4, + max_function_index: 16, + identity_format: IdentityFormat::Digits { len: 6 }, + supports_throttle_server: true, + commissioning: CommissioningKind::SoftAp, + commissioning_net: None, + firmware: None, + }); + assert!(!wire.supports_firmware_update); + assert_eq!(wire.max_firmware_bytes, None); + assert!(wire.firmware_modes.is_empty()); + assert!(!wire.firmware_require_esp_app_bin); + } } diff --git a/crates/wp-core/src/driver.rs b/crates/wp-core/src/driver.rs index 6d87b7e..ab3717e 100644 --- a/crates/wp-core/src/driver.rs +++ b/crates/wp-core/src/driver.rs @@ -94,6 +94,10 @@ pub trait DeviceDriver { fn name(&self) -> &'static str; /// Capabilities advertised via `hello`. + /// + /// Firmware limits and allowed `updateFirmware` modes live on + /// [`DriverCapabilities::firmware`]; shared job code must read those + /// fields instead of matching on a driver id. fn capabilities(&self) -> DriverCapabilities; /// Filters applied to raw scan observations. diff --git a/crates/wp-core/src/lib.rs b/crates/wp-core/src/lib.rs index 176031c..3ea6cf5 100644 --- a/crates/wp-core/src/lib.rs +++ b/crates/wp-core/src/lib.rs @@ -18,7 +18,8 @@ mod request; mod transport; pub use capabilities::{ - CommissioningKind, CommissioningNet, DriverCapabilities, DriverId, IdentityFormat, + CommissioningKind, CommissioningNet, DriverCapabilities, DriverId, FirmwareCapabilities, + FirmwareModes, FirmwareReach, IdentityFormat, }; pub use driver::{ validate_common, DeviceCandidate, DeviceDriver, NoProgress, Observation, Outcome, ProgressSink, diff --git a/crates/wp-drivers/src/fred.rs b/crates/wp-drivers/src/fred.rs index 6ab7f6a..2b942cc 100644 --- a/crates/wp-drivers/src/fred.rs +++ b/crates/wp-drivers/src/fred.rs @@ -38,8 +38,8 @@ impl DeviceDriver for FredDriver { identity_format: IdentityFormat::Any, supports_throttle_server: false, commissioning: wp_core::CommissioningKind::Lan, - supports_firmware_update: false, commissioning_net: None, + firmware: None, } } diff --git a/crates/wp-drivers/src/longfred/constants.rs b/crates/wp-drivers/src/longfred/constants.rs index d6ecc37..fe7f9a7 100644 --- a/crates/wp-drivers/src/longfred/constants.rs +++ b/crates/wp-drivers/src/longfred/constants.rs @@ -28,6 +28,9 @@ pub const MAX_FUNCTION: u8 = 0; /// Settings read endpoint. pub const SETTINGS_PATH: &str = "/api/v1/settings"; +/// Firmware POST cap — LongFred OTA slot (`ota_0` / `ota_1`). +pub const MAX_FIRMWARE_BYTES: u64 = 0x3C_0000; + /// Firmware upload endpoint (raw `.app.bin`). pub const FIRMWARE_PATH: &str = "/api/v1/firmware"; diff --git a/crates/wp-drivers/src/longfred/mod.rs b/crates/wp-drivers/src/longfred/mod.rs index 603a21f..c0e7069 100644 --- a/crates/wp-drivers/src/longfred/mod.rs +++ b/crates/wp-drivers/src/longfred/mod.rs @@ -18,13 +18,13 @@ mod settings; use wp_core::{ validate_common, CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, - DriverError, DriverId, IdentityFormat, Observation, Outcome, ProgressSink, ScanFilters, - Transport, + DriverError, DriverId, FirmwareCapabilities, FirmwareModes, IdentityFormat, Observation, + Outcome, ProgressSink, ScanFilters, Transport, }; pub use constants::{ CONFIG_AP_PORT, CONFIG_HOST, CONFIG_PREFIX_LEN, CONFIG_SOURCE, FIRMWARE_CONTENT_TYPE, - FIRMWARE_PATH, MAX_FUNCTION, MAX_ROSTER_SLOTS, WIFI_CONFIG_SSID_PREFIX, + FIRMWARE_PATH, MAX_FIRMWARE_BYTES, MAX_FUNCTION, MAX_ROSTER_SLOTS, WIFI_CONFIG_SSID_PREFIX, }; pub use discovery::identify; pub use settings::{build_settings_put, format_roster_addr, verify}; @@ -64,13 +64,19 @@ 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, source: CONFIG_SOURCE, prefix: CONFIG_PREFIX_LEN, }), + firmware: Some(FirmwareCapabilities { + max_bytes: MAX_FIRMWARE_BYTES, + max_bytes_label: "LongFred OTA slot (3.75 MiB)", + modes: FirmwareModes::AP_LAN_USB, + require_esp_app_bin: true, + success_on_reset_after_write: false, + }), } } diff --git a/crates/wp-drivers/src/rb23xx/mod.rs b/crates/wp-drivers/src/rb23xx/mod.rs index 0d2455b..b49f738 100644 --- a/crates/wp-drivers/src/rb23xx/mod.rs +++ b/crates/wp-drivers/src/rb23xx/mod.rs @@ -17,7 +17,8 @@ mod discovery; use wp_core::{ CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, DriverId, - IdentityFormat, Observation, Outcome, ProgressSink, ScanFilters, Transport, + FirmwareCapabilities, FirmwareModes, IdentityFormat, Observation, Outcome, ProgressSink, + ScanFilters, Transport, }; use wp_link::percent_encode; @@ -58,13 +59,19 @@ impl DeviceDriver for Rb23xxDriver { identity_format: IdentityFormat::Any, supports_throttle_server: false, commissioning: wp_core::CommissioningKind::SoftAp, - supports_firmware_update: true, commissioning_net: Some(CommissioningNet { host: CONFIG_HOST, port: CONFIG_AP_PORT, source: CONFIG_SOURCE, prefix: CONFIG_PREFIX_LEN, }), + firmware: Some(FirmwareCapabilities { + max_bytes: MAX_FIRMWARE_BYTES, + max_bytes_label: "5 MiB", + modes: FirmwareModes::AP, + require_esp_app_bin: false, + success_on_reset_after_write: true, + }), } } diff --git a/crates/wp-drivers/src/wifred/mod.rs b/crates/wp-drivers/src/wifred/mod.rs index 046d565..00eba5c 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -57,10 +57,10 @@ 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, + firmware: None, } } diff --git a/crates/wp-proto/src/results.rs b/crates/wp-proto/src/results.rs index 2146563..9eb2512 100644 --- a/crates/wp-proto/src/results.rs +++ b/crates/wp-proto/src/results.rs @@ -74,6 +74,15 @@ pub struct CapabilitiesWire { /// Soft-AP addressing for commissioning, when not using daemon defaults. #[serde(skip_serializing_if = "Option::is_none", default)] pub commissioning_net: Option, + /// HTTP firmware image cap in bytes, when [`Self::supports_firmware_update`]. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub max_firmware_bytes: Option, + /// `updateFirmware` reach modes this driver accepts (`ap` / `lan` / `usb`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub firmware_modes: Vec, + /// HTTP images must be an ESP `.app.bin` (magic `0xE9`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub firmware_require_esp_app_bin: bool, } /// On-link Soft-AP addressing advertised by a driver. diff --git a/docs/api.md b/docs/api.md index e907608..c75efcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -43,7 +43,10 @@ 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, firmware-update support). +support, firmware-update support). When `supportsFirmwareUpdate` is true, +`hello` also includes `maxFirmwareBytes`, `firmwareModes` (`ap` / `lan` / +`usb`), and `firmwareRequireEspAppBin`. The daemon uses those fields instead +of per-driver branches in shared job code. `version` is the release tag from the ELF section `.wireless-programmer.version` when the binary was published via the release workflow; otherwise the Cargo diff --git a/docs/drivers/longfred.md b/docs/drivers/longfred.md index a66101c..13fa95a 100644 --- a/docs/drivers/longfred.md +++ b/docs/drivers/longfred.md @@ -39,6 +39,9 @@ Candidate identity: SSID prefix `longfred_prog`, stable key = BSSID. | `identityFormat` | `Alphanumeric { max_len: 16 }` | | `supportsThrottleServer` | true (field accepted, unused) | | `supportsFirmwareUpdate` | true | +| `maxFirmwareBytes` | 3.75 MiB (`0x3C0000`) | +| `firmwareModes` | `ap`, `lan`, `usb` | +| `firmwareRequireEspAppBin` | true | | `commissioning` | `SoftAp` | | `commissioningNet` | `192.168.4.1` / source `.2` /24 | diff --git a/docs/drivers/rb23xx.md b/docs/drivers/rb23xx.md index 97be949..35e8127 100644 --- a/docs/drivers/rb23xx.md +++ b/docs/drivers/rb23xx.md @@ -32,6 +32,9 @@ Turning F28 on is out of scope for this driver. | `identityFormat` | `Any` | | `supportsThrottleServer` | false | | `supportsFirmwareUpdate` | true | +| `maxFirmwareBytes` | 5 MiB | +| `firmwareModes` | `ap` | +| `firmwareRequireEspAppBin` | false | | `commissioning` | `SoftAp` | | `commissioningNet` | `192.168.4.1` / source `.2` /24 | diff --git a/docs/go-client.md b/docs/go-client.md index 90810f9..8d9e2cb 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -194,7 +194,7 @@ The Go structs mirror `wp-proto` 1:1 (camelCase JSON tags). The main ones: - `CandidateWire{Driver, Key, Label, RSSI *int32}` - `CandidateRef{Driver, Key}` - `HelloResult{Version, Commit, Drivers []DriverInfoWire}` -- `DriverInfoWire{ID, Name, Capabilities}` / `CapabilitiesWire{MaxRosterSlots, MaxFunctionIndex, IdentityFormat, SupportsThrottleServer, Commissioning, CommissioningNet}` +- `DriverInfoWire{ID, Name, Capabilities}` / `CapabilitiesWire{MaxRosterSlots, MaxFunctionIndex, IdentityFormat, SupportsThrottleServer, SupportsFirmwareUpdate, Commissioning, CommissioningNet, MaxFirmwareBytes, FirmwareModes, FirmwareRequireEspAppBin}` - `ProgramRequestWire{Identity, Wifi, Server, Roster []RosterEntryWire, Bigfred, RosterMode}` - `WifiCredentialsWire{SSID, PSK}` / `ThrottleServerWire{Host, Port, Automatic *bool}` - `RosterEntryWire{Address *uint16, LongAddress *bool, Mode string, Direction *uint8, Functions []FunctionMappingWire}` diff --git a/go/client/client.go b/go/client/client.go index 704ebc6..9eb4d9c 100644 --- a/go/client/client.go +++ b/go/client/client.go @@ -38,13 +38,16 @@ 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"` - SupportsFirmwareUpdate bool `json:"supportsFirmwareUpdate"` - 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"` + MaxFirmwareBytes *uint64 `json:"maxFirmwareBytes,omitempty"` + FirmwareModes []string `json:"firmwareModes,omitempty"` + FirmwareRequireEspAppBin bool `json:"firmwareRequireEspAppBin,omitempty"` } // CommissioningNetWire mirrors wp_proto::CommissioningNetWire. From 3fb0ee28ba4f770d349842326e7b4eeb0ad14997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:32:14 +0200 Subject: [PATCH 3/3] refactor(firmware): capabilities-driven dispatch + wpa_supplicant fallback Move softap_psk into DriverCapabilities (public factory default, not a secret) and remove the per-driver softap_psk() method. Add update_firmware(transport, image, filename, progress) to the DeviceDriver trait so the registry dispatches uniformly instead of matching per-driver signatures; WiFred/Fred return a not-supported error from their trait impls, removing the unreachable registry arms. Replace FirmwareReach with ReachMode directly in FirmwareModes::allows (Z21 -> false). Implement wpa_supplicant fallback in Nl80211Radio::associate: when nl80211 CONNECT with a PMK does not produce carrier (brcmfmac on Pi 5 does not offload the 4-way handshake), spawn wpa_supplicant on the programming interface with a minimal config for the duration of the job and kill it on release. The supplicant runs in the foreground so its lifetime is tied to the radio handle. Co-authored-by: Cursor --- CHANGELOG.md | 11 +- crates/wireless-programmer/src/drivers.rs | 29 ++--- crates/wireless-programmer/src/runtime.rs | 50 +++++--- crates/wp-core/src/capabilities.rs | 49 ++++---- crates/wp-core/src/driver.rs | 20 ++++ crates/wp-core/src/lib.rs | 2 +- crates/wp-drivers/src/fred.rs | 13 ++ crates/wp-drivers/src/longfred/mod.rs | 11 +- crates/wp-drivers/src/rb23xx/mod.rs | 14 +-- crates/wp-drivers/src/wifred/mod.rs | 13 ++ crates/wp-drivers/tests/longfred_write.rs | 2 +- crates/wp-link/src/radio.rs | 140 +++++++++++++++++++++- docs/drivers/rb23xx.md | 22 +++- 13 files changed, 282 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42717f0..5db490a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Firmware job limits (max bytes, allowed reach modes, ESP `.app.bin` - requirement, RST-after-write) come from driver `capabilities.firmware` - instead of per-driver branches in shared runtime code. + requirement, RST-after-write) and Soft-AP PSK come from driver + `capabilities` instead of per-driver branches in shared runtime code. +- `update_firmware` is now a `DeviceDriver` trait method (uniform + `filename` parameter); the registry no longer manually dispatches with + per-driver signatures. +- WPA2-PSK join falls back to `wpa_supplicant` on the programming + interface when nl80211 CONNECT with a PMK does not produce carrier + (brcmfmac on Pi 5 does not offload the 4-way handshake). The supplicant + is killed on radio release. - LongFred Soft-AP commissioning addresses are now `192.168.4.1` / source `192.168.4.2` (ESP-IDF default, same as WiFred). This no longer overlaps the BigFred hub LAN (`192.168.0.0/24`). Requires matching LongFred diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index f76aae3..ec4f522 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -65,17 +65,6 @@ impl Driver { } } - /// WPA2-PSK for the device Soft-AP, when it is not open. - /// - /// The passphrase is never logged and is not advertised on `hello`. - #[must_use] - pub fn softap_psk(self) -> Option<&'static str> { - match self { - Driver::Rb23xx => Some(wp_drivers::rb23xx::SOFTAP_PSK), - Driver::WiFred | Driver::LongFred | Driver::Fred => None, - } - } - /// Parse a driver id string. pub fn from_id(id: &str) -> Option { match id { @@ -212,17 +201,21 @@ impl DriverRegistry { progress: &mut dyn ProgressSink, ) -> Result { match driver { - Driver::WiFred => Err(DriverError::Other( - "firmware update is not supported".into(), - )), + Driver::WiFred => { + self.wifred + .update_firmware(transport, image, filename, progress) + .await + } Driver::LongFred => { self.longfred - .update_firmware(transport, image, progress) + .update_firmware(transport, image, filename, progress) + .await + } + Driver::Fred => { + self.fred + .update_firmware(transport, image, filename, progress) .await } - Driver::Fred => Err(DriverError::Other( - "firmware update is not supported".into(), - )), Driver::Rb23xx => { self.rb23xx .update_firmware(transport, image, filename, progress) diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index 5aa6c25..0fb9567 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -12,8 +12,8 @@ use std::time::{Duration, Instant}; use parking_lot::Mutex; use wp_core::{ - CommissioningNet, FirmwareReach, Observation, ProgramRequest, ProgressSink, RosterEntry, - ThrottleServer, Transport, WifiCredentials, + CommissioningNet, Observation, ProgramRequest, ProgressSink, RosterEntry, ThrottleServer, + Transport, WifiCredentials, }; use wp_link::{BoundedHttpClient, Radio, ScanResult}; use wp_proto::{ProgramRequestWire, ReachMode}; @@ -422,8 +422,13 @@ impl Runtime { 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) = - radio_join(r.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await + if let Err(e) = radio_join( + r.as_mut(), + &candidate.ssid, + bssid, + registry.capabilities(driver).softap_psk, + ) + .await { tracing::warn!( ssid = %candidate.ssid, @@ -472,7 +477,13 @@ impl Runtime { self.rt.handle().block_on(async move { let mut r = radio.lock().await; let bssid = parse_bssid(candidate.bssid.as_deref()); - radio_join(r.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await?; + radio_join( + r.as_mut(), + &candidate.ssid, + bssid, + registry.capabilities(driver).softap_psk, + ) + .await?; r.set_address(net.source, net.prefix).await?; r.link_up().await?; r.prepare_softap(net.source, net.host).await?; @@ -955,7 +966,14 @@ async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { bssid = ?candidate.bssid, "connecting to Soft-AP" ); - if let Err(e) = radio_join(radio.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await { + if let Err(e) = radio_join( + radio.as_mut(), + &candidate.ssid, + bssid, + rt.registry.capabilities(driver).softap_psk, + ) + .await + { tracing::warn!( job_id = %id.0, ssid = %candidate.ssid, @@ -1115,15 +1133,6 @@ async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { } } -fn firmware_reach(mode: ReachMode) -> FirmwareReach { - match mode { - ReachMode::Ap => FirmwareReach::Ap, - ReachMode::Lan => FirmwareReach::Lan, - ReachMode::Usb => FirmwareReach::Usb, - ReachMode::Z21 => FirmwareReach::Unsupported, - } -} - async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob) { use std::net::Ipv4Addr; @@ -1147,7 +1156,7 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob ); return; }; - if !fw.allows(firmware_reach(job.mode)) { + if !fw.allows(job.mode) { rt.jobs.transition( &id, JobState::Failed, @@ -1360,8 +1369,13 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob 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_join(radio.as_mut(), &candidate.ssid, bssid, driver.softap_psk()).await + if let Err(e) = radio_join( + radio.as_mut(), + &candidate.ssid, + bssid, + rt.registry.capabilities(driver).softap_psk, + ) + .await { rt.jobs.transition( &id, diff --git a/crates/wp-core/src/capabilities.rs b/crates/wp-core/src/capabilities.rs index d1be273..830e125 100644 --- a/crates/wp-core/src/capabilities.rs +++ b/crates/wp-core/src/capabilities.rs @@ -114,19 +114,6 @@ impl From for IdentityFormatWire { } } -/// Reach path for `updateFirmware`, independent of the IPC `ReachMode` tag. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FirmwareReach { - /// Soft-AP HTTP upload. - Ap, - /// Layout LAN HTTP upload. - Lan, - /// USB serial (`espflash`). - Usb, - /// Not a firmware path (e.g. Z21). - Unsupported, -} - /// Which `updateFirmware` reach paths a driver accepts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct FirmwareModes { @@ -153,14 +140,15 @@ impl FirmwareModes { usb: true, }; - /// Whether `reach` is an accepted firmware path. + /// Whether `mode` is an accepted firmware path. `Z21` is never a firmware + /// path; drivers that do not support a mode simply leave it `false`. #[must_use] - pub fn allows(self, reach: FirmwareReach) -> bool { - match reach { - FirmwareReach::Ap => self.ap, - FirmwareReach::Lan => self.lan, - FirmwareReach::Usb => self.usb, - FirmwareReach::Unsupported => false, + pub fn allows(self, mode: ReachMode) -> bool { + match mode { + ReachMode::Ap => self.ap, + ReachMode::Lan => self.lan, + ReachMode::Usb => self.usb, + ReachMode::Z21 => false, } } @@ -197,10 +185,10 @@ pub struct FirmwareCapabilities { } impl FirmwareCapabilities { - /// Whether `reach` is an accepted firmware path. + /// Whether `mode` is an accepted firmware path. #[must_use] - pub fn allows(self, reach: FirmwareReach) -> bool { - self.modes.allows(reach) + pub fn allows(self, mode: ReachMode) -> bool { + self.modes.allows(mode) } /// Error detail when the image is larger than [`Self::max_bytes`]. @@ -236,6 +224,10 @@ pub struct DriverCapabilities { /// Soft-AP addressing for commissioning, when the driver does not use the /// daemon's historical `192.168.4.x` defaults. pub commissioning_net: Option, + /// WPA2-PSK passphrase for the device Soft-AP, when it is not open. + /// These are publicly documented factory default passwords for a + /// temporary commissioning AP, not secrets. + pub softap_psk: Option<&'static str>, /// Firmware-upload policy, when the driver supports `updateFirmware`. pub firmware: Option, } @@ -302,11 +294,12 @@ mod tests { #[test] fn firmware_modes_ap_only_rejects_lan_and_usb() { + use wp_proto::ReachMode; let modes = FirmwareModes::AP; - assert!(modes.allows(FirmwareReach::Ap)); - assert!(!modes.allows(FirmwareReach::Lan)); - assert!(!modes.allows(FirmwareReach::Usb)); - assert!(!modes.allows(FirmwareReach::Unsupported)); + assert!(modes.allows(ReachMode::Ap)); + assert!(!modes.allows(ReachMode::Lan)); + assert!(!modes.allows(ReachMode::Usb)); + assert!(!modes.allows(ReachMode::Z21)); } #[test] @@ -322,6 +315,7 @@ mod tests { supports_throttle_server: false, commissioning: CommissioningKind::SoftAp, commissioning_net: None, + softap_psk: Some("000000000"), firmware: Some(fw), }); assert!(wire.supports_firmware_update); @@ -339,6 +333,7 @@ mod tests { supports_throttle_server: true, commissioning: CommissioningKind::SoftAp, commissioning_net: None, + softap_psk: None, firmware: None, }); assert!(!wire.supports_firmware_update); diff --git a/crates/wp-core/src/driver.rs b/crates/wp-core/src/driver.rs index ab3717e..ed2e9bf 100644 --- a/crates/wp-core/src/driver.rs +++ b/crates/wp-core/src/driver.rs @@ -135,6 +135,26 @@ pub trait DeviceDriver { req: &ProgramRequest<'_>, progress: &mut dyn ProgressSink, ) -> impl std::future::Future>; + + /// Upload firmware over the supplied transport. + /// + /// `filename` is the basename the device stores the image under; drivers + /// that ignore the name (e.g. LongFred posts to a fixed path) simply + /// disregard it. Drivers without firmware support return + /// [`crate::DriverError::Other`]; the runtime checks + /// [`DriverCapabilities::firmware`] before dispatch so the error arm + /// is only reached if the trait is called directly. + /// + /// # Errors + /// + /// Returns [`crate::DriverError`] on runtime failure. + fn update_firmware( + &self, + transport: Transport<'_>, + image: &[u8], + filename: &str, + progress: &mut dyn ProgressSink, + ) -> impl std::future::Future>; } /// Shared validation used by drivers: capacity, identity format, address diff --git a/crates/wp-core/src/lib.rs b/crates/wp-core/src/lib.rs index 3ea6cf5..8623b5b 100644 --- a/crates/wp-core/src/lib.rs +++ b/crates/wp-core/src/lib.rs @@ -19,7 +19,7 @@ mod transport; pub use capabilities::{ CommissioningKind, CommissioningNet, DriverCapabilities, DriverId, FirmwareCapabilities, - FirmwareModes, FirmwareReach, IdentityFormat, + FirmwareModes, IdentityFormat, }; pub use driver::{ validate_common, DeviceCandidate, DeviceDriver, NoProgress, Observation, Outcome, ProgressSink, diff --git a/crates/wp-drivers/src/fred.rs b/crates/wp-drivers/src/fred.rs index 2b942cc..dfaa4be 100644 --- a/crates/wp-drivers/src/fred.rs +++ b/crates/wp-drivers/src/fred.rs @@ -39,6 +39,7 @@ impl DeviceDriver for FredDriver { supports_throttle_server: false, commissioning: wp_core::CommissioningKind::Lan, commissioning_net: None, + softap_psk: None, firmware: None, } } @@ -82,6 +83,18 @@ impl DeviceDriver for FredDriver { "fred programming uses Z21 UDP, not HTTP/serial".into(), )) } + + async fn update_firmware( + &self, + _transport: Transport<'_>, + _image: &[u8], + _filename: &str, + _progress: &mut dyn ProgressSink, + ) -> Result { + Err(DriverError::Other( + "firmware update is not supported".into(), + )) + } } #[cfg(test)] diff --git a/crates/wp-drivers/src/longfred/mod.rs b/crates/wp-drivers/src/longfred/mod.rs index c0e7069..06926b4 100644 --- a/crates/wp-drivers/src/longfred/mod.rs +++ b/crates/wp-drivers/src/longfred/mod.rs @@ -70,6 +70,7 @@ impl DeviceDriver for LongFredDriver { source: CONFIG_SOURCE, prefix: CONFIG_PREFIX_LEN, }), + softap_psk: None, firmware: Some(FirmwareCapabilities { max_bytes: MAX_FIRMWARE_BYTES, max_bytes_label: "LongFred OTA slot (3.75 MiB)", @@ -138,18 +139,12 @@ impl DeviceDriver for LongFredDriver { mismatches: Vec::new(), }) } -} -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( + async fn update_firmware( &self, transport: Transport<'_>, image: &[u8], + _filename: &str, progress: &mut dyn ProgressSink, ) -> Result { let client = http_client(transport)?; diff --git a/crates/wp-drivers/src/rb23xx/mod.rs b/crates/wp-drivers/src/rb23xx/mod.rs index b49f738..5c73359 100644 --- a/crates/wp-drivers/src/rb23xx/mod.rs +++ b/crates/wp-drivers/src/rb23xx/mod.rs @@ -65,6 +65,7 @@ impl DeviceDriver for Rb23xxDriver { source: CONFIG_SOURCE, prefix: CONFIG_PREFIX_LEN, }), + softap_psk: Some(SOFTAP_PSK), firmware: Some(FirmwareCapabilities { max_bytes: MAX_FIRMWARE_BYTES, max_bytes_label: "5 MiB", @@ -113,19 +114,8 @@ impl DeviceDriver for Rb23xxDriver { "rb23xx does not support program; use updateFirmware to upload a .bin".into(), )) } -} -impl Rb23xxDriver { - /// POST a firmware `.bin` to the decoder file browser. - /// - /// `filename` is the basename placed in `/?p=/{filename}`. The HTTP client - /// should treat a TCP reset after a full write as success (decoder reboot). - /// - /// # Errors - /// - /// Returns [`DriverError`] when the HTTP POST fails for a reason other - /// than a post-write connection reset. - pub async fn update_firmware( + async fn update_firmware( &self, transport: Transport<'_>, image: &[u8], diff --git a/crates/wp-drivers/src/wifred/mod.rs b/crates/wp-drivers/src/wifred/mod.rs index 00eba5c..a4e8cb8 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -60,6 +60,7 @@ impl DeviceDriver for WiFredDriver { // 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, + softap_psk: None, firmware: None, } } @@ -193,6 +194,18 @@ impl DeviceDriver for WiFredDriver { mismatches: Vec::new(), }) } + + async fn update_firmware( + &self, + _transport: Transport<'_>, + _image: &[u8], + _filename: &str, + _progress: &mut dyn ProgressSink, + ) -> Result { + Err(DriverError::Other( + "firmware update is not supported".into(), + )) + } } /// Blink the device LED so an operator can find the physical throttle. diff --git a/crates/wp-drivers/tests/longfred_write.rs b/crates/wp-drivers/tests/longfred_write.rs index 5d33b92..2ccdf5b 100644 --- a/crates/wp-drivers/tests/longfred_write.rs +++ b/crates/wp-drivers/tests/longfred_write.rs @@ -186,7 +186,7 @@ async fn update_firmware_posts_app_image() { let mut progress = wp_core::NoProgress; let transport = Transport::Http(&mut fake); let outcome = LongFredDriver::new() - .update_firmware(transport, &image, &mut progress) + .update_firmware(transport, &image, "firmware.app.bin", &mut progress) .await .expect("firmware"); assert!(outcome.restarted); diff --git a/crates/wp-link/src/radio.rs b/crates/wp-link/src/radio.rs index 7e59676..15bd9ed 100644 --- a/crates/wp-link/src/radio.rs +++ b/crates/wp-link/src/radio.rs @@ -10,6 +10,7 @@ use std::future::Future; use std::path::Path; use std::pin::Pin; +use std::sync::Arc; use wp_core::DriverError; @@ -247,6 +248,10 @@ pub struct Nl80211Radio { /// Fib-rule exception installed by [`Radio::prepare_softap`], removed on /// [`Radio::release`]. policy: Option, + /// `wpa_supplicant` child spawned as a fallback when nl80211 CONNECT + /// with a PMK does not produce carrier (e.g. brcmfmac on Pi 5 does not + /// offload the 4-way handshake). Killed on [`Radio::release`]. + supplicant: Arc>>, } /// Hard cap on waiting for `NEW_SCAN_RESULTS` after TRIGGER_SCAN. @@ -393,13 +398,17 @@ async fn connect_once( } /// Bring the link up, CONNECT, wait for carrier; rescan and retry once. +/// When a PSK is supplied and nl80211 CONNECT does not produce carrier +/// (e.g. brcmfmac on Pi 5 does not offload the 4-way handshake), spawn +/// `wpa_supplicant` on the interface as a fallback. The returned child +/// must be killed by the caller on `release`. async fn associate( iface: String, if_index: u32, ssid: String, bssid: Option<[u8; 6]>, psk: Option, -) -> Result<(), DriverError> { +) -> Result, DriverError> { // `release` puts the link down, so without this a second job // would try to associate on a down interface and silently fail. set_link_up(if_index).await?; @@ -421,7 +430,7 @@ async fn associate( log::debug!("connect: CONNECT accepted for {ssid}"); if wait_associated(&iface, ASSOCIATE_DEADLINE).await { tokio::time::sleep(ASSOCIATE_SETTLE).await; - return Ok(()); + return Ok(None); } log::warn!("connect: {ssid} accepted but never gained carrier"); } @@ -450,10 +459,110 @@ async fn associate( iface_operstate(&iface), iface_carrier(&iface) ); + // Last resort: spawn wpa_supplicant on the programming interface. + // brcmfmac (Pi 5) does not offload the 4-way handshake from a PMK + // passed via nl80211 CONNECT, so the kernel never reaches carrier. + // wpa_supplicant runs in the foreground; the caller kills it on + // release so the radio is freed for the next job. + if let Some(ref passphrase) = psk { + log::info!("connect: {ssid} nl80211 CONNECT did not produce carrier; trying wpa_supplicant fallback"); + let child = spawn_wpa_supplicant(&iface, &ssid, passphrase).await?; + log::info!("connect: {ssid} associated via wpa_supplicant"); + return Ok(Some(child)); + } return Err(DriverError::AssociationTimedOut); } tokio::time::sleep(ASSOCIATE_SETTLE).await; - Ok(()) + Ok(None) +} + +/// Spawn `wpa_supplicant` on `iface` with a minimal config for `ssid`/`psk`, +/// then wait for carrier. Returns the running child so the caller can kill it +/// on `release`. The config file is written to a per-SSID temp directory and +/// removed when the child is killed. +async fn spawn_wpa_supplicant( + iface: &str, + ssid: &str, + psk: &str, +) -> Result { + // Disconnect via nl80211 first so the interface is not in a connecting + // state when wpa_supplicant takes over. + if let Ok((connection, handle, _)) = wl_nl80211::new_connection() { + tokio::spawn(connection); + use wl_nl80211::Nl80211Disconnect; + let attrs = Nl80211Disconnect::new(iface_to_index(iface).unwrap_or(0)).build(); + let mut stream = handle.connection().disconnect(attrs).execute().await; + use futures::stream::TryStreamExt; + let _ = stream.try_next().await; + } + + let dir = std::env::temp_dir().join(format!("wp-wpa-{}", sanitize(ssid))); + std::fs::create_dir_all(&dir) + .map_err(|e| DriverError::Other(format!("wpa_supplicant temp dir: {e}")))?; + let conf_path = dir.join("wpa_supplicant.conf"); + let config = format!( + "ctrl_interface=/dev/null\n\ + network={{\n ssid=\"{ssid}\"\n psk=\"{psk}\"\n}}\n" + ); + std::fs::write(&conf_path, &config) + .map_err(|e| DriverError::Other(format!("wpa_supplicant config: {e}")))?; + + log::info!("wpa_supplicant: spawning on {iface} for {ssid}"); + let mut child = std::process::Command::new("wpa_supplicant") + .args([ + "-i", + iface, + "-c", + conf_path.to_str().unwrap_or("wpa_supplicant.conf"), + "-C", + dir.join("ctrl").to_str().unwrap_or("/tmp/wp-wpa-ctrl"), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| DriverError::Other(format!("spawn wpa_supplicant: {e}")))?; + + // Wait for carrier (reuse the nl80211 deadline). + let deadline = ASSOCIATE_DEADLINE; + let start = std::time::Instant::now(); + loop { + if iface_carrier(iface).is_some() { + tokio::time::sleep(ASSOCIATE_SETTLE).await; + return Ok(child); + } + if start.elapsed() >= deadline { + log::warn!( + "wpa_supplicant: {ssid} no carrier after {:?}, killing", + deadline + ); + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&dir); + return Err(DriverError::AssociationTimedOut); + } + tokio::time::sleep(ASSOCIATE_POLL).await; + } +} + +/// Replace characters that are not safe in a path component with `_`. +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '.' { + c + } else { + '_' + } + }) + .collect() +} + +/// Look up an interface index by name via `/sys/class/net`. +fn iface_to_index(iface: &str) -> Option { + let path = format!("/sys/class/net/{iface}/ifindex"); + std::fs::read_to_string(&path) + .ok() + .and_then(|s| s.trim().parse::().ok()) } fn log_rfkill() { @@ -679,6 +788,7 @@ impl Nl80211Radio { saved_conf: crate::netcfg::SavedConf::default(), assigned: None, policy: None, + supplicant: Arc::new(std::sync::Mutex::new(None)), }) } @@ -704,7 +814,10 @@ impl Radio for Nl80211Radio { let if_index = self.if_index; let iface = self.iface.clone(); let ssid = ssid.to_string(); - Box::pin(async move { associate(iface, if_index, ssid, bssid, None).await }) + Box::pin(async move { + associate(iface, if_index, ssid, bssid, None).await?; + Ok(()) + }) } fn connect_psk(&mut self, ssid: &str, bssid: Option<[u8; 6]>, psk: &str) -> RadioFut<'_, ()> { @@ -712,7 +825,14 @@ impl Radio for Nl80211Radio { let iface = self.iface.clone(); let ssid = ssid.to_string(); let psk = psk.to_string(); - Box::pin(async move { associate(iface, if_index, ssid, bssid, Some(psk)).await }) + let supplicant = Arc::clone(&self.supplicant); + Box::pin(async move { + let child = associate(iface, if_index, ssid, bssid, Some(psk)).await?; + if let Some(child) = child { + *supplicant.lock().unwrap() = Some(child); + } + Ok(()) + }) } fn set_address(&mut self, addr: std::net::Ipv4Addr, prefix_len: u8) -> RadioFut<'_, ()> { @@ -777,9 +897,19 @@ impl Radio for Nl80211Radio { let iface = self.iface.clone(); let assigned = self.assigned.take(); let policy = self.policy.take(); + let supplicant = self.supplicant.lock().unwrap().take(); crate::netcfg::restore(&self.saved_conf); self.saved_conf = crate::netcfg::SavedConf::default(); Box::pin(async move { + // Kill the wpa_supplicant fallback first so it does not fight + // the nl80211 DISCONNECT below. + if let Some(mut child) = supplicant { + log::debug!("release: killing wpa_supplicant pid={}", child.id()); + let _ = child.kill(); + let _ = child.wait(); + let dir = std::env::temp_dir().join(format!("wp-wpa-{}", sanitize(&iface))); + let _ = std::fs::remove_dir_all(&dir); + } if let Some(ref route) = policy { crate::netcfg::remove_policy_route(route); } diff --git a/docs/drivers/rb23xx.md b/docs/drivers/rb23xx.md index 35e8127..a250255 100644 --- a/docs/drivers/rb23xx.md +++ b/docs/drivers/rb23xx.md @@ -6,8 +6,9 @@ sound decoders in Wi-Fi file-browser mode. ## Commissioning model With **F28** on, the decoder raises a **WPA2-PSK** Soft-AP named -`RB2300_XXXXX` (also `RB2310_` / `RB2302_`). The factory password is -`000000000` (never logged). The AP uses `192.168.4.1/24`. The daemon +`RB2300_XXXXX` (also `RB2310_` / `RB2302_`). The factory password +`000000000` is a publicly documented default for a temporary commissioning +AP, not a secret. The AP uses `192.168.4.1/24`. The daemon assigns `192.168.4.2/24` on the wireless interface (**no default route**), hands a sync `HttpClient` to the driver, and releases the radio on every exit path. @@ -45,6 +46,23 @@ Turning F28 on is out of scope for this driver. `GET /?p=/` confirms the file browser is up. Probe returns `{ "ok": true, "bytes": }`. +## WPA2-PSK join + +The daemon derives the PMK from the SSID and the factory passphrase +(`000000000`) via PBKDF2-HMAC-SHA1 (4096 rounds, 32-byte output) and passes +it to `NL80211_CMD_CONNECT` with `privacy`, `WPA2`, CCMP, and +`AkmSuite::Psk`. This lets firmware that offloads the 4-way handshake +complete association without a userspace supplicant. + +**Fallback.** brcmfmac on Raspberry Pi 5 does not offload the 4-way +handshake from a PMK passed via nl80211, so `CONNECT` never reaches +carrier. When that happens the daemon spawns `wpa_supplicant` on the +programming interface with a minimal config (SSID + PSK) for the duration +of the job. The supplicant runs in the foreground (not `-B`) and is killed +on `release`, so the radio is freed for the next job. The nl80211 path is +tried first; the fallback only fires when both nl80211 attempts (initial + +post-rescan) fail to produce carrier. + ## Firmware update Same HTTP contract as the `rb` CLI sound upload, aimed at the decoder