//! 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);
}
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
9.1 KiB · text
History
65cab78