//! The config contract between this crate and the consuming firmware.
//!
//! The crate owns the *mechanism* (NVS blob storage, the portal form, WiFi
//! bring-up) but not the *schema*: each device has its own settings. A
//! consumer defines its own struct, embeds the fragments it wants with
//! `#[serde(flatten)]`, and implements [`PortalConfig`].
//!
//! ```ignore
//! #[derive(Serialize, Deserialize, Clone, Default)]
//! struct Config {
//! #[serde(flatten)] wifi: WifiConfig,
//! #[serde(flatten)] mqtt: MqttConfig, // feature = "mqtt"
//! #[serde(default)] port_count: u16,
//! }
//!
//! impl PortalConfig for Config {
//! fn from_form(f: &Form) -> Self {
//! Self {
//! wifi: WifiConfig::from_form(f),
//! mqtt: MqttConfig::from_form(f),
//! port_count: f.parsed_or("port_count", 2),
//! }
//! }
//! fn wifi(&self) -> &WifiConfig { &self.wifi }
//! fn is_provisioned(&self) -> bool {
//! self.wifi.is_complete() && self.mqtt.is_complete()
//! }
//! }
//! ```
//!
//! `flatten` keeps the stored JSON **flat** (`{"wifi_ssid":…,"mqtt_host":…}`),
//! which is byte-compatible with blobs already written by `nfc-522` before the
//! extraction — devices in the field keep their config across the upgrade.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::form::Form;
/// A device's persisted settings.
///
/// `Default` must produce the unprovisioned state: it is what a device falls
/// back to when NVS is empty or the stored blob fails to parse.
pub trait PortalConfig:
Serialize + DeserializeOwned + Default + Clone + Send + Sync + 'static
{
/// Build a config from a submitted portal form.
fn from_form(form: &Form) -> Self;
/// The WiFi credentials, which the crate needs in order to bring up STA
/// mode and to decide whether the device can leave provisioning.
fn wifi(&self) -> &WifiConfig;
/// Whether this config is complete enough to run normally.
///
/// Defaults to "has an SSID". Override when the device also needs, say, a
/// broker before normal operation makes sense.
fn is_provisioned(&self) -> bool {
self.wifi().is_complete()
}
}
/// One set of station credentials: an SSID and, unless the network is open,
/// its passphrase.
///
/// A device carries more than one of these so it can be moved between known
/// networks -- a bench AP and the one it actually lives on -- without being
/// re-provisioned in between. See [`WifiConfig::networks`].
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct WifiNetwork {
#[serde(default)]
pub ssid: String,
#[serde(default)]
pub pass: String,
}
impl WifiNetwork {
pub fn new(ssid: impl Into<String>, pass: impl Into<String>) -> Self {
Self {
ssid: ssid.into(),
pass: pass.into(),
}
}
/// An empty passphrase means an open network, so only the SSID is required.
pub fn is_complete(&self) -> bool {
!self.ssid.is_empty()
}
/// Whether to use WPA2 (vs an open network).
pub fn is_protected(&self) -> bool {
!self.pass.is_empty()
}
}
/// How many networks the portal form offers, the primary included.
///
/// Bounded rather than open-ended because the form is a fixed page and the
/// stored blob has a size cap ([`crate::store`]); three covers home, work and
/// a phone hotspot, which is what a device that moves actually needs.
pub const MAX_NETWORKS: usize = 3;
/// WiFi station credentials. Every consumer needs these, so they are a
/// first-class fragment rather than something each app redeclares.
///
/// The primary network keeps the flat `wifi_ssid`/`wifi_pass` keys it has
/// always had, so blobs written before this existed load unchanged and
/// consumers reading `.wifi_ssid` still compile. Alternates live in
/// `wifi_alts`, which is absent from those older blobs and defaults to empty.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct WifiConfig {
#[serde(default)]
pub wifi_ssid: String,
#[serde(default)]
pub wifi_pass: String,
/// Networks to try when the primary is not the one in range. Order is the
/// order they were entered; it is only a tie-break, since
/// [`crate::wifi`] scans and prefers whichever known SSID is loudest.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wifi_alts: Vec<WifiNetwork>,
}
impl WifiConfig {
/// Field names this fragment reads from the portal form.
///
/// The alternates are numbered from 2, matching the primary being 1
/// without saying so: `wifi_ssid_2`, `wifi_pass_2`, and so on.
pub const FIELDS: &'static [&'static str] = &[
"wifi_ssid",
"wifi_pass",
"wifi_ssid_2",
"wifi_pass_2",
"wifi_ssid_3",
"wifi_pass_3",
];
pub fn from_form(form: &Form) -> Self {
// The SSID is trimmed (leading/trailing spaces in an SSID are almost
// always a paste artifact) but the passphrase is NOT — spaces are
// legal in WPA2 passphrases and trimming would silently break a valid
// one.
let read = |ssid_key: &str, pass_key: &str| {
WifiNetwork::new(form.str(ssid_key).trim(), form.str(pass_key))
};
Self {
wifi_ssid: form.str("wifi_ssid").trim().to_string(),
wifi_pass: form.str("wifi_pass").to_string(),
// A passphrase with no SSID beside it is a half-filled row, not a
// network: dropped, rather than stored as something that can never
// be joined.
wifi_alts: (2..=MAX_NETWORKS)
.map(|n| read(&format!("wifi_ssid_{n}"), &format!("wifi_pass_{n}")))
.filter(WifiNetwork::is_complete)
.collect(),
}
}
/// Every network this device may join, primary first.
///
/// Blank entries are dropped and repeated SSIDs collapse to their first
/// appearance, so a form submitted with the same network typed twice does
/// not cost an extra join attempt.
pub fn networks(&self) -> Vec<WifiNetwork> {
let mut out: Vec<WifiNetwork> = Vec::with_capacity(1 + self.wifi_alts.len());
let primary = WifiNetwork::new(self.wifi_ssid.as_str(), self.wifi_pass.as_str());
for net in std::iter::once(primary).chain(self.wifi_alts.iter().cloned()) {
if net.is_complete() && !out.iter().any(|seen| seen.ssid == net.ssid) {
out.push(net);
}
}
out
}
/// The SSIDs this device may join, primary first — for logging and for
/// putting on a screen. Never secret.
pub fn ssids(&self) -> Vec<String> {
self.networks().into_iter().map(|net| net.ssid).collect()
}
/// An empty passphrase means an open network, so only the SSID is
/// required.
///
/// An alternate cannot stand in for a missing primary: the portal writes
/// the first row it is given into the primary, and a config with only
/// alternates can only come from a hand-written blob.
pub fn is_complete(&self) -> bool {
!self.wifi_ssid.is_empty()
}
/// Whether the primary network uses WPA2 (vs being open).
pub fn is_protected(&self) -> bool {
!self.wifi_pass.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_wifi_fields() {
let f = Form::parse("wifi_ssid=my+net&wifi_pass=hunter2");
let w = WifiConfig::from_form(&f);
assert_eq!(w.wifi_ssid, "my net");
assert_eq!(w.wifi_pass, "hunter2");
assert!(w.is_complete());
assert!(w.is_protected());
}
#[test]
fn open_network_has_no_password() {
let w = WifiConfig::from_form(&Form::parse("wifi_ssid=cafe&wifi_pass="));
assert!(w.is_complete());
assert!(!w.is_protected());
}
#[test]
fn missing_ssid_is_incomplete() {
assert!(!WifiConfig::default().is_complete());
assert!(!WifiConfig::from_form(&Form::parse("wifi_pass=x")).is_complete());
}
#[test]
fn ssid_is_trimmed_but_passphrase_is_not() {
// A passphrase may legitimately contain leading/trailing spaces.
let f = Form::parse("wifi_ssid=++net++&wifi_pass=++pass++");
let w = WifiConfig::from_form(&f);
assert_eq!(w.wifi_ssid, "net");
assert_eq!(w.wifi_pass, " pass ");
}
#[test]
fn round_trips_as_flat_json() {
let w = WifiConfig {
wifi_ssid: "net".into(),
wifi_pass: "pw".into(),
..Default::default()
};
let json = serde_json::to_string(&w).unwrap();
assert_eq!(json, r#"{"wifi_ssid":"net","wifi_pass":"pw"}"#);
assert_eq!(serde_json::from_str::<WifiConfig>(&json).unwrap(), w);
}
#[test]
fn reads_the_numbered_alternates() {
let f = Form::parse(
"wifi_ssid=home&wifi_pass=one&wifi_ssid_2=office&wifi_pass_2=two&\
wifi_ssid_3=hotspot&wifi_pass_3=",
);
let w = WifiConfig::from_form(&f);
assert_eq!(w.wifi_ssid, "home");
assert_eq!(
w.wifi_alts,
vec![
WifiNetwork::new("office", "two"),
WifiNetwork::new("hotspot", ""),
]
);
assert_eq!(w.ssids(), ["home", "office", "hotspot"]);
}
#[test]
fn a_passphrase_with_no_ssid_is_not_a_network() {
let w = WifiConfig::from_form(&Form::parse("wifi_ssid=home&wifi_ssid_2=&wifi_pass_2=x"));
assert!(w.wifi_alts.is_empty());
assert_eq!(w.ssids(), ["home"]);
}
#[test]
fn networks_drop_repeats_and_blanks() {
let w = WifiConfig {
wifi_ssid: "home".into(),
wifi_pass: "one".into(),
wifi_alts: vec![
WifiNetwork::new("home", "stale"),
WifiNetwork::new("", "orphan"),
WifiNetwork::new("office", "two"),
],
};
// The primary wins the duplicate, so re-entering the network you are
// already on cannot quietly replace its passphrase with an older one.
assert_eq!(
w.networks(),
vec![
WifiNetwork::new("home", "one"),
WifiNetwork::new("office", "two")
]
);
}
#[test]
fn alternates_stay_out_of_the_blob_when_there_are_none() {
let w = WifiConfig {
wifi_ssid: "net".into(),
wifi_pass: "pw".into(),
wifi_alts: Vec::new(),
};
// Byte-identical to what devices in the field already store.
assert_eq!(
serde_json::to_string(&w).unwrap(),
r#"{"wifi_ssid":"net","wifi_pass":"pw"}"#
);
}
#[test]
fn alternates_round_trip() {
let w = WifiConfig {
wifi_ssid: "home".into(),
wifi_pass: "one".into(),
wifi_alts: vec![WifiNetwork::new("office", "two")],
};
let json = serde_json::to_string(&w).unwrap();
assert_eq!(serde_json::from_str::<WifiConfig>(&json).unwrap(), w);
}
#[test]
fn tolerates_missing_fields_in_stored_blob() {
// Forward/backward compatibility: an old blob without wifi_pass loads.
let w: WifiConfig = serde_json::from_str(r#"{"wifi_ssid":"net"}"#).unwrap();
assert_eq!(w.wifi_ssid, "net");
assert_eq!(w.wifi_pass, "");
}
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.3 KiB · text
History
b85db9e