9.1 KiB · text History 65cab78
//! Optional MQTT broker settings fragment (`feature = "mqtt"`).
//!
//! This is *config only* — it deliberately does not build or own an MQTT
//! client, so the crate stays useful to devices that speak something else (or
//! nothing) over the network. `nfc-522` keeps its own `mqtt.rs` and feeds it
//! from this struct.
//!
//! Field names match the blob `nfc-522` already writes to NVS, so enabling
//! this feature reads existing device configs unchanged.

use serde::{Deserialize, Serialize};

use crate::form::Form;

/// Default broker port for plaintext MQTT.
pub const DEFAULT_PORT: u16 = 1883;
/// Default broker port for MQTT over TLS.
pub const DEFAULT_TLS_PORT: u16 = 8883;
/// Default topic namespace.
pub const DEFAULT_TOPIC_ROOT: &str = "neiam";

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct MqttConfig {
    #[serde(default)]
    pub mqtt_host: String,
    #[serde(default = "default_port")]
    pub mqtt_port: u16,
    #[serde(default)]
    pub mqtt_username: Option<String>,
    #[serde(default)]
    pub mqtt_password: Option<String>,
    #[serde(default)]
    pub mqtt_use_tls: bool,
    #[serde(default = "default_topic_root")]
    pub topic_root: String,
}

fn default_port() -> u16 {
    DEFAULT_PORT
}

fn default_topic_root() -> String {
    DEFAULT_TOPIC_ROOT.to_string()
}

impl Default for MqttConfig {
    fn default() -> Self {
        Self {
            mqtt_host: String::new(),
            mqtt_port: DEFAULT_PORT,
            mqtt_username: None,
            mqtt_password: None,
            mqtt_use_tls: false,
            topic_root: default_topic_root(),
        }
    }
}

