11.3 KiB · text History b85db9e
//! 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, "");
    }
}