From 6608be47d85d818ee0ee1bc14d5d5c33d3de697b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:32:44 +0200 Subject: [PATCH 1/4] Add FRED programming via Z21 LAN LOCONET_DISPATCH_ADDR. Introduce wp-link Z21 client (mDNS _z21._udp plus UDP serial probe), driver fred (one DCC address, no Soft-AP), scan --mode z21, and runtime UDP dispatch without joining a programming AP. Co-authored-by: Cursor --- crates/wireless-programmer/src/cli/client.rs | 1 + crates/wireless-programmer/src/cli/mod.rs | 6 +- crates/wireless-programmer/src/cli/program.rs | 27 +- crates/wireless-programmer/src/drivers.rs | 28 +- crates/wireless-programmer/src/ipc.rs | 1 + crates/wireless-programmer/src/runtime.rs | 168 +++++++ .../tests/fake_mode_test.rs | 54 ++- crates/wp-client/src/client.rs | 2 +- crates/wp-drivers/Cargo.toml | 2 +- crates/wp-drivers/src/fred.rs | 145 ++++++ crates/wp-drivers/src/lib.rs | 2 + crates/wp-fake/src/lib.rs | 2 + crates/wp-fake/src/z21.rs | 152 ++++++ crates/wp-link/src/lib.rs | 9 +- crates/wp-link/src/mdns.rs | 18 +- crates/wp-link/src/z21.rs | 435 ++++++++++++++++++ crates/wp-proto/src/wire.rs | 14 +- docs/api.md | 21 +- docs/cli.md | 18 +- docs/drivers/fred.md | 66 +++ docs/go-client.md | 2 +- go/client/client.go | 2 +- 22 files changed, 1148 insertions(+), 27 deletions(-) create mode 100644 crates/wp-drivers/src/fred.rs create mode 100644 crates/wp-fake/src/z21.rs create mode 100644 crates/wp-link/src/z21.rs create mode 100644 docs/drivers/fred.md diff --git a/crates/wireless-programmer/src/cli/client.rs b/crates/wireless-programmer/src/cli/client.rs index 4209b4a..8242e65 100644 --- a/crates/wireless-programmer/src/cli/client.rs +++ b/crates/wireless-programmer/src/cli/client.rs @@ -111,6 +111,7 @@ fn parse_reach_mode(mode: &str) -> wp_client::ReachMode { match mode { "lan" => wp_client::ReachMode::Lan, "usb" => wp_client::ReachMode::Usb, + "z21" => wp_client::ReachMode::Z21, _ => wp_client::ReachMode::Ap, } } diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index 205e46a..a116bc9 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -98,8 +98,8 @@ pub struct CommonArgs { pub struct ScanArgs { #[command(flatten)] pub common: ClientCommon, - /// `ap` (Soft-AP radio, default), `lan` (mDNS `_longfred-ota._tcp`), or `usb`. - #[arg(long, default_value = "ap", value_parser = ["ap", "lan", "usb"])] + /// `ap` (Soft-AP radio, default), `lan` (mDNS `_longfred-ota._tcp`), `usb`, or `z21`. + #[arg(long, default_value = "ap", value_parser = ["ap", "lan", "usb", "z21"])] pub mode: String, } @@ -152,7 +152,7 @@ pub struct ProbeArgs { pub struct ProgramArgs { #[command(flatten)] pub common: ClientCommon, - /// Driver identifier. + /// Driver identifier (e.g. `wifred`, `longfred`, `fred`). #[arg(long)] pub driver: String, /// Candidate key. diff --git a/crates/wireless-programmer/src/cli/program.rs b/crates/wireless-programmer/src/cli/program.rs index 44b6ad0..25c1412 100644 --- a/crates/wireless-programmer/src/cli/program.rs +++ b/crates/wireless-programmer/src/cli/program.rs @@ -47,14 +47,27 @@ fn build_request(args: &ProgramArgs) -> Result { None => Vec::new(), }; - let wifi = WifiCredentialsWire { - ssid: required(args.wifi_ssid.clone(), "--wifi-ssid")?, - psk: wifi_psk(args)?, + let wifi = if args.driver == "fred" { + WifiCredentialsWire { + ssid: args.wifi_ssid.clone().unwrap_or_default(), + psk: wifi_psk(args).ok().flatten(), + } + } else { + WifiCredentialsWire { + ssid: required(args.wifi_ssid.clone(), "--wifi-ssid")?, + psk: wifi_psk(args)?, + } }; // With mDNS discovery the device finds the host itself, so a fixed host // and port stop being mandatory. - let server = if args.server_automatic { + let server = if args.driver == "fred" { + ThrottleServerWire { + host: args.server_host.clone().unwrap_or_default(), + port: args.server_port.unwrap_or(0), + automatic: None, + } + } else if args.server_automatic { ThrottleServerWire { host: args.server_host.clone().unwrap_or_default(), port: args.server_port.unwrap_or(DEFAULT_WITHROTTLE_PORT), @@ -69,7 +82,11 @@ fn build_request(args: &ProgramArgs) -> Result { }; Ok(ProgramRequestWire { - identity: required(args.identity.clone(), "--identity")?, + identity: if args.driver == "fred" { + args.identity.clone().unwrap_or_default() + } else { + required(args.identity.clone(), "--identity")? + }, wifi, server, roster, diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index 83bfc3e..907fb88 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -9,15 +9,17 @@ use wp_core::{ CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, Observation, Outcome, ProgramRequest, ProgressSink, Transport, }; -use wp_drivers::{LongFredDriver, WiFredDriver}; +use wp_drivers::{FredDriver, LongFredDriver, WiFredDriver}; /// All registered drivers. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Driver { /// NewHeiko WiFred. WiFred, /// LongFred Soft-AP programming. LongFred, + /// Digitrax FRED via Z21 LAN LocoNet dispatch. + Fred, } impl Driver { @@ -26,6 +28,7 @@ impl Driver { match self { Driver::WiFred => "wifred", Driver::LongFred => "longfred", + Driver::Fred => "fred", } } @@ -34,6 +37,7 @@ impl Driver { match self { Driver::WiFred => "NewHeiko WiFred", Driver::LongFred => "LongFred", + Driver::Fred => "Digitrax FRED", } } @@ -47,6 +51,12 @@ impl Driver { prefix: 24, }, Driver::LongFred => wp_drivers::longfred::commissioning_net(), + Driver::Fred => CommissioningNet { + host: Ipv4Addr::UNSPECIFIED, + port: 0, + source: Ipv4Addr::UNSPECIFIED, + prefix: 0, + }, } } @@ -55,6 +65,7 @@ impl Driver { match id { "wifred" => Some(Driver::WiFred), "longfred" => Some(Driver::LongFred), + "fred" => Some(Driver::Fred), _ => None, } } @@ -65,6 +76,7 @@ impl Driver { pub struct DriverRegistry { wifred: WiFredDriver, longfred: LongFredDriver, + fred: FredDriver, } impl DriverRegistry { @@ -73,6 +85,7 @@ impl DriverRegistry { Self { wifred: WiFredDriver::new(), longfred: LongFredDriver::new(), + fred: FredDriver::new(), } } @@ -81,6 +94,7 @@ impl DriverRegistry { vec![ (Driver::WiFred, self.wifred.capabilities()), (Driver::LongFred, self.longfred.capabilities()), + (Driver::Fred, self.fred.capabilities()), ] } @@ -117,6 +131,7 @@ impl DriverRegistry { match driver { Driver::WiFred => self.wifred.validate(req), Driver::LongFred => self.longfred.validate(req), + Driver::Fred => self.fred.validate(req), } } @@ -129,6 +144,7 @@ impl DriverRegistry { match driver { Driver::WiFred => self.wifred.probe(transport).await, Driver::LongFred => self.longfred.probe(transport).await, + Driver::Fred => self.fred.probe(transport).await, } } @@ -143,6 +159,7 @@ impl DriverRegistry { match driver { 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, } } @@ -151,6 +168,7 @@ impl DriverRegistry { match driver { Driver::WiFred => self.wifred.capabilities().supports_firmware_update, Driver::LongFred => self.longfred.capabilities().supports_firmware_update, + Driver::Fred => false, } } @@ -171,6 +189,9 @@ impl DriverRegistry { .update_firmware(transport, image, progress) .await } + Driver::Fred => Err(DriverError::Other( + "firmware update is not supported".into(), + )), } } @@ -199,6 +220,9 @@ impl DriverRegistry { Driver::LongFred => Err(DriverError::Other( "LongFred has no LED identify in programming mode".into(), )), + Driver::Fred => Err(DriverError::Other( + "FRED has no LED identify over Z21 LAN".into(), + )), } } diff --git a/crates/wireless-programmer/src/ipc.rs b/crates/wireless-programmer/src/ipc.rs index 930f5c9..30d757c 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -228,6 +228,7 @@ impl ServerInner { let scanned = match mode { wp_proto::ReachMode::Lan => self.runtime.scan_lan(), wp_proto::ReachMode::Usb => self.runtime.scan_usb(), + wp_proto::ReachMode::Z21 => self.runtime.scan_z21(), wp_proto::ReachMode::Ap => self.runtime.scan(), }; match scanned { diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index 4e3a291..74bc23e 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -193,6 +193,46 @@ impl Runtime { Ok(out) } + /// Discover Z21 LAN command stations (mDNS `_z21._udp` + UDP serial probe). + pub fn scan_z21(&self) -> Result, wp_core::DriverError> { + let hosts = wp_link::discover_z21(Duration::from_millis(1500)) + .map_err(|e| wp_core::DriverError::Other(format!("z21 scan: {e}")))?; + let mut out = Vec::new(); + let mut cache = self.cache.lock(); + cache.retain(|_, v| v.mode != ReachMode::Z21); + for h in hosts { + let key = h.key(); + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::Fred.id_str().into(), + key: key.clone(), + label: h.label(), + rssi: None, + mode: ReachMode::Z21, + }; + cache.insert((cached.driver.clone(), key), cached.clone()); + out.push(cached); + } + Ok(out) + } + + /// Remember a Z21 `host:port` so `program` can skip `scan --mode z21`. + pub fn cache_z21_host(&self, key: &str, label: Option<&str>) { + let cached = CachedCandidate { + ssid: String::new(), + bssid: None, + driver: Driver::Fred.id_str().into(), + key: key.to_string(), + label: label.unwrap_or(key).to_string(), + rssi: None, + mode: ReachMode::Z21, + }; + self.cache + .lock() + .insert((cached.driver.clone(), cached.key.clone()), cached); + } + /// Remember a USB serial device so `updateFirmware` can skip scan when `--port` is set. pub fn cache_usb_port(&self, port: &str, label: Option<&str>) { let cached = CachedCandidate { @@ -282,6 +322,10 @@ impl Runtime { let borrowed = owned.borrow(); self.registry.validate(driver, &borrowed)?; + if driver == Driver::Fred && self.cached(driver.id_str(), key).is_none() { + self.cache_z21_host(key, None); + } + let id = self.jobs.submit( driver.id_str(), key, @@ -641,6 +685,115 @@ async fn run_job(rt: &Runtime, id: JobId) { } } +async fn run_fred_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire, key: &str) { + let Some(target) = wp_link::Z21Host::parse_key(key) else { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("invalid Z21 address (want host:port)"), + ); + return; + }; + let Some(loco) = wire + .roster + .first() + .and_then(|e| e.address) + .filter(|a| (1..=10239).contains(a)) + else { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("fred programming needs exactly one DCC address 1..=10239"), + ); + return; + }; + + if rt.jobs.is_cancelled(&id) { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + rt.jobs + .transition(&id, JobState::Writing, Some("write"), Some(10), None); + + let sock = match std::net::UdpSocket::bind("0.0.0.0:0") { + Ok(s) => s, + Err(e) => { + rt.jobs.transition( + &id, + JobState::Failed, + Some("write"), + None, + Some(&format!("udp bind: {e}")), + ); + return; + } + }; + + let outcome = tokio::task::spawn_blocking(move || wp_link::dispatch_addr(&sock, target, loco)) + .await + .unwrap_or_else(|e| Err(wp_link::DispatchError::Io(e.to_string()))); + + if rt.jobs.is_cancelled(&id) { + rt.jobs + .transition(&id, JobState::Cancelled, None, None, Some("cancelled")); + return; + } + + match outcome { + Ok(wp_link::DispatchOutcome::Slot(slot)) => { + rt.jobs.transition( + &id, + JobState::Done, + Some("done"), + Some(100), + Some(&format!("slot {slot}")), + ); + } + Ok(wp_link::DispatchOutcome::NoAck) => { + rt.jobs.transition( + &id, + JobState::Done, + Some("done"), + Some(100), + Some("noAck"), + ); + } + Err(wp_link::DispatchError::Rejected) => { + rt.jobs.transition( + &id, + JobState::Failed, + Some("write"), + None, + Some("dispatchFailed"), + ); + } + Err(wp_link::DispatchError::UnknownCommand) => { + rt.jobs.transition( + &id, + JobState::Failed, + Some("write"), + None, + Some("z21NoLocoNet"), + ); + } + Err(e) => { + rt.jobs.transition( + &id, + JobState::Failed, + Some("write"), + None, + Some(&e.to_string()), + ); + } + } +} + async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { let snap = match rt.jobs.snapshot(&id) { Some(s) => s, @@ -687,6 +840,11 @@ async fn run_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire) { "job started" ); + if driver == Driver::Fred { + run_fred_program_job(rt, id, wire, &candidate.key).await; + return; + } + let owned = OwnedRequest::from_wire(wire); let net = rt.effective_net(driver); @@ -1020,6 +1178,16 @@ async fn run_firmware_job(rt: &Runtime, id: JobId, job: crate::jobs::FirmwareJob ) .await } + ReachMode::Z21 => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some("firmware update is not supported over Z21 LAN"), + ); + return; + } ReachMode::Ap => { let candidate = match rt.cached(&snap.driver, &snap.key) { Some(c) => c, diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs index 21b535d..9a26c55 100644 --- a/crates/wireless-programmer/tests/fake_mode_test.rs +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -4,7 +4,7 @@ use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use wp_fake::{CompositeFakeDevice, FakeRadio}; +use wp_fake::{CompositeFakeDevice, FakeRadio, FakeZ21, FakeZ21Mode}; use wp_proto::{ProgramRequestWire, RosterEntryWire, ThrottleServerWire, WifiCredentialsWire}; use wireless_programmer::config::Config; @@ -108,6 +108,23 @@ fn longfred_request() -> ProgramRequestWire { } } +fn fred_request(addr: u16) -> ProgramRequestWire { + ProgramRequestWire { + identity: String::new(), + wifi: WifiCredentialsWire::default(), + server: ThrottleServerWire::default(), + roster: vec![RosterEntryWire { + address: Some(addr), + long_address: None, + mode: None, + direction: None, + functions: Vec::new(), + }], + bigfred: None, + roster_mode: None, + } +} + fn wait_terminal(rt: &Runtime, id: &wireless_programmer::jobs::JobId) -> JobState { let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { @@ -189,3 +206,38 @@ fn fake_identify_wifred() { rt.identify(Driver::WiFred, &c.key, Some(3)) .expect("identify"); } + +#[test] +fn fake_program_fred_dispatch_reaches_done() { + let fake = FakeZ21::spawn(FakeZ21Mode::Accept).unwrap(); + let rt = setup_runtime(); + let key = fake.addr().to_string(); + let id = rt + .submit_program(Driver::Fred, &key, fred_request(42)) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + assert_eq!(state, JobState::Done, "detail={snap:?}"); + assert_eq!( + snap.as_ref().and_then(|s| s.detail.as_deref()), + Some("slot 3") + ); + assert!(fake.dispatch_count() >= 1); +} + +#[test] +fn fake_program_fred_reject_fails() { + let fake = FakeZ21::spawn(FakeZ21Mode::Reject).unwrap(); + let rt = setup_runtime(); + let key = fake.addr().to_string(); + let id = rt + .submit_program(Driver::Fred, &key, fred_request(7)) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + assert_eq!(state, JobState::Failed, "detail={snap:?}"); + assert_eq!( + snap.as_ref().and_then(|s| s.detail.as_deref()), + Some("dispatchFailed") + ); +} diff --git a/crates/wp-client/src/client.rs b/crates/wp-client/src/client.rs index 923ef99..7e9d4aa 100644 --- a/crates/wp-client/src/client.rs +++ b/crates/wp-client/src/client.rs @@ -115,7 +115,7 @@ impl Client { self.scan_mode(wp_proto::ReachMode::Ap) } - /// `scan` with an explicit reach mode (`ap` or `lan`). + /// `scan` with an explicit reach mode (`ap`, `lan`, `usb`, or `z21`). pub fn scan_mode(&self, mode: wp_proto::ReachMode) -> Result, ClientError> { let params = if mode == wp_proto::ReachMode::Ap { Some(Params::None) diff --git a/crates/wp-drivers/Cargo.toml b/crates/wp-drivers/Cargo.toml index 228d51a..8f668cb 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)" +description = "Device driver implementations for wireless-programmer (WiFred, LongFred, FRED)" [lib] name = "wp_drivers" diff --git a/crates/wp-drivers/src/fred.rs b/crates/wp-drivers/src/fred.rs new file mode 100644 index 0000000..52eb753 --- /dev/null +++ b/crates/wp-drivers/src/fred.rs @@ -0,0 +1,145 @@ +//! Digitrax FRED (wired LocoNet throttle) via Z21 `LAN_LOCONET_DISPATCH_ADDR`. +//! +//! The FRED itself has no Wi‑Fi page. Programming is DISPATCH_PUT of one DCC +//! address on a Z21-LAN command station that is LocoNet master. The daemon +//! owns the UDP socket; this driver only validates the roster. + +use wp_core::{ + DeviceCandidate, DeviceDriver, DriverCapabilities, DriverError, DriverId, IdentityFormat, + Observation, Outcome, ProgressSink, ScanFilters, Transport, ValidationError, +}; + +/// Digitrax FRED driver. +#[derive(Debug, Default)] +pub struct FredDriver; + +impl FredDriver { + /// Construct a new driver instance. + pub const fn new() -> Self { + Self + } +} + +const ID: DriverId = DriverId::new("fred"); + +impl DeviceDriver for FredDriver { + fn id(&self) -> DriverId { + ID + } + + fn name(&self) -> &'static str { + "Digitrax FRED" + } + + fn capabilities(&self) -> DriverCapabilities { + DriverCapabilities { + max_roster_slots: 1, + max_function_index: 0, + identity_format: IdentityFormat::Any, + supports_throttle_server: false, + commissioning: wp_core::CommissioningKind::Lan, + supports_firmware_update: false, + commissioning_net: None, + } + } + + fn scan_filters(&self) -> ScanFilters { + ScanFilters::default() + } + + fn identify(&self, _obs: &Observation) -> Option { + None + } + + fn validate(&self, req: &wp_core::ProgramRequest<'_>) -> Result<(), ValidationError> { + if req.roster.len() != 1 { + return Err(ValidationError::CapacityExceeded { + capacity: 1, + requested: req.roster.len(), + }); + } + match req.roster[0].address { + Some(addr) if (1..=10239).contains(&addr) => Ok(()), + Some(addr) => Err(ValidationError::AddressOutOfRange { addr }), + None => Err(ValidationError::RosterEntry { + slot: 0, + reason: "missing address", + }), + } + } + + fn probe( + &self, + _transport: Transport<'_>, + ) -> impl std::future::Future> { + async { Ok(serde_json::json!({ "driver": "fred" })) } + } + + fn program( + &self, + _transport: Transport<'_>, + _req: &wp_core::ProgramRequest<'_>, + _progress: &mut dyn ProgressSink, + ) -> impl std::future::Future> { + async { + Err(DriverError::Other( + "fred programming uses Z21 UDP, not HTTP/serial".into(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_core::{ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials}; + + fn req(addr: Option) -> ProgramRequest<'static> { + ProgramRequest { + identity: "", + wifi: WifiCredentials { + ssid: "", + psk: None, + }, + server: ThrottleServer { + host: "", + port: 0, + automatic: false, + }, + roster: vec![RosterEntry { + address: addr, + long_address: None, + mode: None, + direction: None, + functions: Vec::new(), + }], + bigfred: None, + roster_mode: None, + } + } + + #[test] + fn validate_requires_one_in_range_address() { + let d = FredDriver::new(); + assert!(d.validate(&req(Some(42))).is_ok()); + assert!(matches!( + d.validate(&req(Some(0))), + Err(ValidationError::AddressOutOfRange { addr: 0 }) + )); + assert!(matches!( + d.validate(&req(None)), + Err(ValidationError::RosterEntry { slot: 0, .. }) + )); + let empty = ProgramRequest { + roster: Vec::new(), + ..req(Some(1)) + }; + assert!(matches!( + d.validate(&empty), + Err(ValidationError::CapacityExceeded { + capacity: 1, + requested: 0 + }) + )); + } +} diff --git a/crates/wp-drivers/src/lib.rs b/crates/wp-drivers/src/lib.rs index a6e3d64..e1ca0de 100644 --- a/crates/wp-drivers/src/lib.rs +++ b/crates/wp-drivers/src/lib.rs @@ -2,8 +2,10 @@ #![forbid(unsafe_code)] +pub mod fred; pub mod longfred; pub mod wifred; +pub use fred::FredDriver; pub use longfred::LongFredDriver; pub use wifred::{Direction, FunctionInfo, WiFredDriver}; diff --git a/crates/wp-fake/src/lib.rs b/crates/wp-fake/src/lib.rs index b81cc63..45951e0 100644 --- a/crates/wp-fake/src/lib.rs +++ b/crates/wp-fake/src/lib.rs @@ -8,6 +8,7 @@ mod longfred; mod radio; mod server; mod wifred; +mod z21; pub use composite::CompositeFakeDevice; pub use device::{not_found, ok_json, ok_text, ok_xml, FakeDevice, FakeRequest, FakeResponse}; @@ -15,3 +16,4 @@ pub use longfred::LongFredFake; pub use radio::FakeRadio; pub use server::{bind_and_serve, FakeHttpServer}; pub use wifred::WifredFake; +pub use z21::{FakeZ21, FakeZ21Mode}; diff --git a/crates/wp-fake/src/z21.rs b/crates/wp-fake/src/z21.rs new file mode 100644 index 0000000..0e6de2f --- /dev/null +++ b/crates/wp-fake/src/z21.rs @@ -0,0 +1,152 @@ +//! In-process UDP Z21 mock for FRED dispatch tests. + +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use wp_link::z21::{ + encode, parse_records, HEADER_GET_SERIAL_NUMBER, HEADER_LOCONET_DISPATCH_ADDR, HEADER_XBUS, +}; + +/// Behaviour of [`FakeZ21`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FakeZ21Mode { + /// Reply to DISPATCH_ADDR with `result > 0`. + Accept, + /// Reply with `result = 0`. + Reject, + /// Reply `LAN_X_UNKNOWN_COMMAND`. + UnknownCommand, + /// Answer serial probe, ignore DISPATCH (FW < 1.22). + NoAck, +} + +/// UDP Z21 mock. Dropping the handle stops the background thread. +pub struct FakeZ21 { + addr: SocketAddr, + stop: Arc, + dispatches: Arc, +} + +impl FakeZ21 { + /// Bind `127.0.0.1:0` and serve `mode`. + pub fn spawn(mode: FakeZ21Mode) -> std::io::Result { + let sock = UdpSocket::bind("127.0.0.1:0")?; + sock.set_read_timeout(Some(Duration::from_millis(100)))?; + let addr = sock.local_addr()?; + let stop = Arc::new(AtomicBool::new(false)); + let dispatches = Arc::new(AtomicU8::new(0)); + let stop_t = Arc::clone(&stop); + let disp_t = Arc::clone(&dispatches); + thread::spawn(move || loop { + if stop_t.load(Ordering::Relaxed) { + break; + } + let mut buf = [0u8; 1500]; + match sock.recv_from(&mut buf) { + Ok((n, from)) => { + for rec in parse_records(&buf[..n]) { + let reply = match rec.header { + HEADER_GET_SERIAL_NUMBER => { + Some(encode(HEADER_GET_SERIAL_NUMBER, &0x00C0_FFEEu32.to_le_bytes())) + } + HEADER_LOCONET_DISPATCH_ADDR => { + disp_t.fetch_add(1, Ordering::Relaxed); + let loco = if rec.data.len() >= 2 { + u16::from_le_bytes([rec.data[0], rec.data[1]]) + } else { + 0 + }; + match mode { + FakeZ21Mode::Accept => { + let mut data = loco.to_le_bytes().to_vec(); + data.push(3); + Some(encode(HEADER_LOCONET_DISPATCH_ADDR, &data)) + } + FakeZ21Mode::Reject => { + let mut data = loco.to_le_bytes().to_vec(); + data.push(0); + Some(encode(HEADER_LOCONET_DISPATCH_ADDR, &data)) + } + FakeZ21Mode::UnknownCommand => { + Some(encode(HEADER_XBUS, &[0x61, 0x82, 0xE3])) + } + FakeZ21Mode::NoAck => None, + } + } + _ => None, + }; + if let Some(pkt) = reply { + let _ = sock.send_to(&pkt, from); + } + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(_) => break, + } + }); + Ok(Self { + addr, + stop, + dispatches, + }) + } + + /// Bound address. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// How many DISPATCH_ADDR requests were seen. + pub fn dispatch_count(&self) -> u8 { + self.dispatches.load(Ordering::Relaxed) + } +} + +impl Drop for FakeZ21 { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wp_link::{dispatch_addr, DispatchOutcome}; + + #[test] + fn accept_returns_slot() { + let fake = FakeZ21::spawn(FakeZ21Mode::Accept).unwrap(); + let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let out = dispatch_addr(&sock, fake.addr(), 42).unwrap(); + assert_eq!(out, DispatchOutcome::Slot(3)); + } + + #[test] + fn reject_is_error() { + let fake = FakeZ21::spawn(FakeZ21Mode::Reject).unwrap(); + let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let err = dispatch_addr(&sock, fake.addr(), 7).unwrap_err(); + assert!(matches!(err, wp_link::DispatchError::Rejected)); + } + + #[test] + fn unknown_command_is_error() { + let fake = FakeZ21::spawn(FakeZ21Mode::UnknownCommand).unwrap(); + let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let err = dispatch_addr(&sock, fake.addr(), 7).unwrap_err(); + assert!(matches!(err, wp_link::DispatchError::UnknownCommand)); + } + + #[test] + fn no_ack_is_success_after_serial() { + let fake = FakeZ21::spawn(FakeZ21Mode::NoAck).unwrap(); + let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); + let out = dispatch_addr(&sock, fake.addr(), 99).unwrap(); + assert_eq!(out, DispatchOutcome::NoAck); + } +} diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 430d25a..67f1000 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -8,6 +8,7 @@ pub mod http; pub mod mdns; pub mod radio; pub mod rfkill; +pub mod z21; pub use espflash::{ classify_image, flash as flash_usb, flash_argv, list_usb_ports, parse_list_ports_output, @@ -15,7 +16,13 @@ pub use espflash::{ }; pub use http::{percent_encode, BoundedHttpClient, MAX_BODY_BYTES}; -pub use mdns::{discover_ota_hosts, parse_ota_hosts, OtaHost, OTA_HTTP_SERVICE}; +pub use mdns::{ + discover_mdns_hosts, discover_ota_hosts, parse_ota_hosts, OtaHost, OTA_HTTP_SERVICE, + Z21_UDP_SERVICE, +}; +pub use z21::{ + discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT, +}; pub use radio::{ first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, diff --git a/crates/wp-link/src/mdns.rs b/crates/wp-link/src/mdns.rs index 4bafda7..f971b2a 100644 --- a/crates/wp-link/src/mdns.rs +++ b/crates/wp-link/src/mdns.rs @@ -13,6 +13,9 @@ use socket2::{Domain, Protocol, Socket, Type}; /// LongFred STA HTTP OTA service. pub const OTA_HTTP_SERVICE: &str = "_longfred-ota._tcp.local"; +/// Z21 LAN protocol DNS-SD type (Roco Z21 / BigFred inbound / RB1110). +pub const Z21_UDP_SERVICE: &str = "_z21._udp.local"; + const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); const MDNS_PORT: u16 = 5353; const TYPE_A: u16 = 1; @@ -51,8 +54,18 @@ fn mdns_listener() -> std::io::Result { /// Returns [`std::io::Error`] on socket failure (including inability to bind /// UDP 5353). pub fn discover_ota_hosts(wait: Duration) -> std::io::Result> { + discover_mdns_hosts(OTA_HTTP_SERVICE, wait) +} + +/// PTR-query `service` (e.g. [`Z21_UDP_SERVICE`]) and collect A/SRV answers. +/// +/// # Errors +/// +/// Returns [`std::io::Error`] on socket failure (including inability to bind +/// UDP 5353). +pub fn discover_mdns_hosts(service: &str, wait: Duration) -> std::io::Result> { let sock = mdns_listener()?; - let q = ptr_query(OTA_HTTP_SERVICE); + let q = ptr_query(service); let _ = sock.send_to(&q, SocketAddrV4::new(MDNS_GROUP, MDNS_PORT)); let deadline = Instant::now() + wait; @@ -233,6 +246,9 @@ mod tests { assert!(q .windows(b"_longfred-ota".len()) .any(|w| w == b"_longfred-ota")); + let z21 = ptr_query(Z21_UDP_SERVICE); + assert!(z21.windows(b"_z21".len()).any(|w| w == b"_z21")); + assert!(z21.windows(b"_udp".len()).any(|w| w == b"_udp")); } #[test] diff --git a/crates/wp-link/src/z21.rs b/crates/wp-link/src/z21.rs new file mode 100644 index 0000000..804310e --- /dev/null +++ b/crates/wp-link/src/z21.rs @@ -0,0 +1,435 @@ +//! Minimal Z21 LAN (UDP) client: framing, serial probe, LocoNet dispatch. +//! +//! The daemon owns the socket (guidelines §1.2). This module only encodes +//! packets and drives request/response on a caller-supplied [`UdpSocket`]. + +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}; +use std::time::{Duration, Instant}; + +use crate::mdns::{discover_mdns_hosts, OtaHost, Z21_UDP_SERVICE}; + +/// Default Z21 LAN port (spec §1.1). +pub const Z21_UDP_PORT: u16 = 21105; +/// Alternate Z21 LAN port (spec §1.1). +pub const Z21_UDP_PORT_ALT: u16 = 21106; + +/// `LAN_GET_SERIAL_NUMBER` header. +pub const HEADER_GET_SERIAL_NUMBER: u16 = 0x0010; +/// `LAN_LOGOFF` header. +pub const HEADER_LOGOFF: u16 = 0x0030; +/// X-BUS tunnel header (`LAN_X_*`). +pub const HEADER_XBUS: u16 = 0x0040; +/// `LAN_LOCONET_DISPATCH_ADDR` header. +pub const HEADER_LOCONET_DISPATCH_ADDR: u16 = 0x00A3; + +/// How long to wait for a DISPATCH_ADDR reply. +pub const DISPATCH_TIMEOUT: Duration = Duration::from_secs(2); +/// How long to wait for the serial-number login probe. +pub const SERIAL_PROBE_TIMEOUT: Duration = Duration::from_millis(500); + +/// One Z21 LAN record (`DataLen` + `Header` + `Data`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Record { + /// Header (little-endian on the wire). + pub header: u16, + /// Payload after the 4-byte header. + pub data: Vec, +} + +/// A Z21 command station found on the LAN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Z21Host { + /// mDNS instance label, when known. + pub hostname: String, + /// IPv4. + pub ipv4: Ipv4Addr, + /// UDP port (typically 21105). + pub port: u16, + /// Serial from `LAN_GET_SERIAL_NUMBER`, when the UDP probe got a reply. + pub serial: Option, +} + +impl Z21Host { + /// Candidate key `ip:port`. + pub fn key(&self) -> String { + format!("{}:{}", self.ipv4, self.port) + } + + /// Human-readable scan label. + pub fn label(&self) -> String { + let addr = self.key(); + if self.hostname.is_empty() || self.hostname == addr { + match self.serial { + Some(s) => format!("Z21 {addr} (serial {s})"), + None => format!("Z21 {addr}"), + } + } else { + match self.serial { + Some(s) => format!("{} ({addr}, serial {s})", self.hostname), + None => format!("{} ({addr})", self.hostname), + } + } + } + + /// Parse a `host:port` candidate key. + pub fn parse_key(key: &str) -> Option { + key.parse().ok().or_else(|| { + let ip: Ipv4Addr = key.parse().ok()?; + Some(SocketAddr::V4(SocketAddrV4::new(ip, Z21_UDP_PORT))) + }) + } +} + +/// Outcome of `LAN_LOCONET_DISPATCH_ADDR`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DispatchOutcome { + /// FW ≥ 1.22: DISPATCH_PUT succeeded; value is the LocoNet slot. + Slot(u8), + /// Z21 answered the serial probe but sent no `0xA3` reply (FW < 1.22). + NoAck, +} + +/// Encode a Z21 record. +pub fn encode(header: u16, data: &[u8]) -> Vec { + let len = 4u16.saturating_add(u16::try_from(data.len()).unwrap_or(u16::MAX)); + let mut out = Vec::with_capacity(usize::from(len)); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&header.to_le_bytes()); + out.extend_from_slice(data); + out +} + +/// `LAN_GET_SERIAL_NUMBER` request (also used as a UDP discovery probe). +pub fn serial_number_request() -> Vec { + encode(HEADER_GET_SERIAL_NUMBER, &[]) +} + +/// `LAN_LOCONET_DISPATCH_ADDR` request: 16-bit loco address, little-endian. +pub fn dispatch_addr_request(loco: u16) -> Vec { + encode(HEADER_LOCONET_DISPATCH_ADDR, &loco.to_le_bytes()) +} + +/// `LAN_LOGOFF` request. +pub fn logoff_request() -> Vec { + encode(HEADER_LOGOFF, &[]) +} + +/// Walk concatenated Z21 records in one UDP datagram. +pub fn parse_records(buf: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut off = 0usize; + while off + 4 <= buf.len() { + let data_len = u16::from_le_bytes([buf[off], buf[off + 1]]) as usize; + let header = u16::from_le_bytes([buf[off + 2], buf[off + 3]]); + if data_len < 4 || off + data_len > buf.len() { + break; + } + out.push(Record { + header, + data: buf[off + 4..off + data_len].to_vec(), + }); + off += data_len; + } + out +} + +/// Serial number from a `LAN_GET_SERIAL_NUMBER` reply payload. +pub fn parse_serial(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + Some(u32::from_le_bytes([data[0], data[1], data[2], data[3]])) +} + +/// `true` when this X-BUS record is `LAN_X_UNKNOWN_COMMAND`. +pub fn is_unknown_command(rec: &Record) -> bool { + rec.header == HEADER_XBUS && rec.data.len() >= 2 && rec.data[0] == 0x61 && rec.data[1] == 0x82 +} + +/// Parse a DISPATCH_ADDR reply: `(loco, result)`. +/// +/// `result == 0` means DISPATCH_PUT failed. `result > 0` is the slot number. +pub fn parse_dispatch_reply(data: &[u8]) -> Option<(u16, u8)> { + if data.len() < 3 { + return None; + } + let loco = u16::from_le_bytes([data[0], data[1]]); + Some((loco, data[2])) +} + +/// Discover Z21 LAN endpoints: mDNS `_z21._udp` plus UDP serial broadcast. +/// +/// # Errors +/// +/// Returns [`std::io::Error`] when both the mDNS socket and the UDP probe +/// socket fail to bind. An empty result is success. +pub fn discover_z21(wait: Duration) -> std::io::Result> { + let mut found: Vec = Vec::new(); + + match discover_mdns_hosts(Z21_UDP_SERVICE, wait) { + Ok(hosts) => { + for h in hosts { + push_host( + &mut found, + Z21Host { + hostname: h.hostname, + ipv4: h.ipv4, + port: if h.port == 0 { Z21_UDP_PORT } else { h.port }, + serial: None, + }, + ); + } + } + Err(e) => log::debug!("z21 mdns: {e}"), + } + + match probe_serial_broadcast(wait) { + Ok(hosts) => { + for h in hosts { + push_host(&mut found, h); + } + } + Err(e) => log::debug!("z21 udp probe: {e}"), + } + + Ok(found) +} + +fn push_host(found: &mut Vec, host: Z21Host) { + if let Some(existing) = found + .iter_mut() + .find(|e| e.ipv4 == host.ipv4 && e.port == host.port) + { + if existing.hostname.is_empty() { + existing.hostname.clone_from(&host.hostname); + } + if existing.serial.is_none() { + existing.serial = host.serial; + } + return; + } + found.push(host); +} + +/// Broadcast `LAN_GET_SERIAL_NUMBER` to 21105/21106 and collect replies. +fn probe_serial_broadcast(wait: Duration) -> std::io::Result> { + let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))?; + sock.set_broadcast(true)?; + sock.set_read_timeout(Some(Duration::from_millis(200)))?; + let req = serial_number_request(); + let _ = sock.send_to( + &req, + SocketAddrV4::new(Ipv4Addr::BROADCAST, Z21_UDP_PORT), + ); + let _ = sock.send_to( + &req, + SocketAddrV4::new(Ipv4Addr::BROADCAST, Z21_UDP_PORT_ALT), + ); + + let deadline = Instant::now() + wait; + let mut found = Vec::new(); + let mut buf = [0u8; 1500]; + while Instant::now() < deadline { + match sock.recv_from(&mut buf) { + Ok((n, from)) => { + let SocketAddr::V4(v4) = from else { + continue; + }; + for rec in parse_records(&buf[..n]) { + if rec.header != HEADER_GET_SERIAL_NUMBER { + continue; + } + push_host( + &mut found, + Z21Host { + hostname: String::new(), + ipv4: *v4.ip(), + port: v4.port(), + serial: parse_serial(&rec.data), + }, + ); + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(e), + } + } + Ok(found) +} + +/// Send DISPATCH_PUT for `loco` to `target`. +/// +/// # Errors +/// +/// Socket, timeout with no prior serial reply, DISPATCH_PUT rejected +/// (`result == 0`), or `LAN_X_UNKNOWN_COMMAND`. +pub fn dispatch_addr( + sock: &UdpSocket, + target: SocketAddr, + loco: u16, +) -> Result { + sock.set_read_timeout(Some(Duration::from_millis(200)))?; + let _ = sock.send_to(&serial_number_request(), target); + + let mut z21_alive = false; + let serial_deadline = Instant::now() + SERIAL_PROBE_TIMEOUT; + let mut buf = [0u8; 1500]; + while Instant::now() < serial_deadline { + match sock.recv_from(&mut buf) { + Ok((n, from)) if from == target || from.ip() == target.ip() => { + for rec in parse_records(&buf[..n]) { + if rec.header == HEADER_GET_SERIAL_NUMBER { + z21_alive = true; + } + if is_unknown_command(&rec) { + return Err(DispatchError::UnknownCommand); + } + } + } + Ok(_) => {} + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(DispatchError::Io(e.to_string())), + } + if z21_alive { + break; + } + } + + let _ = sock.send_to(&dispatch_addr_request(loco), target); + let deadline = Instant::now() + DISPATCH_TIMEOUT; + while Instant::now() < deadline { + match sock.recv_from(&mut buf) { + Ok((n, from)) if from == target || from.ip() == target.ip() => { + for rec in parse_records(&buf[..n]) { + if is_unknown_command(&rec) { + return Err(DispatchError::UnknownCommand); + } + if rec.header != HEADER_LOCONET_DISPATCH_ADDR { + continue; + } + let Some((addr, result)) = parse_dispatch_reply(&rec.data) else { + continue; + }; + if addr != loco { + continue; + } + if result == 0 { + return Err(DispatchError::Rejected); + } + let _ = sock.send_to(&logoff_request(), target); + return Ok(DispatchOutcome::Slot(result)); + } + } + Ok(_) => {} + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(DispatchError::Io(e.to_string())), + } + } + + let _ = sock.send_to(&logoff_request(), target); + if z21_alive { + Ok(DispatchOutcome::NoAck) + } else { + Err(DispatchError::Unreachable(target.to_string())) + } +} + +/// Failures from [`dispatch_addr`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DispatchError { + /// No serial reply and no DISPATCH reply. + Unreachable(String), + /// `Result = 0` — DISPATCH_PUT rejected (Z21 is slave / slot busy). + Rejected, + /// Z21 does not speak LocoNet dispatch (`LAN_X_UNKNOWN_COMMAND`). + UnknownCommand, + /// Socket I/O. + Io(String), +} + +impl std::fmt::Display for DispatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unreachable(a) => write!(f, "device unreachable: {a}"), + Self::Rejected => write!(f, "DISPATCH_PUT rejected"), + Self::UnknownCommand => write!(f, "z21 does not support LocoNet dispatch"), + Self::Io(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for DispatchError {} + +impl From for DispatchError { + fn from(e: std::io::Error) -> Self { + Self::Io(e.to_string()) + } +} + +/// Re-export so callers can label mDNS-only hits without a second type. +pub type MdnsHost = OtaHost; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_dispatch_matches_spec() { + let pkt = dispatch_addr_request(42); + assert_eq!( + pkt, + vec![0x06, 0x00, 0xA3, 0x00, 42, 0x00] + ); + } + + #[test] + fn encode_serial_request() { + assert_eq!(serial_number_request(), vec![0x04, 0x00, 0x10, 0x00]); + } + + #[test] + fn parse_concatenated_records() { + let mut buf = serial_number_request(); + buf.extend_from_slice(&encode( + HEADER_GET_SERIAL_NUMBER, + &0x1234_5678u32.to_le_bytes(), + )); + let recs = parse_records(&buf); + assert_eq!(recs.len(), 2); + assert_eq!(parse_serial(&recs[1].data), Some(0x1234_5678)); + } + + #[test] + fn parse_dispatch_ok_and_fail() { + assert_eq!(parse_dispatch_reply(&[0x2A, 0x00, 0x03]), Some((42, 3))); + assert_eq!(parse_dispatch_reply(&[0x2A, 0x00, 0x00]), Some((42, 0))); + assert_eq!(parse_dispatch_reply(&[0x2A]), None); + } + + #[test] + fn unknown_command_detect() { + let rec = Record { + header: HEADER_XBUS, + data: vec![0x61, 0x82, 0xE3], + }; + assert!(is_unknown_command(&rec)); + assert!(!is_unknown_command(&Record { + header: HEADER_XBUS, + data: vec![0x61, 0x00, 0x61], + })); + } + + #[test] + fn parse_key_with_and_without_port() { + let with = Z21Host::parse_key("192.168.0.111:21105").unwrap(); + assert_eq!(with.to_string(), "192.168.0.111:21105"); + let bare = Z21Host::parse_key("10.0.0.5").unwrap(); + assert_eq!(bare.to_string(), "10.0.0.5:21105"); + } +} diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index 4f5cd83..a4df6a9 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -151,13 +151,16 @@ pub enum ReachMode { Lan, /// USB serial via `espflash` (`--port` / `scan --mode usb`). Usb, + /// Z21 LAN command stations (`scan --mode z21`): mDNS `_z21._udp` + UDP probe. + Z21, } /// `scan` parameters. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScanParams { - /// Soft-AP radio scan (`ap`, default), LAN mDNS (`lan`), or USB serial (`usb`). + /// Soft-AP radio scan (`ap`, default), LAN mDNS (`lan`), USB serial (`usb`), + /// or Z21 LAN (`z21`). #[serde(default)] pub mode: ReachMode, } @@ -202,10 +205,13 @@ pub struct CandidateRef { #[serde(rename_all = "camelCase")] pub struct ProgramRequestWire { /// Opaque device identity (e.g. a 6-digit BigFred pairing code for WiFred). + #[serde(default)] pub identity: String, /// WiFi network the device should join after programming. + #[serde(default)] pub wifi: WifiCredentialsWire, /// wiThrottle server the device should connect to. + #[serde(default)] pub server: ThrottleServerWire, /// DCC vehicle list (capped by the driver's `max_roster_slots`). pub roster: Vec, @@ -228,10 +234,11 @@ pub struct BigfredCredsWire { } /// WiFi credentials. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct WifiCredentialsWire { /// SSID. + #[serde(default)] pub ssid: String, /// PSK (never logged by the daemon). #[serde(skip_serializing_if = "Option::is_none")] @@ -239,10 +246,11 @@ pub struct WifiCredentialsWire { } /// wiThrottle server endpoint. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThrottleServerWire { /// Hostname or IP. + #[serde(default)] pub host: String, /// TCP port. pub port: u16, diff --git a/docs/api.md b/docs/api.md index 54aff56..aae8a74 100644 --- a/docs/api.md +++ b/docs/api.md @@ -28,7 +28,7 @@ the response so callers can correlate requests without an explicit id. | Method | Params | Result | Notes | |----------------|-------------------------|-----------------------|--------------------------------| | `hello` | none | `HelloResult` | Version + driver capabilities | -| `scan` | `{ mode? }` | `Candidate[]` | Soft-AP (`ap`), LAN mDNS (`lan`), or USB serial (`usb`) | +| `scan` | `{ mode? }` | `Candidate[]` | Soft-AP (`ap`), LAN mDNS (`lan`), USB serial (`usb`), or Z21 LAN (`z21`) | | `probe` | `{ candidate }` | `DeviceInfo` | Read a single device's info | | `program` | `{ candidate, request }` | `ProgramResult` | Start a job, returns `jobId` | | `updateFirmware` | `{ mode, candidate?, path, host?, port?, partitionTable? }` | `ProgramResult` | Firmware upload job | @@ -51,7 +51,7 @@ package version. `commit` is the matching tag/build commit when available. ### `scan` -Optional `params.mode` is `"ap"` (default), `"lan"`, or `"usb"`. +Optional `params.mode` is `"ap"` (default), `"lan"`, `"usb"`, or `"z21"`. Soft-AP (`ap`) triggers an nl80211 scan and returns the candidates each driver claims: @@ -66,6 +66,16 @@ the advertised IPv4. USB (`usb`) lists serial ports (`espflash list-ports -n`, then `/dev/ttyUSB*` / `/dev/ttyACM*`). Each candidate `key` is the device node. +Z21 (`z21`) does not use the radio. It queries mDNS for `_z21._udp.local` +and broadcasts `LAN_GET_SERIAL_NUMBER` to `255.255.255.255:21105` and +`:21106`, then returns unique `ip:port` candidates with `driver: "fred"`. +Hardware Roco Z21 usually has no mDNS, so the UDP probe is required. + +`program` with `driver: "fred"` does not join a Soft-AP. The daemon binds +UDP and sends `LAN_LOCONET_DISPATCH_ADDR` (`0xA3`) with one DCC address +from the roster (`1..=10239`). `wifi` / `server` / `identity` may be empty. +A prior `scan` is not required when `candidate.key` is `host:port`. + ### `updateFirmware` Starts a firmware-upload job. The image path is on the hub filesystem. @@ -135,8 +145,9 @@ request body is supplied by the caller (`bigfred`/`bigfred-wizard`), keeping } ``` -See [`drivers/wifred.md`](drivers/wifred.md) and -[`drivers/longfred.md`](drivers/longfred.md) for per-driver write sequences. +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. The job runs through the state machine: `queued → joining → probing → writing → verifying → restarting → done`. Progress is observable via @@ -208,7 +219,7 @@ over the same socket. Every client subcommand accepts `--json` | Subcommand | Purpose | |------------|---------| -| `scan [--mode ap\|lan\|usb]` | Enumerate Soft-AP APs, LAN OTA hosts, or USB serial ports | +| `scan [--mode ap\|lan\|usb\|z21]` | Enumerate Soft-AP APs, LAN OTA hosts, USB serial ports, or Z21 LAN stations | | `probe --driver --key` | Read a single candidate's device info | | `program --driver --key ...` | Start a programming job and stream progress to completion | | `update-firmware --mode ap\|lan\|usb --file ...` | Upload firmware over HTTP or USB `espflash` | diff --git a/docs/cli.md b/docs/cli.md index 2ffbdcc..ea3162b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,6 +113,9 @@ wireless-programmer scan --mode lan # USB serial ports (`espflash list-ports` / `/dev/ttyUSB*` / `ttyACM*`): wireless-programmer scan --mode usb +# Z21 LAN command stations (mDNS `_z21._udp` + UDP serial probe): +wireless-programmer scan --mode z21 + # 3. Read one device's current config over the radio. wireless-programmer probe --driver wifred --key AA:BB:CC:DD:EE:01 @@ -200,6 +203,17 @@ Omit both for an open network. `--server-automatic` makes `--server-host` and `--server-port` optional, since the device discovers the server over mDNS; the port then defaults to the wiThrottle port `12090`. +Digitrax FRED (`--driver fred`) does not use Wi‑Fi or a throttle server. +Pass `--key host:port` (a Z21 LAN endpoint) and `--roster-file` with exactly +one DCC address `1..=10239`. `--identity` / `--wifi-ssid` / `--server-*` +are not required. Discover stations with `scan --mode z21`, or skip scan +when the address is known: + +```bash +wireless-programmer program --driver fred --key 192.168.0.111:21105 \ + --roster-file roster.json +``` + ### From a request file `--request-file` loads a complete `ProgramRequest` JSON document and ignores @@ -257,9 +271,9 @@ function index (WiFred: 16). | Flag | Field | Notes | |------|-------|-------| | `--identity` | `identity` | Opaque; 6-digit BigFred pairing code for WiFred | -| `--wifi-ssid` | `wifi.ssid` | Required | +| `--wifi-ssid` | `wifi.ssid` | Required except `--driver fred` | | `--wifi-psk` / `--wifi-psk-file` | `wifi.psk` | Mutually exclusive; omit both for an open network | -| `--server-host` / `--server-port` | `server.host` / `server.port` | Required unless `--server-automatic` | +| `--server-host` / `--server-port` | `server.host` / `server.port` | Required unless `--server-automatic` or `--driver fred` | | `--server-automatic` | `server.automatic` | mDNS discovery instead of a fixed host; port defaults to 12090 | | `--roster-file` | `roster` | JSON array of `RosterEntry` | diff --git a/docs/drivers/fred.md b/docs/drivers/fred.md new file mode 100644 index 0000000..d61c7b3 --- /dev/null +++ b/docs/drivers/fred.md @@ -0,0 +1,66 @@ +# Digitrax FRED driver + +Programs a wired Digitrax FRED throttle by sending +[`LAN_LOCONET_DISPATCH_ADDR`](../../../docs/content/en/specs/bigfred/protos/z21.md) +(`0xA3`) to a **physical** Z21-LAN command station that is LocoNet master +(Roco Z21, possibly RB1110). The FRED has no Wi‑Fi and no config page: the +operator plugs it into an L-NET / LOCONET-T / LOCONET-B jack after the +dispatch. + +This is **not** LongFred/WiFred Soft-AP programming, and **not** BigFred's +inbound `z21server` (which does not implement `0xA3`). + +## Commissioning model + +The daemon binds a UDP socket and talks Z21 LAN. The driver never opens +sockets itself. + +1. Optional discovery: `scan --mode z21` (mDNS `_z21._udp.local` plus a UDP + `LAN_GET_SERIAL_NUMBER` broadcast to `255.255.255.255:21105` and `:21106`). + Hardware Roco Z21 usually has no mDNS, so the UDP probe is required. +2. `program --driver fred --key host:port` with exactly one roster address. + A prior scan is not required when `key` is `host:port`. +3. The runtime sends `LAN_GET_SERIAL_NUMBER` as a login/probe, then + `LAN_LOCONET_DISPATCH_ADDR` with the 16-bit DCC address (little-endian). + Timeout is ~2 s. Optional `LAN_LOGOFF` follows. + +Candidate `key` is `ip:port` (typically `192.168.0.111:21105`). + +## Capabilities + +| Field | Value | +|--------------------------|-----------| +| `maxRosterSlots` | 1 | +| `maxFunctionIndex` | 0 | +| `identityFormat` | `any` | +| `supportsThrottleServer` | false | +| `supportsFirmwareUpdate` | false | +| `commissioning` | `Lan` | + +`identity`, `wifi`, and `server` are unused (empty defaults on the wire). + +## Dispatch result + +| Reply | Job outcome | +|-------|-------------| +| `Result > 0` | `done`, detail `slot N` (LocoNet slot) | +| `Result = 0` | `failed`, detail `dispatchFailed` (DISPATCH_PUT rejected) | +| `LAN_X_UNKNOWN_COMMAND` | `failed`, detail `z21NoLocoNet` (no LocoNet dispatch) | +| Serial probe succeeded, no `0xA3` reply (FW < 1.22) | `done`, detail `noAck` | +| No serial reply and no dispatch reply | `failed` (unreachable) | + +## CLI + +```bash +wireless-programmer scan --mode z21 +wireless-programmer program --driver fred --key 192.168.0.111:21105 \ + --roster-file roster.json +``` + +`roster.json`: + +```jsonc +[{ "address": 42 }] +``` + +`--identity`, `--wifi-ssid`, and `--server-*` are not required for `fred`. diff --git a/docs/go-client.md b/docs/go-client.md index f3937e8..90810f9 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -64,7 +64,7 @@ failure (see [Errors](#errors)). |--------|-------------|---------| | `Hello()` | `hello` | `*HelloResult` (version + drivers) | | `Scan()` | `scan` | `[]CandidateWire` (Soft-AP) | -| `ScanMode(mode)` | `scan` | `[]CandidateWire` (`ap`, `lan`, or `usb`) | +| `ScanMode(mode)` | `scan` | `[]CandidateWire` (`ap`, `lan`, `usb`, or `z21`) | | `Probe(candidate)` | `probe` | `*DeviceInfoWire` | | `Program(candidate, req)` | `program` | `*ProgramResult` (job id) | | `UpdateFirmware(mode, candidate, path, host, port, partitionTable)` | `updateFirmware` | `*ProgramResult` (job id) | diff --git a/go/client/client.go b/go/client/client.go index dcfe146..704ebc6 100644 --- a/go/client/client.go +++ b/go/client/client.go @@ -263,7 +263,7 @@ func (c *Client) Scan() ([]CandidateWire, error) { return c.ScanMode("ap") } -// ScanMode enumerates candidates. mode is "ap" (radio Soft-AP), "lan" (mDNS), or "usb". +// ScanMode enumerates candidates. mode is "ap" (radio Soft-AP), "lan" (mDNS), "usb", or "z21". func (c *Client) ScanMode(mode string) ([]CandidateWire, error) { var params *requestParams if mode != "" && mode != "ap" { From 452fdbc6dddbc54966e27e84ce3f0f1cd636b0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:24:08 +0200 Subject: [PATCH 2/4] Harden FRED Z21 dispatch after review. Resolve hostnames via async DNS, send LAN_LOGOFF on dispatch errors, preserve manually cached Z21 keys across rescans, map unreachable to a stable job detail, and add runtime tests for noAck and z21NoLocoNet. Co-authored-by: Cursor --- crates/wireless-programmer/src/runtime.rs | 59 ++++++++++++++----- .../tests/fake_mode_test.rs | 34 +++++++++++ crates/wp-fake/src/z21.rs | 7 ++- crates/wp-link/src/lib.rs | 6 +- crates/wp-link/src/z21.rs | 57 ++++++++++++++---- crates/wp-proto/src/wire.rs | 5 ++ 6 files changed, 137 insertions(+), 31 deletions(-) diff --git a/crates/wireless-programmer/src/runtime.rs b/crates/wireless-programmer/src/runtime.rs index 74bc23e..fd76e06 100644 --- a/crates/wireless-programmer/src/runtime.rs +++ b/crates/wireless-programmer/src/runtime.rs @@ -39,6 +39,9 @@ pub struct CachedCandidate { pub rssi: Option, /// How this candidate was discovered. pub mode: ReachMode, + /// `true` when inserted manually (e.g. `cache_z21_host`) rather than by a + /// scan. Manual entries survive a rescan of the same mode. + pub manual: bool, } /// Shared handle used by IPC and the worker. @@ -140,6 +143,7 @@ impl Runtime { label: c.label, rssi: c.rssi, mode: ReachMode::Ap, + manual: false, }; cache.insert((c.driver, c.key), cached.clone()); out.push(cached); @@ -164,6 +168,7 @@ impl Runtime { label: format!("{} ({})", h.hostname, h.ipv4), rssi: None, mode: ReachMode::Lan, + manual: false, }; cache.insert((cached.driver.clone(), key), cached.clone()); out.push(cached); @@ -186,6 +191,7 @@ impl Runtime { label: p.label, rssi: None, mode: ReachMode::Usb, + manual: false, }; cache.insert((cached.driver.clone(), cached.key.clone()), cached.clone()); out.push(cached); @@ -199,7 +205,9 @@ impl Runtime { .map_err(|e| wp_core::DriverError::Other(format!("z21 scan: {e}")))?; let mut out = Vec::new(); let mut cache = self.cache.lock(); - cache.retain(|_, v| v.mode != ReachMode::Z21); + // Clear scan-discovered Z21 entries but keep manually cached ones + // (e.g. from `cache_z21_host` when the operator passed `--key`). + cache.retain(|_, v| !(v.mode == ReachMode::Z21 && !v.manual)); for h in hosts { let key = h.key(); let cached = CachedCandidate { @@ -210,6 +218,7 @@ impl Runtime { label: h.label(), rssi: None, mode: ReachMode::Z21, + manual: false, }; cache.insert((cached.driver.clone(), key), cached.clone()); out.push(cached); @@ -227,6 +236,7 @@ impl Runtime { label: label.unwrap_or(key).to_string(), rssi: None, mode: ReachMode::Z21, + manual: true, }; self.cache .lock() @@ -243,6 +253,7 @@ impl Runtime { label: label.unwrap_or(port).to_string(), rssi: None, mode: ReachMode::Usb, + manual: true, }; self.cache .lock() @@ -259,6 +270,7 @@ impl Runtime { label: label.unwrap_or(host).to_string(), rssi: None, mode: ReachMode::Lan, + manual: true, }; self.cache .lock() @@ -686,7 +698,23 @@ async fn run_job(rt: &Runtime, id: JobId) { } async fn run_fred_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire, key: &str) { - let Some(target) = wp_link::Z21Host::parse_key(key) else { + // Resolve `host:port` (or bare host → default port) via async DNS so + // hostnames work, not just IPs. `parse_key` is the IP-only fast path. + let target_str = wp_link::Z21Host::normalize_key(key); + let target = match tokio::net::lookup_host(&target_str).await { + Ok(mut addrs) => addrs.next(), + Err(e) => { + rt.jobs.transition( + &id, + JobState::Failed, + None, + None, + Some(&format!("invalid Z21 address (want host:port): {e}")), + ); + return; + } + }; + let Some(target) = target else { rt.jobs.transition( &id, JobState::Failed, @@ -696,12 +724,9 @@ async fn run_fred_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire, ); return; }; - let Some(loco) = wire - .roster - .first() - .and_then(|e| e.address) - .filter(|a| (1..=10239).contains(a)) - else { + // validate() already enforced exactly one roster entry with an address in + // 1..=10239; this is a defensive guard against a missing address only. + let Some(loco) = wire.roster.first().and_then(|e| e.address) else { rt.jobs.transition( &id, JobState::Failed, @@ -756,13 +781,8 @@ async fn run_fred_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire, ); } Ok(wp_link::DispatchOutcome::NoAck) => { - rt.jobs.transition( - &id, - JobState::Done, - Some("done"), - Some(100), - Some("noAck"), - ); + rt.jobs + .transition(&id, JobState::Done, Some("done"), Some(100), Some("noAck")); } Err(wp_link::DispatchError::Rejected) => { rt.jobs.transition( @@ -782,6 +802,15 @@ async fn run_fred_program_job(rt: &Runtime, id: JobId, wire: ProgramRequestWire, Some("z21NoLocoNet"), ); } + Err(wp_link::DispatchError::Unreachable(_)) => { + rt.jobs.transition( + &id, + JobState::Failed, + Some("write"), + None, + Some("unreachable"), + ); + } Err(e) => { rt.jobs.transition( &id, diff --git a/crates/wireless-programmer/tests/fake_mode_test.rs b/crates/wireless-programmer/tests/fake_mode_test.rs index 9a26c55..abbeee4 100644 --- a/crates/wireless-programmer/tests/fake_mode_test.rs +++ b/crates/wireless-programmer/tests/fake_mode_test.rs @@ -241,3 +241,37 @@ fn fake_program_fred_reject_fails() { Some("dispatchFailed") ); } + +#[test] +fn fake_program_fred_unknown_command_fails() { + let fake = FakeZ21::spawn(FakeZ21Mode::UnknownCommand).unwrap(); + let rt = setup_runtime(); + let key = fake.addr().to_string(); + let id = rt + .submit_program(Driver::Fred, &key, fred_request(9)) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + assert_eq!(state, JobState::Failed, "detail={snap:?}"); + assert_eq!( + snap.as_ref().and_then(|s| s.detail.as_deref()), + Some("z21NoLocoNet") + ); +} + +#[test] +fn fake_program_fred_no_ack_reaches_done() { + let fake = FakeZ21::spawn(FakeZ21Mode::NoAck).unwrap(); + let rt = setup_runtime(); + let key = fake.addr().to_string(); + let id = rt + .submit_program(Driver::Fred, &key, fred_request(99)) + .expect("submit"); + let state = wait_terminal(&rt, &id); + let snap = rt.jobs().snapshot(&id); + assert_eq!(state, JobState::Done, "detail={snap:?}"); + assert_eq!( + snap.as_ref().and_then(|s| s.detail.as_deref()), + Some("noAck") + ); +} diff --git a/crates/wp-fake/src/z21.rs b/crates/wp-fake/src/z21.rs index 0e6de2f..b7fdadc 100644 --- a/crates/wp-fake/src/z21.rs +++ b/crates/wp-fake/src/z21.rs @@ -49,9 +49,10 @@ impl FakeZ21 { Ok((n, from)) => { for rec in parse_records(&buf[..n]) { let reply = match rec.header { - HEADER_GET_SERIAL_NUMBER => { - Some(encode(HEADER_GET_SERIAL_NUMBER, &0x00C0_FFEEu32.to_le_bytes())) - } + HEADER_GET_SERIAL_NUMBER => Some(encode( + HEADER_GET_SERIAL_NUMBER, + &0x00C0_FFEEu32.to_le_bytes(), + )), HEADER_LOCONET_DISPATCH_ADDR => { disp_t.fetch_add(1, Ordering::Relaxed); let loco = if rec.data.len() >= 2 { diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index 67f1000..b5b673e 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -20,11 +20,11 @@ pub use mdns::{ discover_mdns_hosts, discover_ota_hosts, parse_ota_hosts, OtaHost, OTA_HTTP_SERVICE, Z21_UDP_SERVICE, }; -pub use z21::{ - discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT, -}; pub use radio::{ first_wireless_interface, is_wireless_interface, parse_bss_infos, parse_scan_attrs, resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, }; pub use rfkill::{aggregate_state, RfkillState}; +pub use z21::{ + discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT, +}; diff --git a/crates/wp-link/src/z21.rs b/crates/wp-link/src/z21.rs index 804310e..b9a6d89 100644 --- a/crates/wp-link/src/z21.rs +++ b/crates/wp-link/src/z21.rs @@ -71,13 +71,29 @@ impl Z21Host { } } - /// Parse a `host:port` candidate key. + /// Parse an `ip:port` (or bare IPv4, defaulting to [`Z21_UDP_PORT`]) candidate + /// key into a [`SocketAddr`]. + /// + /// This is a synchronous IP-only fast path used by tests and the fake Z21. + /// For arbitrary hostnames use [`Z21Host::normalize_key`] plus async DNS + /// resolution (e.g. `tokio::net::lookup_host`) at the call site. pub fn parse_key(key: &str) -> Option { key.parse().ok().or_else(|| { let ip: Ipv4Addr = key.parse().ok()?; Some(SocketAddr::V4(SocketAddrV4::new(ip, Z21_UDP_PORT))) }) } + + /// Normalize a candidate key to `host:port`, appending [`Z21_UDP_PORT`] when + /// no port is present. Accepts a hostname or IP (DNS resolution is the + /// caller's responsibility — use `tokio::net::lookup_host(&normalized)`). + pub fn normalize_key(key: &str) -> String { + if key.rsplit_once(':').is_some() { + key.to_string() + } else { + format!("{key}:{Z21_UDP_PORT}") + } + } } /// Outcome of `LAN_LOCONET_DISPATCH_ADDR`. @@ -169,12 +185,22 @@ pub fn discover_z21(wait: Duration) -> std::io::Result> { match discover_mdns_hosts(Z21_UDP_SERVICE, wait) { Ok(hosts) => { for h in hosts { + let port = if h.port == 0 { + log::debug!( + "z21 mdns: {} advertised without SRV port, defaulting to {}", + h.ipv4, + Z21_UDP_PORT + ); + Z21_UDP_PORT + } else { + h.port + }; push_host( &mut found, Z21Host { hostname: h.hostname, ipv4: h.ipv4, - port: if h.port == 0 { Z21_UDP_PORT } else { h.port }, + port, serial: None, }, ); @@ -217,10 +243,7 @@ fn probe_serial_broadcast(wait: Duration) -> std::io::Result> { sock.set_broadcast(true)?; sock.set_read_timeout(Some(Duration::from_millis(200)))?; let req = serial_number_request(); - let _ = sock.send_to( - &req, - SocketAddrV4::new(Ipv4Addr::BROADCAST, Z21_UDP_PORT), - ); + let _ = sock.send_to(&req, SocketAddrV4::new(Ipv4Addr::BROADCAST, Z21_UDP_PORT)); let _ = sock.send_to( &req, SocketAddrV4::new(Ipv4Addr::BROADCAST, Z21_UDP_PORT_ALT), @@ -284,6 +307,7 @@ pub fn dispatch_addr( z21_alive = true; } if is_unknown_command(&rec) { + let _ = sock.send_to(&logoff_request(), target); return Err(DispatchError::UnknownCommand); } } @@ -306,6 +330,7 @@ pub fn dispatch_addr( Ok((n, from)) if from == target || from.ip() == target.ip() => { for rec in parse_records(&buf[..n]) { if is_unknown_command(&rec) { + let _ = sock.send_to(&logoff_request(), target); return Err(DispatchError::UnknownCommand); } if rec.header != HEADER_LOCONET_DISPATCH_ADDR { @@ -318,6 +343,7 @@ pub fn dispatch_addr( continue; } if result == 0 { + let _ = sock.send_to(&logoff_request(), target); return Err(DispatchError::Rejected); } let _ = sock.send_to(&logoff_request(), target); @@ -382,10 +408,7 @@ mod tests { #[test] fn encode_dispatch_matches_spec() { let pkt = dispatch_addr_request(42); - assert_eq!( - pkt, - vec![0x06, 0x00, 0xA3, 0x00, 42, 0x00] - ); + assert_eq!(pkt, vec![0x06, 0x00, 0xA3, 0x00, 42, 0x00]); } #[test] @@ -432,4 +455,18 @@ mod tests { let bare = Z21Host::parse_key("10.0.0.5").unwrap(); assert_eq!(bare.to_string(), "10.0.0.5:21105"); } + + #[test] + fn normalize_key_appends_default_port() { + assert_eq!(Z21Host::normalize_key("10.0.0.5"), "10.0.0.5:21105"); + assert_eq!( + Z21Host::normalize_key("192.168.0.111:21106"), + "192.168.0.111:21106" + ); + assert_eq!( + Z21Host::normalize_key("z21.local:21105"), + "z21.local:21105" + ); + assert_eq!(Z21Host::normalize_key("z21.local"), "z21.local:21105"); + } } diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index a4df6a9..68dee03 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -201,6 +201,11 @@ pub struct CandidateRef { } /// The full programming request, in wire form. +/// +/// `identity`, `wifi`, and `server` default to empty values so the FRED driver +/// (which uses none of them — it programs over Z21 UDP) can submit a request +/// with only a roster entry. This is additive: existing clients that always +/// send these fields are unaffected. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProgramRequestWire { From 3315439248ab55c2f6be9fd027a77a728f038316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:25:54 +0200 Subject: [PATCH 3/4] Apply rustfmt after FRED Z21 review fixes. Co-authored-by: Cursor --- crates/wp-link/src/lib.rs | 4 +--- crates/wp-link/src/z21.rs | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index b5b673e..ec30d3b 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -25,6 +25,4 @@ pub use radio::{ resolve_wireless_interface, Nl80211Radio, Radio, RadioFut, ScanResult, }; pub use rfkill::{aggregate_state, RfkillState}; -pub use z21::{ - discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT, -}; +pub use z21::{discover_z21, dispatch_addr, DispatchError, DispatchOutcome, Z21Host, Z21_UDP_PORT}; diff --git a/crates/wp-link/src/z21.rs b/crates/wp-link/src/z21.rs index b9a6d89..fa43c6a 100644 --- a/crates/wp-link/src/z21.rs +++ b/crates/wp-link/src/z21.rs @@ -463,10 +463,7 @@ mod tests { Z21Host::normalize_key("192.168.0.111:21106"), "192.168.0.111:21106" ); - assert_eq!( - Z21Host::normalize_key("z21.local:21105"), - "z21.local:21105" - ); + assert_eq!(Z21Host::normalize_key("z21.local:21105"), "z21.local:21105"); assert_eq!(Z21Host::normalize_key("z21.local"), "z21.local:21105"); } } From ad18ee0c3324cb6b7bf0cdbf0f1a04853f8629be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:27:54 +0200 Subject: [PATCH 4/4] Use async fn in FredDriver to satisfy clippy. Co-authored-by: Cursor --- crates/wp-drivers/src/fred.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/wp-drivers/src/fred.rs b/crates/wp-drivers/src/fred.rs index 52eb753..6ab7f6a 100644 --- a/crates/wp-drivers/src/fred.rs +++ b/crates/wp-drivers/src/fred.rs @@ -68,24 +68,19 @@ impl DeviceDriver for FredDriver { } } - fn probe( - &self, - _transport: Transport<'_>, - ) -> impl std::future::Future> { - async { Ok(serde_json::json!({ "driver": "fred" })) } + async fn probe(&self, _transport: Transport<'_>) -> Result { + Ok(serde_json::json!({ "driver": "fred" })) } - fn program( + async fn program( &self, _transport: Transport<'_>, _req: &wp_core::ProgramRequest<'_>, _progress: &mut dyn ProgressSink, - ) -> impl std::future::Future> { - async { - Err(DriverError::Other( - "fred programming uses Z21 UDP, not HTTP/serial".into(), - )) - } + ) -> Result { + Err(DriverError::Other( + "fred programming uses Z21 UDP, not HTTP/serial".into(), + )) } }