impl MqttConfig {
    /// Field names this fragment reads from the portal form.
    pub const FIELDS: &'static [&'static str] = &[
        "mqtt_host",
        "mqtt_port",
        "mqtt_username",
        "mqtt_password",
        "mqtt_use_tls",
        "topic_root",
    ];

    pub fn from_form(form: &Form) -> Self {
        Self {
            mqtt_host: form.str("mqtt_host").trim().to_string(),
            mqtt_port: form.parsed_or("mqtt_port", DEFAULT_PORT),
            mqtt_username: form.non_empty("mqtt_username"),
            mqtt_password: form.non_empty("mqtt_password"),
            // Absent (unchecked box) reads as false; see `Form::checkbox`.
            mqtt_use_tls: form.checkbox("mqtt_use_tls"),
            topic_root: form.trimmed_or("topic_root", DEFAULT_TOPIC_ROOT),
        }
    }

    /// A broker host is the minimum needed to attempt a connection.
    pub fn is_complete(&self) -> bool {
        !self.mqtt_host.is_empty()
    }

    /// Broker URL, e.g. `mqtt://10.0.0.5:1883` or `mqtts://…:8883`.
    pub fn url(&self) -> String {
        let scheme = if self.mqtt_use_tls { "mqtts" } else { "mqtt" };
        format!("{scheme}://{}:{}", self.mqtt_host, self.mqtt_port)
    }

    /// Build a topic under the configured root.
    ///
    /// `cfg.topic(&["nfc", id, "status"])` -> `neiam/nfc/0a1b2c/status`. The
    /// crate has no opinion about the segments; the *shape* of a device's topic
    /// tree belongs to that device's firmware.
    pub fn topic(&self, segments: &[&str]) -> String {
        let mut out = String::with_capacity(
            self.topic_root.len() + segments.iter().map(|s| s.len() + 1).sum::<usize>(),
        );
        out.push_str(self.topic_root.trim_end_matches('/'));
        for segment in segments {
            out.push('/');
            out.push_str(segment.trim_matches('/'));
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn full_form() -> Form {
        Form::parse(
            "mqtt_host=10.0.0.5&mqtt_port=8883&mqtt_username=u&mqtt_password=p\
             &mqtt_use_tls=on&topic_root=cbz",
        )
    }

    #[test]
    fn reads_all_fields() {
        let c = MqttConfig::from_form(&full_form());
        assert_eq!(c.mqtt_host, "10.0.0.5");
        assert_eq!(c.mqtt_port, 8883);
        assert_eq!(c.mqtt_username.as_deref(), Some("u"));
        assert_eq!(c.mqtt_password.as_deref(), Some("p"));
        assert!(c.mqtt_use_tls);
        assert_eq!(c.topic_root, "cbz");
    }

    #[test]
    fn unchecked_tls_box_is_false() {
        // The original nfc-522 parser needed a `tls_seen` flag for this; the
        // shared `Form::checkbox` handles absence directly.
        let c = MqttConfig::from_form(&Form::parse("mqtt_host=h"));
        assert!(!c.mqtt_use_tls);
    }

    #[test]
    fn blank_credentials_become_none() {
        let c = MqttConfig::from_form(&Form::parse("mqtt_host=h&mqtt_username=&mqtt_password="));
        assert_eq!(c.mqtt_username, None);
        assert_eq!(c.mqtt_password, None);
    }

    #[test]
    fn invalid_or_missing_port_falls_back() {
        assert_eq!(
            MqttConfig::from_form(&Form::parse("mqtt_port=nope")).mqtt_port,
            DEFAULT_PORT
        );
        assert_eq!(
            MqttConfig::from_form(&Form::parse("mqtt_host=h")).mqtt_port,
            DEFAULT_PORT
        );
    }

    #[test]
    fn blank_topic_root_falls_back_to_default() {
        let c = MqttConfig::from_form(&Form::parse("topic_root=+++"));
        assert_eq!(c.topic_root, DEFAULT_TOPIC_ROOT);
    }

    #[test]
    fn builds_urls() {
        let mut c = MqttConfig::from_form(&Form::parse("mqtt_host=h&mqtt_port=1883"));
        assert_eq!(c.url(), "mqtt://h:1883");
        c.mqtt_use_tls = true;
        c.mqtt_port = DEFAULT_TLS_PORT;
        assert_eq!(c.url(), "mqtts://h:8883");
    }

    #[test]
    fn builds_topics() {
        let c = MqttConfig {
            topic_root: "neiam".into(),
            ..Default::default()
        };
        // Matches nfc-522's original scan/status/ip topic shapes.
        assert_eq!(
            c.topic(&["nfc", "0a1b2c", "status"]),
            "neiam/nfc/0a1b2c/status"
        );
        assert_eq!(
            c.topic(&["nfc", "0a1b2c", "scans", "0"]),
            "neiam/nfc/0a1b2c/scans/0"
        );
        assert_eq!(c.topic(&[]), "neiam");
    }

    #[test]
    fn topic_does_not_double_up_slashes() {
        let c = MqttConfig {
            topic_root: "neiam/".into(),
            ..Default::default()
        };
        assert_eq!(c.topic(&["/nfc/", "id"]), "neiam/nfc/id");
    }

    #[test]
    fn stored_blob_compatibility() {
        // A blob written by nfc-522 before the extraction must still load.
        let stored = r#"{"wifi_ssid":"net","wifi_pass":"pw","mqtt_host":"10.0.0.5",
            "mqtt_port":1883,"mqtt_username":null,"mqtt_password":null,
            "mqtt_use_tls":false,"topic_root":"neiam"}"#;
        let c: MqttConfig = serde_json::from_str(stored).unwrap();
        assert_eq!(c.mqtt_host, "10.0.0.5");
        assert_eq!(c.topic_root, "neiam");
        assert!(!c.mqtt_use_tls);
    }

    /// The exact shape `nfc-522` uses after the extraction: two flattened
    /// fragments sharing one flat blob. Guards the whole scheme — if serde
    /// ever stopped splitting keys across both, stored configs would silently
    /// come back as defaults and every device would drop into provisioning.
    #[test]
    fn two_flattened_fragments_share_one_flat_blob() {
        use crate::config::WifiConfig;

        #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
        struct Combined {
            #[serde(flatten)]
            wifi: WifiConfig,
            #[serde(flatten)]
            mqtt: MqttConfig,
        }

        // Byte-for-byte a blob nfc-522 wrote before the extraction.
        let stored = r#"{"wifi_ssid":"net","wifi_pass":"pw","mqtt_host":"10.0.0.5",
            "mqtt_port":8883,"mqtt_username":"u","mqtt_password":"p",
            "mqtt_use_tls":true,"topic_root":"cbz"}"#;

        let c: Combined = serde_json::from_str(stored).unwrap();
        assert_eq!(c.wifi.wifi_ssid, "net");
        assert_eq!(c.wifi.wifi_pass, "pw");
        assert_eq!(c.mqtt.mqtt_host, "10.0.0.5");
        assert_eq!(c.mqtt.mqtt_port, 8883);
        assert!(c.mqtt.mqtt_use_tls);
        assert_eq!(c.mqtt.topic_root, "cbz");

        // Re-serializing stays flat (no nesting introduced), and round-trips.
        let out = serde_json::to_value(&c).unwrap();
        assert!(
            out.get("wifi_ssid").is_some(),
            "wifi keys must stay top-level"
        );
        assert!(
            out.get("mqtt_host").is_some(),
            "mqtt keys must stay top-level"
        );
        assert!(
            out.get("wifi").is_none(),
            "must not nest under a `wifi` key"
        );
        assert_eq!(serde_json::from_value::<Combined>(out).unwrap(), c);
    }

    /// Both fragments must also populate from one submitted form.
    #[test]
    fn two_fragments_parse_from_one_form() {
        use crate::config::WifiConfig;

        let form =
            Form::parse("wifi_ssid=net&wifi_pass=pw&mqtt_host=h&mqtt_port=1883&topic_root=cbz");
        let wifi = WifiConfig::from_form(&form);
        let mqtt = MqttConfig::from_form(&form);
        assert_eq!(wifi.wifi_ssid, "net");
        assert_eq!(mqtt.mqtt_host, "h");
        assert_eq!(mqtt.topic_root, "cbz");
    }

    #[test]
    fn defaults_apply_to_partial_blob() {
        let c: MqttConfig = serde_json::from_str(r#"{"mqtt_host":"h"}"#).unwrap();
        assert_eq!(c.mqtt_port, DEFAULT_PORT);
        assert_eq!(c.topic_root, DEFAULT_TOPIC_ROOT);
    }
}