diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a848633 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +# wireless-programmer — build / release helpers + +TARGET_MUSL ?= aarch64-unknown-linux-musl +CARGO ?= cargo +RUSTUP_TOOLCHAIN ?= stable +export RUSTUP_TOOLCHAIN + +.PHONY: all build release release-musl check test test-release-assertions clean fmt clippy + +all: build + +build: + $(CARGO) build --workspace + +release: + $(CARGO) build --workspace --release + +# Static musl binary (default: aarch64). Override: make release-musl TARGET_MUSL=x86_64-unknown-linux-musl +release-musl: + RUSTFLAGS='-C target-feature=+crt-static' \ + $(CARGO) build --workspace --release --target $(TARGET_MUSL) + @mkdir -p dist + @case "$(TARGET_MUSL)" in \ + aarch64-*) dist_name=wireless-programmer-linux-arm64 ;; \ + x86_64-*) dist_name=wireless-programmer-linux-amd64 ;; \ + *) dist_name=wireless-programmer-$(TARGET_MUSL) ;; \ + esac; \ + cp -f target/$(TARGET_MUSL)/release/wireless-programmer "dist/$${dist_name}"; \ + echo "wrote dist/$${dist_name}" + +check: + $(CARGO) check --workspace + +test: + $(CARGO) test --workspace --locked + +test-release-assertions: + $(CARGO) test --workspace --locked --profile release-assertions + +fmt: + $(CARGO) fmt --all + +clippy: + $(CARGO) clippy --workspace --all-targets --locked -- -D warnings + +clean: + $(CARGO) clean + rm -rf dist diff --git a/README.md b/README.md index 56954ee..3ccb346 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ max 64 scan results, max 8 socket connections, max 1 MiB socket frame, max ## Building +```bash +make build # debug +make release # release (opt-level z, LTO, strip) +make release-musl TARGET_MUSL=aarch64-unknown-linux-musl # static arm64 → dist/ +``` + +Or the usual Cargo checks: + ```bash cargo fmt --all -- --check cargo clippy --workspace --all-targets --locked -- -D warnings @@ -51,7 +59,7 @@ cargo test --workspace --locked cargo test --workspace --locked --profile release-assertions ``` -Static musl builds (arm64 / amd64) are produced by CI; see +Static musl builds (arm64 / amd64) are also produced by CI; see `.github/workflows/ci.yml`. ## Socket API @@ -71,6 +79,7 @@ subcommand it runs the daemon; the subcommands below are clients. # daemon (default) wireless-programmer --socket /data/run/wireless-programmer/wireless-programmer.sock wireless-programmer daemon --verbose +wireless-programmer daemon --interface wlan0 # discovery + programming wireless-programmer scan # list candidates on the radio diff --git a/crates/wireless-programmer/src/cli/daemon.rs b/crates/wireless-programmer/src/cli/daemon.rs index b4662e5..e1ef332 100644 --- a/crates/wireless-programmer/src/cli/daemon.rs +++ b/crates/wireless-programmer/src/cli/daemon.rs @@ -1,6 +1,7 @@ //! Daemon subcommand runner (the previous `main` behaviour). use std::path::PathBuf; +use std::process::ExitCode; use clap::Args; use tracing_subscriber::EnvFilter; @@ -15,15 +16,17 @@ pub struct DaemonArgs { /// Verbose logging. #[arg(short, long)] pub verbose: bool, + + /// Wireless interface to use (e.g. `wlan0`, `wlp2s0`). + /// + /// When omitted, the first wireless interface under `/sys/class/net` is + /// selected. Overrides `WIRELESS_PROGRAMMER_INTERFACE` when set. + #[arg(short = 'i', long = "interface", value_name = "IFACE")] + pub interface: Option, } /// Run the IPC daemon until shutdown. -pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> std::process::ExitCode { - let mut cfg = Config::default(); - if let Some(s) = socket_override { - cfg.socket = s; - } - +pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> ExitCode { let filter = if args.verbose { EnvFilter::new("debug") } else { @@ -31,14 +34,45 @@ pub fn run_daemon(args: DaemonArgs, socket_override: Option) -> std::pr }; tracing_subscriber::fmt().with_env_filter(filter).init(); + let mut cfg = Config::default(); + if let Some(s) = socket_override { + cfg.socket = s; + } + // CLI wins over the environment default baked into Config::default. + if let Some(iface) = args.interface { + let iface = iface.trim().to_string(); + if iface.is_empty() { + tracing::error!("--interface must not be empty"); + return ExitCode::FAILURE; + } + cfg.interface = Some(iface); + } + + // Validate the preferred interface early so a typo fails at start-up + // rather than on the first scan/program request. + if let Some(ref name) = cfg.interface { + match wp_link::resolve_wireless_interface(Some(name)) { + Ok(resolved) => cfg.interface = Some(resolved), + Err(e) => { + tracing::error!("wireless interface: {e}"); + return ExitCode::FAILURE; + } + } + } + + match &cfg.interface { + Some(name) => tracing::info!("wireless interface: {name}"), + None => tracing::info!("wireless interface: auto (first wireless)"), + } + let registry = DriverRegistry::new(); let runtime = Server::new(cfg, registry); match runtime.run() { - Ok(()) => std::process::ExitCode::SUCCESS, + Ok(()) => ExitCode::SUCCESS, Err(e) => { tracing::error!("fatal: {e}"); - std::process::ExitCode::FAILURE + ExitCode::FAILURE } } } diff --git a/crates/wireless-programmer/src/cli/mod.rs b/crates/wireless-programmer/src/cli/mod.rs index a7953c7..871da62 100644 --- a/crates/wireless-programmer/src/cli/mod.rs +++ b/crates/wireless-programmer/src/cli/mod.rs @@ -30,6 +30,11 @@ pub struct Cli { /// Verbose logging (daemon only). #[arg(short, long)] pub verbose: bool, + + /// Wireless interface for the daemon (e.g. `wlan0`). Also accepted on + /// `daemon --interface`. Overrides `WIRELESS_PROGRAMMER_INTERFACE`. + #[arg(short = 'i', long = "interface", value_name = "IFACE")] + pub interface: Option, } /// Top-level subcommands. diff --git a/crates/wireless-programmer/src/cli/program.rs b/crates/wireless-programmer/src/cli/program.rs index 2fa2fdb..44b6ad0 100644 --- a/crates/wireless-programmer/src/cli/program.rs +++ b/crates/wireless-programmer/src/cli/program.rs @@ -73,6 +73,8 @@ fn build_request(args: &ProgramArgs) -> Result { wifi, server, roster, + bigfred: None, + roster_mode: None, }) } diff --git a/crates/wireless-programmer/src/config.rs b/crates/wireless-programmer/src/config.rs index ac05fe5..6633a99 100644 --- a/crates/wireless-programmer/src/config.rs +++ b/crates/wireless-programmer/src/config.rs @@ -25,6 +25,9 @@ pub struct Config { pub commit: Option, /// Source address bound on the wireless interface during programming. pub source_addr: SocketAddr, + /// Wireless interface to use (`wlan0`, `wlp2s0`, …). `None` means auto- + /// select the first wireless interface at radio open time. + pub interface: Option, } impl Default for Config { @@ -45,6 +48,7 @@ impl Default for Config { version: env!("CARGO_PKG_VERSION").into(), commit: option_env!("WIRELESS_PROGRAMMER_GIT_COMMIT").map(Into::into), source_addr: "192.168.4.2:0".parse().expect("valid default source addr"), + interface: resolve_interface_env(), } } } @@ -75,6 +79,14 @@ fn resolve_allow_users() -> Vec { } } +/// Optional wireless interface from `WIRELESS_PROGRAMMER_INTERFACE`. +fn resolve_interface_env() -> Option { + std::env::var("WIRELESS_PROGRAMMER_INTERFACE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + /// Resolve the BigFred data directory. pub fn resolve_data_dir() -> PathBuf { if let Ok(d) = std::env::var("BIGFRED_DATA_DIR") { diff --git a/crates/wireless-programmer/src/drivers.rs b/crates/wireless-programmer/src/drivers.rs index adcd3e1..e3fa30d 100644 --- a/crates/wireless-programmer/src/drivers.rs +++ b/crates/wireless-programmer/src/drivers.rs @@ -4,13 +4,15 @@ //! (guidelines §8.2) rather than `Box`. use wp_core::{DeviceCandidate, DeviceDriver, DriverCapabilities, Observation}; -use wp_drivers::WiFredDriver; +use wp_drivers::{LongFredDriver, WiFredDriver}; /// All registered drivers. #[derive(Debug, Clone, Copy)] pub enum Driver { /// NewHeiko WiFred. WiFred, + /// LongFred Soft-AP programming. + LongFred, } impl Driver { @@ -18,6 +20,7 @@ impl Driver { pub fn id_str(self) -> &'static str { match self { Driver::WiFred => "wifred", + Driver::LongFred => "longfred", } } @@ -25,6 +28,7 @@ impl Driver { pub fn name(self) -> &'static str { match self { Driver::WiFred => "NewHeiko WiFred", + Driver::LongFred => "LongFred", } } } @@ -33,6 +37,7 @@ impl Driver { #[derive(Debug)] pub struct DriverRegistry { wifred: WiFredDriver, + longfred: LongFredDriver, } impl DriverRegistry { @@ -40,12 +45,16 @@ impl DriverRegistry { pub fn new() -> Self { Self { wifred: WiFredDriver::new(), + longfred: LongFredDriver::new(), } } /// Iterate over (driver tag, capabilities) for `hello`. pub fn drivers(&self) -> Vec<(Driver, DriverCapabilities)> { - vec![(Driver::WiFred, self.wifred.capabilities())] + vec![ + (Driver::WiFred, self.wifred.capabilities()), + (Driver::LongFred, self.longfred.capabilities()), + ] } /// Build the `hello` result's driver list. @@ -55,13 +64,7 @@ impl DriverRegistry { .map(|(d, caps)| wp_proto::DriverInfoWire { id: d.id_str().into(), name: d.name().into(), - capabilities: wp_proto::CapabilitiesWire { - max_roster_slots: caps.max_roster_slots, - max_function_index: caps.max_function_index, - identity_format: caps.identity_format.into(), - supports_throttle_server: caps.supports_throttle_server, - commissioning: caps.commissioning.into(), - }, + capabilities: caps.into(), }) .collect() } @@ -70,20 +73,27 @@ impl DriverRegistry { pub fn driver_for(&self, candidate: &wp_proto::CandidateRef) -> Option { match candidate.driver.as_str() { "wifred" => Some(Driver::WiFred), + "longfred" => Some(Driver::LongFred), _ => None, } } /// Claim a raw observation against every driver. pub fn identify(&self, obs: &Observation) -> Option { - // WiFred is the only driver today; its filter is the SSID prefix. - self.wifred.identify(obs) + self.longfred + .identify(obs) + .or_else(|| self.wifred.identify(obs)) } /// Borrow the WiFred driver. pub fn wifred(&self) -> &WiFredDriver { &self.wifred } + + /// Borrow the LongFred driver. + pub fn longfred(&self) -> &LongFredDriver { + &self.longfred + } } impl Default for DriverRegistry { diff --git a/crates/wireless-programmer/src/ipc.rs b/crates/wireless-programmer/src/ipc.rs index fccaa38..e94da67 100644 --- a/crates/wireless-programmer/src/ipc.rs +++ b/crates/wireless-programmer/src/ipc.rs @@ -218,7 +218,11 @@ impl ServerInner { kind: RequestKind::LinkStatus, result: Some(ResultBody::LinkStatus(wp_proto::LinkStatusWire { busy: self.jobs_is_busy(), - interface: None, + interface: self + .cfg + .interface + .clone() + .or_else(|| wp_link::first_wireless_interface().ok()), rfkill_blocked: false, })), error: None, diff --git a/crates/wireless-programmer/src/main.rs b/crates/wireless-programmer/src/main.rs index 5062b12..291f971 100644 --- a/crates/wireless-programmer/src/main.rs +++ b/crates/wireless-programmer/src/main.rs @@ -22,11 +22,22 @@ use cli::{Cli, Command}; fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { - Some(Command::Daemon(args)) => cli::run_daemon(args, cli.socket), + Some(Command::Daemon(mut args)) => { + // Top-level `--interface` / `--verbose` apply when the + // subcommand did not set them itself. + if args.interface.is_none() { + args.interface = cli.interface; + } + if !args.verbose { + args.verbose = cli.verbose; + } + cli::run_daemon(args, cli.socket) + } Some(command) => cli::run_client(command, cli.socket), None => cli::run_daemon( cli::DaemonArgs { verbose: cli.verbose, + interface: cli.interface, }, cli.socket, ), diff --git a/crates/wp-client/tests/client_test.rs b/crates/wp-client/tests/client_test.rs index 836459e..629212e 100644 --- a/crates/wp-client/tests/client_test.rs +++ b/crates/wp-client/tests/client_test.rs @@ -142,6 +142,8 @@ fn busy_maps_to_a_typed_error() { automatic: None, }, roster: Vec::new(), + bigfred: None, + roster_mode: None, }, ) .expect_err("expected busy"); diff --git a/crates/wp-core/src/capabilities.rs b/crates/wp-core/src/capabilities.rs index e3a6895..9421d16 100644 --- a/crates/wp-core/src/capabilities.rs +++ b/crates/wp-core/src/capabilities.rs @@ -1,6 +1,8 @@ //! Driver capabilities and commissioning model. -use wp_proto::{CapabilitiesWire, CommissioningKindWire, IdentityFormatWire}; +use std::net::Ipv4Addr; + +use wp_proto::{CapabilitiesWire, CommissioningKindWire, CommissioningNetWire, IdentityFormatWire}; /// Stable identifier for a driver implementation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -34,6 +36,34 @@ impl From for CommissioningKindWire { } } +/// On-link Soft-AP addressing for commissioning. +/// +/// When present on [`DriverCapabilities`], the daemon should bind the wireless +/// interface to `source/prefix` and talk to `host:port`. When absent, the +/// daemon keeps its historical defaults (`192.168.4.1` / `192.168.4.2/24`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommissioningNet { + /// Device Soft-AP address (e.g. `192.168.0.1`). + pub host: Ipv4Addr, + /// HTTP port on the Soft-AP (typically 80). + pub port: u16, + /// Address the hub assigns on the wireless interface (e.g. `192.168.0.2`). + pub source: Ipv4Addr, + /// Prefix length for the on-link route (typically 24). + pub prefix: u8, +} + +impl From for CommissioningNetWire { + fn from(n: CommissioningNet) -> Self { + CommissioningNetWire { + host: n.host.to_string(), + port: n.port, + source: n.source.to_string(), + prefix: n.prefix, + } + } +} + /// Required format of the device identity string. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdentityFormat { @@ -95,6 +125,9 @@ pub struct DriverCapabilities { pub supports_throttle_server: bool, /// How the device is commissioned. pub commissioning: CommissioningKind, + /// Soft-AP addressing for commissioning, when the driver does not use the + /// daemon's historical `192.168.4.x` defaults. + pub commissioning_net: Option, } impl From for CapabilitiesWire { @@ -105,6 +138,7 @@ impl From for CapabilitiesWire { identity_format: c.identity_format.into(), supports_throttle_server: c.supports_throttle_server, commissioning: c.commissioning.into(), + commissioning_net: c.commissioning_net.map(Into::into), } } } diff --git a/crates/wp-core/src/lib.rs b/crates/wp-core/src/lib.rs index 7d9a64a..176031c 100644 --- a/crates/wp-core/src/lib.rs +++ b/crates/wp-core/src/lib.rs @@ -17,11 +17,15 @@ mod error; mod request; mod transport; -pub use capabilities::{CommissioningKind, DriverCapabilities, DriverId, IdentityFormat}; +pub use capabilities::{ + CommissioningKind, CommissioningNet, DriverCapabilities, DriverId, IdentityFormat, +}; pub use driver::{ validate_common, DeviceCandidate, DeviceDriver, NoProgress, Observation, Outcome, ProgressSink, ScanFilters, Transport, }; pub use error::{DriverError, ValidationError}; -pub use request::{FunctionMapping, ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials}; +pub use request::{ + BigfredCreds, FunctionMapping, ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials, +}; pub use transport::{ByteStream, HttpClient}; diff --git a/crates/wp-core/src/request.rs b/crates/wp-core/src/request.rs index 6ea1f34..a002a30 100644 --- a/crates/wp-core/src/request.rs +++ b/crates/wp-core/src/request.rs @@ -44,6 +44,16 @@ pub struct FunctionMapping { pub value: u8, } +/// BigFred login credentials for devices that authenticate with login+PIN +/// (e.g. LongFred) rather than a 6-digit wiThrottle pairing code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BigfredCreds<'a> { + /// BigFred login name. + pub login: &'a str, + /// BigFred PIN (never logged by the daemon). + pub pin: &'a str, +} + /// The full programming request, borrowing caller-owned data. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProgramRequest<'a> { @@ -55,4 +65,8 @@ pub struct ProgramRequest<'a> { pub server: ThrottleServer, /// DCC vehicle list (capped by the driver's `max_roster_slots`). pub roster: Vec>, + /// Optional BigFred login+PIN (LongFred and similar). + pub bigfred: Option>, + /// Optional roster mode string (driver-specific, e.g. `"auto"` / `"static"`). + pub roster_mode: Option<&'a str>, } diff --git a/crates/wp-core/src/transport.rs b/crates/wp-core/src/transport.rs index 01e0e3a..7ca8d25 100644 --- a/crates/wp-core/src/transport.rs +++ b/crates/wp-core/src/transport.rs @@ -10,13 +10,32 @@ use std::io; /// Implementations are expected to be bounded: a deadline, a maximum response /// body size, and a bounded retry count. pub trait HttpClient { - /// Issue a `GET` to `path` (path begins with `/`) and return the body. + /// Issue an HTTP request to `path` (path begins with `/`) and return the body. + /// + /// `body` is an optional `(content_type, bytes)` pair for methods that + /// carry a payload (`PUT`/`POST`). When present, the client sends + /// `Content-Type` and `Content-Length`. + /// + /// # Errors + /// + /// Returns [`io::Error`] on transport failure or when the response exceeds + /// the configured bounds. + fn request( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result>; + + /// Issue a `GET` to `path` and return the body. /// /// # Errors /// /// Returns [`io::Error`] on transport failure or when the response exceeds /// the configured bounds. - fn get(&mut self, path: &str) -> io::Result>; + fn get(&mut self, path: &str) -> io::Result> { + self.request("GET", path, None) + } } /// A bidirectional byte stream for serial devices. diff --git a/crates/wp-drivers/Cargo.toml b/crates/wp-drivers/Cargo.toml index 3a12ae9..228d51a 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 (NewHeiko WiFred)" +description = "Device driver implementations for wireless-programmer (WiFred, LongFred)" [lib] name = "wp_drivers" diff --git a/crates/wp-drivers/src/lib.rs b/crates/wp-drivers/src/lib.rs index 71e77f8..a6e3d64 100644 --- a/crates/wp-drivers/src/lib.rs +++ b/crates/wp-drivers/src/lib.rs @@ -2,6 +2,8 @@ #![forbid(unsafe_code)] +pub mod longfred; pub mod wifred; +pub use longfred::LongFredDriver; pub use wifred::{Direction, FunctionInfo, WiFredDriver}; diff --git a/crates/wp-drivers/src/longfred/constants.rs b/crates/wp-drivers/src/longfred/constants.rs new file mode 100644 index 0000000..fb75b18 --- /dev/null +++ b/crates/wp-drivers/src/longfred/constants.rs @@ -0,0 +1,35 @@ +//! LongFred Soft-AP programming constants. + +#![allow(dead_code)] + +use std::net::Ipv4Addr; + +/// SSID prefix of the programming Soft-AP (`longfred_prog_XXXXXX`). +pub const WIFI_CONFIG_SSID_PREFIX: &str = "longfred_prog"; + +/// Config AP HTTP port. +pub const CONFIG_AP_PORT: u16 = 80; + +/// Config AP address (firmware static Soft-AP IP). +pub const CONFIG_HOST: Ipv4Addr = Ipv4Addr::new(192, 168, 0, 1); + +/// Source address the daemon assigns to the wireless interface. +pub const CONFIG_SOURCE: Ipv4Addr = Ipv4Addr::new(192, 168, 0, 2); + +/// On-link prefix length for the config AP subnet. +pub const CONFIG_PREFIX_LEN: u8 = 24; + +/// LongFred static roster capacity (`MAX_SAVED_LOCOS`). +pub const MAX_ROSTER_SLOTS: u8 = 12; + +/// LongFred programming does not write per-function maps via `/settings`. +pub const MAX_FUNCTION: u8 = 0; + +/// Settings read endpoint. +pub const SETTINGS_PATH: &str = "/api/v1/settings"; + +/// Exit programming mode endpoint. +pub const PROGRAMMING_MODE_OFF_PATH: &str = "/api/v1/programming-mode/off"; + +/// JSON content type for PUT bodies. +pub const JSON_CONTENT_TYPE: &str = "application/json"; diff --git a/crates/wp-drivers/src/longfred/discovery.rs b/crates/wp-drivers/src/longfred/discovery.rs new file mode 100644 index 0000000..0a5608f --- /dev/null +++ b/crates/wp-drivers/src/longfred/discovery.rs @@ -0,0 +1,23 @@ +//! LongFred scan/discovery. + +use wp_core::{DeviceCandidate, Observation}; + +use crate::longfred::constants::WIFI_CONFIG_SSID_PREFIX; + +/// Claim a raw scan observation as a LongFred candidate. +/// +/// The programming Soft-AP SSID is `longfred_prog_XXXXXX` (6 hex chars from +/// the MAC). Match on the prefix; the BSSID is the stable candidate key. +pub fn identify(obs: &Observation) -> Option { + let ssid = obs.ssid.as_ref()?; + if !ssid.starts_with(WIFI_CONFIG_SSID_PREFIX) { + return None; + } + let key = obs.bssid.clone().unwrap_or_else(|| ssid.clone()); + Some(DeviceCandidate { + driver: "longfred".into(), + key, + label: ssid.clone(), + rssi: obs.rssi, + }) +} diff --git a/crates/wp-drivers/src/longfred/mod.rs b/crates/wp-drivers/src/longfred/mod.rs new file mode 100644 index 0000000..0083d01 --- /dev/null +++ b/crates/wp-drivers/src/longfred/mod.rs @@ -0,0 +1,153 @@ +//! LongFred Soft-AP programming driver. +//! +//! Implements [`wp_core::DeviceDriver`] for LongFred throttles in programming +//! mode. The firmware raises an open Soft-AP named `longfred_prog_XXXXXX` with +//! a static address `192.168.0.1/24` and serves: +//! +//! - `GET /api/v1/settings` +//! - `PUT /api/v1/settings` +//! - `POST /api/v1/programming-mode/off` +//! +//! Configuration is written as a single JSON PUT, verified with a GET, then +//! programming mode is cleared so the device leaves the Soft-AP. + +mod constants; +mod discovery; +mod settings; + +use wp_core::{ + validate_common, CommissioningNet, DeviceCandidate, DeviceDriver, DriverCapabilities, + DriverError, DriverId, IdentityFormat, Observation, Outcome, ProgressSink, ScanFilters, + Transport, +}; + +pub use constants::{ + CONFIG_AP_PORT, CONFIG_HOST, CONFIG_PREFIX_LEN, CONFIG_SOURCE, MAX_FUNCTION, MAX_ROSTER_SLOTS, + WIFI_CONFIG_SSID_PREFIX, +}; +pub use discovery::identify; +pub use settings::{build_settings_put, format_roster_addr, verify}; + +use constants::{JSON_CONTENT_TYPE, PROGRAMMING_MODE_OFF_PATH, SETTINGS_PATH}; + +/// The LongFred driver. +#[derive(Debug, Default)] +pub struct LongFredDriver; + +impl LongFredDriver { + /// Construct a new driver instance. + pub const fn new() -> Self { + Self + } +} + +const ID: DriverId = DriverId::new("longfred"); + +impl DeviceDriver for LongFredDriver { + fn id(&self) -> DriverId { + ID + } + + fn name(&self) -> &'static str { + "LongFred" + } + + fn capabilities(&self) -> DriverCapabilities { + DriverCapabilities { + max_roster_slots: MAX_ROSTER_SLOTS, + max_function_index: MAX_FUNCTION, + // Written as `wifi.hostname` (firmware max 16). + identity_format: IdentityFormat::Alphanumeric { max_len: 16 }, + // LongFred authenticates to BigFred via login+PIN; the wiThrottle + // server endpoint in ProgramRequest is unused but accepted so + // callers can share a request shape with WiFred. + supports_throttle_server: true, + commissioning: wp_core::CommissioningKind::SoftAp, + 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: vec![WIFI_CONFIG_SSID_PREFIX.into()], + } + } + + fn identify(&self, obs: &Observation) -> Option { + discovery::identify(obs) + } + + fn validate(&self, req: &wp_core::ProgramRequest<'_>) -> Result<(), wp_core::ValidationError> { + validate_common(&self.capabilities(), req) + } + + async fn probe(&self, transport: Transport<'_>) -> Result { + let client = http_client(transport)?; + let body = client + .get(SETTINGS_PATH) + .map_err(|e| DriverError::Http(e.to_string()))?; + serde_json::from_slice(&body).map_err(|e| DriverError::Parse(e.to_string())) + } + + async fn program( + &self, + transport: Transport<'_>, + req: &wp_core::ProgramRequest<'_>, + progress: &mut dyn ProgressSink, + ) -> Result { + let client = http_client(transport)?; + + progress.step("write"); + let put = build_settings_put(req); + let put_bytes = serde_json::to_vec(&put).map_err(|e| DriverError::Other(e.to_string()))?; + client + .request("PUT", SETTINGS_PATH, Some((JSON_CONTENT_TYPE, &put_bytes))) + .map_err(|e| DriverError::Http(e.to_string()))?; + + progress.step("verify"); + let body = client + .get(SETTINGS_PATH) + .map_err(|e| DriverError::Http(e.to_string()))?; + let after: serde_json::Value = + serde_json::from_slice(&body).map_err(|e| DriverError::Parse(e.to_string()))?; + let mismatches = settings::verify(&after, req); + if !mismatches.is_empty() { + return Err(DriverError::VerificationFailed { mismatches }); + } + + progress.step("exit"); + client + .request("POST", PROGRAMMING_MODE_OFF_PATH, None) + .map_err(|e| DriverError::Http(e.to_string()))?; + + 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( + "longfred driver requires an HTTP transport".into(), + )), + } +} + +/// Soft-AP addressing helpers for callers that prefer constants over capabilities. +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/src/longfred/settings.rs b/crates/wp-drivers/src/longfred/settings.rs new file mode 100644 index 0000000..d6d13c2 --- /dev/null +++ b/crates/wp-drivers/src/longfred/settings.rs @@ -0,0 +1,130 @@ +//! LongFred `/api/v1/settings` JSON helpers. + +use serde_json::{json, Value}; +use wp_core::ProgramRequest; + +/// Build the JSON body for `PUT /api/v1/settings`. +/// +/// Maps the domain [`ProgramRequest`] onto LongFred's provisioning DTO: +/// - `wifi.ssid` / `wifi.password` from the request WiFi credentials +/// - `wifi.hostname` from `identity` when non-empty +/// - `bigfred` from optional BigFred credentials +/// - `roster_mode` and `roster` (addresses as `S`/`L` + digits) +pub fn build_settings_put(req: &ProgramRequest<'_>) -> Value { + let mut body = serde_json::Map::new(); + + let mut wifi = serde_json::Map::new(); + wifi.insert("ssid".into(), json!(req.wifi.ssid)); + if let Some(psk) = req.wifi.psk { + wifi.insert("password".into(), json!(psk)); + } + if !req.identity.is_empty() { + wifi.insert("hostname".into(), json!(req.identity)); + } + body.insert("wifi".into(), Value::Object(wifi)); + + if let Some(bf) = req.bigfred { + body.insert( + "bigfred".into(), + json!({ + "login": bf.login, + "pin": bf.pin, + }), + ); + } + + if let Some(mode) = req.roster_mode { + body.insert("roster_mode".into(), json!(mode)); + } + + let roster: Vec = req + .roster + .iter() + .filter_map(|entry| { + let addr = format_roster_addr(entry.address?, entry.long_address)?; + Some(json!({ "addr": addr })) + }) + .collect(); + if !roster.is_empty() { + body.insert("roster".into(), Value::Array(roster)); + } + + Value::Object(body) +} + +/// Format a DCC address the way LongFred's static roster expects (`S42`, `L128`). +pub fn format_roster_addr(address: u16, long_address: Option) -> Option { + if address == 0 || address > 10239 { + return None; + } + let long = long_address.unwrap_or(address >= 128); + let prefix = if long { 'L' } else { 'S' }; + Some(format!("{prefix}{address}")) +} + +/// Compare a GET settings payload against the request; return mismatch fields. +pub fn verify(settings: &Value, req: &ProgramRequest<'_>) -> Vec { + let mut mismatches = Vec::new(); + + let networks = settings + .pointer("/wifi/networks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let has_ssid = networks.iter().any(|n| n.as_str() == Some(req.wifi.ssid)); + if !has_ssid { + mismatches.push("wifi".into()); + } + + if !req.identity.is_empty() { + let hostname = settings + .pointer("/wifi/hostname") + .and_then(Value::as_str) + .unwrap_or_default(); + if hostname != req.identity { + mismatches.push("wifi.hostname".into()); + } + } + + if let Some(bf) = req.bigfred { + let login = settings + .pointer("/bigfred/login") + .and_then(Value::as_str) + .unwrap_or_default(); + if login != bf.login { + mismatches.push("bigfred.login".into()); + } + } + + if let Some(mode) = req.roster_mode { + let got = settings + .pointer("/roster/mode") + .and_then(Value::as_str) + .unwrap_or_default(); + if got != mode { + mismatches.push("roster.mode".into()); + } + } + + let entries = settings + .pointer("/roster/entries") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let want: Vec = req + .roster + .iter() + .filter_map(|e| format_roster_addr(e.address?, e.long_address)) + .collect(); + for (i, addr) in want.iter().enumerate() { + let got = entries + .get(i) + .and_then(|e| e.get("addr")) + .and_then(Value::as_str); + if got != Some(addr.as_str()) { + mismatches.push(format!("roster[{i}].addr")); + } + } + + mismatches +} diff --git a/crates/wp-drivers/src/wifred/mod.rs b/crates/wp-drivers/src/wifred/mod.rs index 8adb395..7f2c5bc 100644 --- a/crates/wp-drivers/src/wifred/mod.rs +++ b/crates/wp-drivers/src/wifred/mod.rs @@ -57,6 +57,9 @@ impl DeviceDriver for WiFredDriver { identity_format: IdentityFormat::Digits { len: 6 }, supports_throttle_server: true, commissioning: wp_core::CommissioningKind::SoftAp, + // Historical Soft-AP defaults (`192.168.4.1` / `.2/24`) live in the + // daemon config; leave unset so existing behaviour is unchanged. + commissioning_net: None, } } diff --git a/crates/wp-drivers/tests/longfred_discovery.rs b/crates/wp-drivers/tests/longfred_discovery.rs new file mode 100644 index 0000000..9e7533e --- /dev/null +++ b/crates/wp-drivers/tests/longfred_discovery.rs @@ -0,0 +1,53 @@ +//! LongFred Soft-AP discovery / identify tests. + +use wp_core::Observation; +use wp_drivers::longfred::identify; + +#[test] +fn identify_matches_prefix() { + let obs = Observation { + ssid: Some("longfred_prog_a1b2c3".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, "longfred"); + assert_eq!(c.key, "aa:bb:cc:dd:ee:ff"); + assert_eq!(c.label, "longfred_prog_a1b2c3"); + assert_eq!(c.rssi, Some(-42)); +} + +#[test] +fn identify_rejects_unrelated_ssid() { + let obs = Observation { + ssid: Some("wiFred-configa1b2".into()), + bssid: None, + rssi: None, + extra: serde_json::Value::Null, + }; + assert!(identify(&obs).is_none()); +} + +#[test] +fn identify_falls_back_to_ssid_when_no_bssid() { + let obs = Observation { + ssid: Some("longfred_prog_ffffff".into()), + bssid: None, + rssi: None, + extra: serde_json::Value::Null, + }; + let c = identify(&obs).expect("claimed"); + assert_eq!(c.key, "longfred_prog_ffffff"); +} + +#[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/longfred_settings.rs b/crates/wp-drivers/tests/longfred_settings.rs new file mode 100644 index 0000000..8352b07 --- /dev/null +++ b/crates/wp-drivers/tests/longfred_settings.rs @@ -0,0 +1,119 @@ +//! Settings JSON helpers for the LongFred driver. + +use serde_json::json; +use wp_core::{BigfredCreds, ProgramRequest, RosterEntry, ThrottleServer, WifiCredentials}; +use wp_drivers::longfred::{build_settings_put, format_roster_addr, verify}; + +fn base_req<'a>() -> ProgramRequest<'a> { + ProgramRequest { + identity: "pilot1", + wifi: WifiCredentials { + ssid: "club-wifi", + psk: Some("secret"), + }, + server: ThrottleServer { + host: "unused.local", + port: 12090, + automatic: false, + }, + roster: vec![ + RosterEntry { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }, + RosterEntry { + address: Some(128), + long_address: Some(true), + mode: None, + direction: None, + functions: Vec::new(), + }, + ], + bigfred: Some(BigfredCreds { + login: "ops", + pin: "1234", + }), + roster_mode: Some("static"), + } +} + +#[test] +fn build_settings_put_shape() { + let body = build_settings_put(&base_req()); + assert_eq!(body["wifi"]["ssid"], "club-wifi"); + assert_eq!(body["wifi"]["password"], "secret"); + assert_eq!(body["wifi"]["hostname"], "pilot1"); + assert_eq!(body["bigfred"]["login"], "ops"); + assert_eq!(body["bigfred"]["pin"], "1234"); + assert_eq!(body["roster_mode"], "static"); + assert_eq!(body["roster"][0]["addr"], "S3"); + assert_eq!(body["roster"][1]["addr"], "L128"); +} + +#[test] +fn format_roster_addr_defaults_long_at_128() { + assert_eq!(format_roster_addr(42, None).as_deref(), Some("S42")); + assert_eq!(format_roster_addr(128, None).as_deref(), Some("L128")); + assert_eq!(format_roster_addr(10, Some(true)).as_deref(), Some("L10")); +} + +#[test] +fn verify_accepts_matching_settings() { + let settings = json!({ + "device": { "name": "Pilot", "id": 4242, "variant": "longfred-standard" }, + "wifi": { "hostname": "pilot1", "networks": ["club-wifi"] }, + "bigfred": { "login": "ops", "pin_set": true }, + "roster": { + "mode": "static", + "entries": [ { "addr": "S3" }, { "addr": "L128" } ] + }, + "programming_mode": true + }); + assert!(verify(&settings, &base_req()).is_empty()); +} + +#[test] +fn verify_reports_wifi_mismatch() { + let settings = json!({ + "wifi": { "hostname": "pilot1", "networks": ["other"] }, + "bigfred": { "login": "ops" }, + "roster": { "mode": "static", "entries": [] } + }); + let m = verify(&settings, &base_req()); + assert!(m.contains(&"wifi".into()), "{m:?}"); +} + +#[test] +fn verify_reports_bigfred_login_mismatch() { + let settings = json!({ + "wifi": { "hostname": "pilot1", "networks": ["club-wifi"] }, + "bigfred": { "login": "wrong" }, + "roster": { "mode": "static", "entries": [ + { "addr": "S3" }, { "addr": "L128" } + ] } + }); + let m = verify(&settings, &base_req()); + assert!( + m.contains(&"bigfred.login".into()), + "expected bigfred.login mismatch, got {m:?}" + ); +} + +#[test] +fn verify_reports_roster_mode_mismatch() { + let settings = json!({ + "wifi": { "hostname": "pilot1", "networks": ["club-wifi"] }, + "bigfred": { "login": "ops" }, + "roster": { "mode": "auto", "entries": [ + { "addr": "S3" }, { "addr": "L128" } + ] } + }); + let m = verify(&settings, &base_req()); + assert!( + m.contains(&"roster.mode".into()), + "expected roster.mode mismatch, got {m:?}" + ); +} diff --git a/crates/wp-drivers/tests/longfred_write.rs b/crates/wp-drivers/tests/longfred_write.rs new file mode 100644 index 0000000..508f877 --- /dev/null +++ b/crates/wp-drivers/tests/longfred_write.rs @@ -0,0 +1,176 @@ +//! Recording fake HTTP client + write-sequence tests for the LongFred driver. + +use std::io; + +use serde_json::json; +use wp_core::{ + BigfredCreds, DeviceDriver, HttpClient, ProgramRequest, RosterEntry, ThrottleServer, Transport, + WifiCredentials, +}; +use wp_drivers::LongFredDriver; + +struct FakeHttp { + /// Recorded as `(method, path, body)`. + requests: Vec<(String, String, Option>)>, + get_settings: std::collections::VecDeque>, +} + +impl FakeHttp { + fn queue_settings(&mut self, body: &[u8]) { + self.get_settings.push_back(body.to_vec()); + } +} + +impl HttpClient for FakeHttp { + fn request( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result> { + self.requests.push(( + method.to_string(), + path.to_string(), + body.map(|(_, b)| b.to_vec()), + )); + match (method, path) { + ("GET", "/api/v1/settings") => self + .get_settings + .pop_front() + .ok_or_else(|| io::Error::other("no queued settings")), + ("PUT", "/api/v1/settings") | ("POST", "/api/v1/programming-mode/off") => { + Ok(Vec::new()) + } + _ => Err(io::Error::other(format!("unexpected {method} {path}"))), + } + } +} + +fn ok_settings() -> Vec { + serde_json::to_vec(&json!({ + "device": { "name": "Pilot", "id": 4242, "variant": "longfred-standard" }, + "wifi": { "hostname": "pilot1", "networks": ["club-wifi"] }, + "bigfred": { "login": "ops", "pin_set": true }, + "roster": { + "mode": "static", + "entries": [ { "addr": "S3" }, { "addr": "L128" } ] + }, + "programming_mode": true + })) + .unwrap() +} + +fn make_request<'a>() -> ProgramRequest<'a> { + ProgramRequest { + identity: "pilot1", + wifi: WifiCredentials { + ssid: "club-wifi", + psk: Some("secret"), + }, + server: ThrottleServer { + host: "bigfred.local", + port: 12090, + automatic: false, + }, + roster: vec![ + RosterEntry { + address: Some(3), + long_address: Some(false), + mode: None, + direction: None, + functions: Vec::new(), + }, + RosterEntry { + address: Some(128), + long_address: Some(true), + mode: None, + direction: None, + functions: Vec::new(), + }, + ], + bigfred: Some(BigfredCreds { + login: "ops", + pin: "1234", + }), + roster_mode: Some("static"), + } +} + +#[tokio::test] +async fn program_put_verify_exit_order() { + let mut fake = FakeHttp { + requests: Vec::new(), + get_settings: std::collections::VecDeque::new(), + }; + fake.queue_settings(&ok_settings()); + + let req = make_request(); + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let outcome = LongFredDriver::new() + .program(transport, &req, &mut progress) + .await + .expect("program"); + assert!(outcome.restarted); + + assert_eq!(fake.requests.len(), 3); + assert_eq!(fake.requests[0].0, "PUT"); + assert_eq!(fake.requests[0].1, "/api/v1/settings"); + let put: serde_json::Value = + serde_json::from_slice(fake.requests[0].2.as_ref().unwrap()).unwrap(); + assert_eq!(put["wifi"]["ssid"], "club-wifi"); + assert_eq!(put["wifi"]["password"], "secret"); + assert_eq!(put["wifi"]["hostname"], "pilot1"); + assert_eq!(put["bigfred"]["login"], "ops"); + assert_eq!(put["roster"][0]["addr"], "S3"); + assert_eq!(put["roster"][1]["addr"], "L128"); + + assert_eq!(fake.requests[1].0, "GET"); + assert_eq!(fake.requests[1].1, "/api/v1/settings"); + + assert_eq!(fake.requests[2].0, "POST"); + assert_eq!(fake.requests[2].1, "/api/v1/programming-mode/off"); +} + +#[tokio::test] +async fn probe_returns_settings_json_with_variant() { + let mut fake = FakeHttp { + requests: Vec::new(), + get_settings: std::collections::VecDeque::new(), + }; + fake.queue_settings(&ok_settings()); + let transport = Transport::Http(&mut fake); + let info = LongFredDriver::new().probe(transport).await.expect("probe"); + assert_eq!(info["device"]["variant"], "longfred-standard"); + assert_eq!(fake.requests[0].0, "GET"); + assert_eq!(fake.requests[0].1, "/api/v1/settings"); +} + +#[tokio::test] +async fn program_skips_exit_on_verify_mismatch() { + let mut fake = FakeHttp { + requests: Vec::new(), + get_settings: std::collections::VecDeque::new(), + }; + fake.queue_settings( + &serde_json::to_vec(&json!({ + "wifi": { "hostname": "pilot1", "networks": ["wrong"] }, + "bigfred": { "login": "ops" }, + "roster": { "mode": "static", "entries": [] } + })) + .unwrap(), + ); + let req = make_request(); + let mut progress = wp_core::NoProgress; + let transport = Transport::Http(&mut fake); + let err = LongFredDriver::new() + .program(transport, &req, &mut progress) + .await + .expect_err("mismatch"); + assert!(matches!( + err, + wp_core::DriverError::VerificationFailed { .. } + )); + assert_eq!(fake.requests.len(), 2); + assert_eq!(fake.requests[1].0, "GET"); +} diff --git a/crates/wp-drivers/tests/wifred_write.rs b/crates/wp-drivers/tests/wifred_write.rs index cf2bda5..8f7578c 100644 --- a/crates/wp-drivers/tests/wifred_write.rs +++ b/crates/wp-drivers/tests/wifred_write.rs @@ -23,7 +23,13 @@ impl FakeHttp { } impl HttpClient for FakeHttp { - fn get(&mut self, path: &str) -> io::Result> { + fn request( + &mut self, + method: &str, + path: &str, + _body: Option<(&str, &[u8])>, + ) -> io::Result> { + let _ = method; self.requests.push(path.to_string()); if path == "/api/getConfigXML" { self.xml_responses @@ -68,6 +74,8 @@ fn make_request<'a>() -> ProgramRequest<'a> { FunctionMapping { index: 1, value: 4 }, ], }], + bigfred: None, + roster_mode: None, } } @@ -155,6 +163,8 @@ async fn program_disables_unused_slot_with_minus_one() { functions: vec![], }, ], + bigfred: None, + roster_mode: None, }; let mut progress = wp_core::NoProgress; let transport = Transport::Http(&mut fake); @@ -209,6 +219,8 @@ async fn program_emits_long_address_flag_only_when_true() { direction: Some(Direction::Normal as u8), functions: vec![], }], + bigfred: None, + roster_mode: None, }; let mut progress = wp_core::NoProgress; let transport = Transport::Http(&mut fake); @@ -269,6 +281,8 @@ async fn program_emits_automatic_flag_when_requested() { direction: Some(Direction::Normal as u8), functions: vec![], }], + bigfred: None, + roster_mode: None, }; let mut progress = wp_core::NoProgress; let transport = Transport::Http(&mut fake); @@ -347,6 +361,8 @@ async fn validate_rejects_too_many_vehicles() { automatic: false, }, roster, + bigfred: None, + roster_mode: None, }; let err = driver.validate(&req).expect_err("should reject"); assert!( @@ -376,6 +392,8 @@ async fn validate_rejects_non_digit_identity() { automatic: false, }, roster: vec![], + bigfred: None, + roster_mode: None, }; let err = driver.validate(&req).expect_err("should reject"); assert!( @@ -408,6 +426,8 @@ async fn validate_rejects_function_index_out_of_range() { value: 0, }], }], + bigfred: None, + roster_mode: None, }; let err = driver.validate(&req).expect_err("should reject"); assert!( @@ -434,6 +454,8 @@ async fn validate_rejects_empty_ssid() { automatic: false, }, roster: vec![], + bigfred: None, + roster_mode: None, }; let err = driver.validate(&req).expect_err("should reject"); assert!( diff --git a/crates/wp-link/src/http.rs b/crates/wp-link/src/http.rs index 0475f2e..6587ff8 100644 --- a/crates/wp-link/src/http.rs +++ b/crates/wp-link/src/http.rs @@ -1,9 +1,10 @@ -//! Bounded HTTP/1.1 GET client for device config pages. +//! Bounded HTTP/1.1 client for device config pages. //! -//! Plain HTTP only — the WiFred config AP serves on `192.168.4.1:80` with no +//! Plain HTTP only — Soft-AP config pages serve on an on-link address with no //! TLS. The client binds to a caller-supplied source address (e.g. -//! `192.168.4.2`) so requests leave the wireless interface, and enforces a -//! deadline, a maximum response body size, and a bounded retry count. +//! `192.168.4.2` or `192.168.0.2`) so requests leave the wireless interface, +//! and enforces a deadline, a maximum response body size, and a bounded retry +//! count. use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpStream}; @@ -24,7 +25,7 @@ pub const CONNECT_DEADLINE: Duration = Duration::from_secs(3); /// Default retry count (total attempts = retries + 1). pub const RETRIES: u32 = 3; -/// A bounded HTTP/1.1 GET client. +/// A bounded HTTP/1.1 client. pub struct BoundedHttpClient { /// Target host (IP literal). host: String, @@ -74,8 +75,13 @@ impl BoundedHttpClient { self } - /// Issue a single GET, returning the raw body. - fn get_once(&mut self, path: &str) -> io::Result> { + /// Issue a single request, returning the raw body. + fn request_once( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result> { let addr: SocketAddr = format!("{}:{}", self.host, self.port) .parse() .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; @@ -93,12 +99,22 @@ impl BoundedHttpClient { stream.set_write_timeout(Some(self.deadline))?; let mut stream = stream; - let request = format!( - "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n", + let mut request = format!( + "{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n", host = self.host, port = self.port, ); + if let Some((content_type, bytes)) = body { + request.push_str(&format!( + "Content-Type: {content_type}\r\nContent-Length: {}\r\n", + bytes.len() + )); + } + request.push_str("\r\n"); stream.write_all(request.as_bytes())?; + if let Some((_, bytes)) = body { + stream.write_all(bytes)?; + } stream.flush()?; let started = Instant::now(); @@ -160,10 +176,15 @@ impl BoundedHttpClient { } impl HttpClient for BoundedHttpClient { - fn get(&mut self, path: &str) -> io::Result> { + fn request( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result> { let mut last = io::Error::other("no attempt made"); for _ in 0..=self.retries { - match self.get_once(path) { + match self.request_once(method, path, body) { Ok(body) => return Ok(body), Err(e) => { last = e; @@ -264,13 +285,22 @@ mod tests { // A trivial in-memory HttpClient for driver tests. #[derive(Default)] pub struct FakeHttp { - pub requests: Vec, + pub requests: Vec<(String, String, Option>)>, pub responses: std::collections::VecDeque>, } impl HttpClient for FakeHttp { - fn get(&mut self, path: &str) -> io::Result> { - self.requests.push(path.to_string()); + fn request( + &mut self, + method: &str, + path: &str, + body: Option<(&str, &[u8])>, + ) -> io::Result> { + self.requests.push(( + method.to_string(), + path.to_string(), + body.map(|(_, b)| b.to_vec()), + )); self.responses .pop_front() .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "no fake response")) @@ -281,11 +311,13 @@ mod tests { fn fake_http_records_and_replies() { let mut f = FakeHttp { requests: Vec::new(), - responses: [b"HTTP/1.1 200 OK\r\n\r\nok".to_vec()].into(), + responses: [b"ok".to_vec()].into(), }; let body = ::get(&mut f, "/index.html?x=1"); - let _ = body; - assert_eq!(f.requests, vec!["/index.html?x=1".to_string()]); + assert_eq!(body.unwrap(), b"ok"); + assert_eq!(f.requests.len(), 1); + assert_eq!(f.requests[0].0, "GET"); + assert_eq!(f.requests[0].1, "/index.html?x=1"); let _ = Cursor::new(b""); } } diff --git a/crates/wp-link/src/lib.rs b/crates/wp-link/src/lib.rs index bc089dc..6802cd3 100644 --- a/crates/wp-link/src/lib.rs +++ b/crates/wp-link/src/lib.rs @@ -8,5 +8,8 @@ pub mod radio; pub mod rfkill; pub use http::{percent_encode, BoundedHttpClient, MAX_BODY_BYTES}; -pub use radio::{first_wireless_interface, Nl80211Radio, Radio, ScanResult}; +pub use radio::{ + first_wireless_interface, is_wireless_interface, resolve_wireless_interface, Nl80211Radio, + Radio, ScanResult, +}; pub use rfkill::{aggregate_state, RfkillState}; diff --git a/crates/wp-link/src/radio.rs b/crates/wp-link/src/radio.rs index c45e12a..5ca669d 100644 --- a/crates/wp-link/src/radio.rs +++ b/crates/wp-link/src/radio.rs @@ -73,6 +73,53 @@ pub fn first_wireless_interface() -> Result { Err(DriverError::NoInterface) } +/// Return `true` when `/sys/class/net/{name}/wireless` exists. +#[must_use] +pub fn is_wireless_interface(name: &str) -> bool { + !name.is_empty() + && Path::new("/sys/class/net") + .join(name) + .join("wireless") + .exists() +} + +/// Resolve the wireless interface to use. +/// +/// When `preferred` is `Some(name)`, that name must exist and be wireless. +/// When `None`, the first wireless interface is selected (same as +/// [`first_wireless_interface`]). +/// +/// # Errors +/// +/// - [`DriverError::NoInterface`] when auto-select finds nothing. +/// - [`DriverError::Other`] when a preferred name is empty, missing, or not +/// wireless. +pub fn resolve_wireless_interface(preferred: Option<&str>) -> Result { + match preferred { + None => first_wireless_interface(), + Some(name) => { + let name = name.trim(); + if name.is_empty() { + return Err(DriverError::Other( + "wireless interface name must not be empty".into(), + )); + } + let path = Path::new("/sys/class/net").join(name); + if !path.exists() { + return Err(DriverError::Other(format!( + "interface {name} does not exist" + ))); + } + if !path.join("wireless").exists() { + return Err(DriverError::Other(format!( + "interface {name} is not wireless" + ))); + } + Ok(name.to_string()) + } + } +} + /// Resolve an interface name to its netlink ifindex via `/sys/class/net`. fn interface_index(name: &str) -> Result { let p = Path::new("/sys/class/net").join(name).join("ifindex"); @@ -85,8 +132,9 @@ fn interface_index(name: &str) -> Result { /// `wl-nl80211` + `rtnetlink` backed radio. /// -/// Construct with [`Nl80211Radio::new`]; requires `CAP_NET_ADMIN` and -/// `CAP_NET_RAW`. All operations are async and run on a tokio runtime. +/// Construct with [`Nl80211Radio::new`] or [`Nl80211Radio::with_interface`]; +/// requires `CAP_NET_ADMIN` and `CAP_NET_RAW`. All operations are async and +/// run on a tokio runtime. /// /// The nl80211 scan/connect and rtnetlink addressing paths require a real /// wireless adapter to exercise end-to-end; they are kept minimal here and @@ -103,12 +151,31 @@ impl Nl80211Radio { /// /// Returns [`DriverError::NoInterface`] when no wireless interface exists. pub fn new() -> Result { - let iface = first_wireless_interface()?; + Self::with_interface_opt(None) + } + + /// Bind to a named wireless interface (e.g. `wlan0`, `wlp2s0`). + /// + /// # Errors + /// + /// Returns [`DriverError::Other`] when the name is missing or not wireless. + pub fn with_interface(name: &str) -> Result { + Self::with_interface_opt(Some(name)) + } + + /// Bind to `preferred` when set, otherwise the first wireless interface. + /// + /// # Errors + /// + /// See [`resolve_wireless_interface`]. + pub fn with_interface_opt(preferred: Option<&str>) -> Result { + let iface = resolve_wireless_interface(preferred)?; let if_index = interface_index(&iface)?; Ok(Self { iface, if_index }) } /// Interface name. + #[must_use] pub fn iface(&self) -> &str { &self.iface } @@ -228,3 +295,55 @@ impl Radio for Nl80211Radio { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_rejects_empty_preferred() { + let err = resolve_wireless_interface(Some("")).expect_err("empty"); + assert!( + matches!(err, DriverError::Other(ref m) if m.contains("empty")), + "{err:?}" + ); + let err = resolve_wireless_interface(Some(" ")).expect_err("whitespace"); + assert!( + matches!(err, DriverError::Other(ref m) if m.contains("empty")), + "{err:?}" + ); + } + + #[test] + fn resolve_rejects_missing_interface() { + let err = resolve_wireless_interface(Some("wlan-does-not-exist-xyz")).expect_err("missing"); + assert!( + matches!(err, DriverError::Other(ref m) if m.contains("does not exist")), + "{err:?}" + ); + } + + #[test] + fn resolve_rejects_non_wireless_loopback() { + // `lo` exists on every Linux host and is never wireless. + if !Path::new("/sys/class/net/lo").exists() { + return; + } + let err = resolve_wireless_interface(Some("lo")).expect_err("not wireless"); + assert!( + matches!(err, DriverError::Other(ref m) if m.contains("not wireless")), + "{err:?}" + ); + assert!(!is_wireless_interface("lo")); + } + + #[test] + fn resolve_accepts_existing_wireless_when_present() { + let Ok(first) = first_wireless_interface() else { + return; + }; + let resolved = resolve_wireless_interface(Some(&first)).expect("named wireless"); + assert_eq!(resolved, first); + assert!(is_wireless_interface(&first)); + } +} diff --git a/crates/wp-proto/src/results.rs b/crates/wp-proto/src/results.rs index b7fb7db..a51491d 100644 --- a/crates/wp-proto/src/results.rs +++ b/crates/wp-proto/src/results.rs @@ -67,6 +67,23 @@ pub struct CapabilitiesWire { pub supports_throttle_server: bool, /// How the device is commissioned. pub commissioning: CommissioningKindWire, + /// Soft-AP addressing for commissioning, when not using daemon defaults. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub commissioning_net: Option, +} + +/// On-link Soft-AP addressing advertised by a driver. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommissioningNetWire { + /// Device Soft-AP address (dotted IPv4). + pub host: String, + /// HTTP port on the Soft-AP. + pub port: u16, + /// Address the hub should assign on the wireless interface. + pub source: String, + /// Prefix length for the on-link route. + pub prefix: u8, } /// Identity format constraints. diff --git a/crates/wp-proto/src/wire.rs b/crates/wp-proto/src/wire.rs index 82612ef..2fce687 100644 --- a/crates/wp-proto/src/wire.rs +++ b/crates/wp-proto/src/wire.rs @@ -156,6 +156,22 @@ pub struct ProgramRequestWire { pub server: ThrottleServerWire, /// DCC vehicle list (capped by the driver's `max_roster_slots`). pub roster: Vec, + /// Optional BigFred login+PIN (LongFred and similar). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bigfred: Option, + /// Optional roster mode string (e.g. `"auto"` / `"static"` for LongFred). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub roster_mode: Option, +} + +/// BigFred login credentials on the wire. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BigfredCredsWire { + /// BigFred login name. + pub login: String, + /// BigFred PIN (never logged by the daemon). + pub pin: String, } /// WiFi credentials. diff --git a/docs/api.md b/docs/api.md index ff92936..7b23e38 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,18 +41,21 @@ 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, throttle-server support). +commissioning kind, optional Soft-AP `commissioningNet`, throttle-server +support). ### `scan` -Triggers an nl80211 scan and returns the candidates each driver claims. For -WiFred this is every AP whose SSID starts with `wiFred-config`. +Triggers an nl80211 scan and returns the candidates each driver claims: + +- WiFred: every AP whose SSID starts with `wiFred-config` +- LongFred: every AP whose SSID starts with `longfred_prog` ### `probe` Reads a single candidate's device info over the radio (associate → HTTP GET -`/api/getConfigXML` → parse → release). Returns firmware revision, identity, -battery voltage and the stored roster when the device exposes them. +→ parse → release). For WiFred this is `/api/getConfigXML`; for LongFred it +is `/api/v1/settings` (JSON, including `device.variant` when present). ### `program` @@ -65,7 +68,7 @@ request body is supplied by the caller (`bigfred`/`bigfred-wizard`), keeping ```jsonc { - "identity": "122145", // opaque; for WiFred: 6-digit BigFred pairing code + "identity": "122145", // opaque; WiFred: 6-digit pairing code; LongFred: hostname "wifi": { "ssid": "bigfred2", "psk": "..." }, "server": { "host": "bigfred.local", "port": 12090, "automatic": false }, "roster": [ @@ -74,10 +77,16 @@ request body is supplied by the caller (`bigfred`/`bigfred-wizard`), keeping "direction": 0, "functions": [ { "index": 0, "value": 0 }, { "index": 1, "value": 4 } ] } - ] + ], + // LongFred (optional): + "bigfred": { "login": "ops", "pin": "1234" }, + "rosterMode": "static" } ``` +See [`drivers/wifred.md`](drivers/wifred.md) and +[`drivers/longfred.md`](drivers/longfred.md) for per-driver write sequences. + The job runs through the state machine: `queued → joining → probing → writing → verifying → restarting → done`. Progress is observable via `job.watch`. @@ -145,7 +154,7 @@ over the same socket. Every client subcommand accepts `--json` | `job get\|watch\|cancel --id` | Inspect or control a running job | | `link-status` | Report radio/link state | | `hello` | Exchange version + driver capabilities | -| `daemon [--verbose]` | Run the IPC daemon (also the default with no subcommand) | +| `daemon [--verbose] [-i|--interface IFACE]` | Run the IPC daemon (also the default with no subcommand) | `program` builds the request either from individual flags (`--identity`, `--wifi-ssid`, `--wifi-psk` / `--wifi-psk-file`, `--server-host`, diff --git a/docs/cli.md b/docs/cli.md index 2ceaa22..de95117 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,10 +18,11 @@ Commands: job Inspect or control a running job Options: - --socket Override the daemon socket path (every subcommand) - -v, --verbose Verbose logging (daemon only) - -h, --help Print help - -V, --version Print version + --socket Override the daemon socket path (every subcommand) + -i, --interface Wireless interface for the daemon (e.g. wlan0) + -v, --verbose Verbose logging (daemon only) + -h, --help Print help + -V, --version Print version ``` ## Socket resolution @@ -47,6 +48,22 @@ whose primary group should own it. If that user does not exist, or the daemon is not privileged enough to chown, it logs a warning and leaves the socket owner-only — useful on a development machine, fatal for peers. +## Wireless interface + +By default the daemon picks the first interface under `/sys/class/net` that +has a `wireless` subdirectory. On a hub with more than one radio, pin it: + +```bash +wireless-programmer --interface wlan1 +wireless-programmer daemon -i wlp2s0 --verbose +``` + +`--interface` / `-i` is accepted both at the top level (when starting the +daemon with no subcommand) and on `daemon`. A missing or non-wireless name +fails at start-up with a non-zero exit. The same choice can be set with +`WIRELESS_PROGRAMMER_INTERFACE`; the CLI flag overrides the environment. +`link-status` reports the configured (or auto-selected) interface name. + Every client subcommand accepts: - `--json` — emit machine-readable JSON instead of human-readable text; @@ -241,4 +258,5 @@ remain machine-parseable. | `DATA_DIR` | Fallback data root | | `WIRELESS_PROGRAMMER_ALLOW_USERS` | Comma-separated peer allowlist (daemon only) | | `WIRELESS_PROGRAMMER_SOCKET_GROUP_USER` | Login name whose primary group owns the socket (daemon only; defaults to the first allowlist entry) | +| `WIRELESS_PROGRAMMER_INTERFACE` | Wireless interface name for the daemon (e.g. `wlan0`); overridden by `--interface` | | `WIRELESS_PROGRAMMER_GIT_COMMIT` | Git commit baked into the `hello` response (build-time) | diff --git a/docs/drivers/longfred.md b/docs/drivers/longfred.md new file mode 100644 index 0000000..b6181ff --- /dev/null +++ b/docs/drivers/longfred.md @@ -0,0 +1,66 @@ +# LongFred driver + +Implements [`wp_core::DeviceDriver`] for LongFred throttles in Soft-AP +programming mode. + +## Commissioning model + +In programming mode the firmware raises an **open** WiFi AP named +`longfred_prog_XXXXXX` (6 hex characters derived from the MAC). The Soft-AP +uses a static address `192.168.0.1/24` (not the ESP-IDF Soft-AP default of +`192.168.4.1`). The driver advertises this via +`capabilities.commissioningNet`: + +| Field | Value | +|----------|----------------| +| `host` | `192.168.0.1` | +| `port` | `80` | +| `source` | `192.168.0.2` | +| `prefix` | `24` | + +The daemon should associate to the open AP, assign `192.168.0.2/24` on the +wireless interface (**no default route**), hand a sync `HttpClient` to the +driver, and release the radio on every exit path. + +Candidate identity: SSID prefix `longfred_prog`, stable key = BSSID. + +## Capabilities + +| Field | Value | +|--------------------------|------------------------------------| +| `maxRosterSlots` | 12 | +| `maxFunctionIndex` | 0 (no function maps via settings) | +| `identityFormat` | `Alphanumeric { max_len: 16 }` | +| `supportsThrottleServer` | true (field accepted, unused) | +| `commissioning` | `SoftAp` | +| `commissioningNet` | `192.168.0.1` / source `.2` /24 | + +`identity` is written as `wifi.hostname`. BigFred authentication uses the +optional `bigfred.login` / `bigfred.pin` fields on `ProgramRequest` (not the +6-digit wiThrottle pairing code used by WiFred). + +## Read-back / probe + +`GET /api/v1/settings` returns the device's current configuration as JSON, +including `device.variant` when the firmware exposes it. Probe returns that +JSON document as-is. + +## Write sequence + +1. `PUT /api/v1/settings` with a JSON body built from the request: + - `wifi.ssid` / `wifi.password` / optional `wifi.hostname` + - optional `bigfred.login` / `bigfred.pin` + - optional `roster_mode` (`auto` / `static`) + - `roster` entries as `{ "addr": "S3" }` / `{ "addr": "L128" }` +2. `GET /api/v1/settings` — verify WiFi SSID, hostname, BigFred login, + roster mode and addresses. +3. `POST /api/v1/programming-mode/off` — clear the flag; the firmware resets + and leaves the Soft-AP. + +The PSK / PIN are never logged by the daemon. + +## Testing + +Covered by unit tests in `longfred/discovery.rs` and `longfred/settings.rs`, +plus `crates/wp-drivers/tests/longfred_write.rs` which asserts the +PUT → GET → POST order and the PUT JSON shape. diff --git a/docs/go-client.md b/docs/go-client.md index d4a7ca1..739493c 100644 --- a/docs/go-client.md +++ b/docs/go-client.md @@ -189,8 +189,8 @@ 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}` -- `ProgramRequestWire{Identity, Wifi, Server, Roster []RosterEntryWire}` +- `DriverInfoWire{ID, Name, Capabilities}` / `CapabilitiesWire{MaxRosterSlots, MaxFunctionIndex, IdentityFormat, SupportsThrottleServer, Commissioning, CommissioningNet}` +- `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}` - `DeviceInfoWire{Driver, Key, FirmwareRevision, Identity, BatteryMV *uint32, Roster}` diff --git a/go/client/client.go b/go/client/client.go index 45e57f7..e454c80 100644 --- a/go/client/client.go +++ b/go/client/client.go @@ -38,11 +38,20 @@ type CommissioningKindWire string // CapabilitiesWire mirrors wp_proto::CapabilitiesWire. type CapabilitiesWire struct { - MaxRosterSlots uint8 `json:"maxRosterSlots"` - MaxFunctionIndex uint8 `json:"maxFunctionIndex"` + MaxRosterSlots uint8 `json:"maxRosterSlots"` + MaxFunctionIndex uint8 `json:"maxFunctionIndex"` IdentityFormat IdentityFormatWire `json:"identityFormat"` SupportsThrottleServer bool `json:"supportsThrottleServer"` - Commissioning CommissioningKindWire `json:"commissioning"` + Commissioning CommissioningKindWire `json:"commissioning"` + CommissioningNet *CommissioningNetWire `json:"commissioningNet,omitempty"` +} + +// CommissioningNetWire mirrors wp_proto::CommissioningNetWire. +type CommissioningNetWire struct { + Host string `json:"host"` + Port uint16 `json:"port"` + Source string `json:"source"` + Prefix uint8 `json:"prefix"` } // DriverInfoWire mirrors wp_proto::DriverInfoWire. @@ -103,10 +112,18 @@ type RosterEntryWire struct { // ProgramRequestWire mirrors wp_proto::ProgramRequestWire. type ProgramRequestWire struct { - Identity string `json:"identity"` - Wifi WifiCredentialsWire `json:"wifi"` - Server ThrottleServerWire `json:"server"` - Roster []RosterEntryWire `json:"roster"` + Identity string `json:"identity"` + Wifi WifiCredentialsWire `json:"wifi"` + Server ThrottleServerWire `json:"server"` + Roster []RosterEntryWire `json:"roster"` + Bigfred *BigfredCredsWire `json:"bigfred,omitempty"` + RosterMode string `json:"rosterMode,omitempty"` +} + +// BigfredCredsWire mirrors wp_proto::BigfredCredsWire. +type BigfredCredsWire struct { + Login string `json:"login"` + PIN string `json:"pin"` } // DeviceInfoWire mirrors wp_proto::DeviceInfoWire.