158 lines
5.8 KiB
Rust
158 lines
5.8 KiB
Rust
//! MQTT topic router. ADR-122 §2.2.
|
|
//!
|
|
//! Pure-function module that maps a [`BfldEvent`] into a list of per-entity
|
|
//! MQTT topic + payload pairs. No broker dependency lives here — the actual
|
|
//! `publish` call is a thin wrapper around `Client::publish(topic, payload)`
|
|
//! once a broker integration lands (deferred to a follow-up iter).
|
|
//!
|
|
//! Topic shape (ADR-122 §2.2):
|
|
//!
|
|
//! ```text
|
|
//! ruview/<node_id>/bfld/presence/state # class >= 2
|
|
//! ruview/<node_id>/bfld/motion/state # class >= 2
|
|
//! ruview/<node_id>/bfld/person_count/state # class >= 2
|
|
//! ruview/<node_id>/bfld/zone_activity/state # class >= 2 (when zone_id set)
|
|
//! ruview/<node_id>/bfld/confidence/state # class >= 2
|
|
//! ruview/<node_id>/bfld/identity_risk/state # class == 2 only
|
|
//! ```
|
|
//!
|
|
//! `raw` (class-1) and `availability` topics are intentionally not yet emitted
|
|
//! by this router; they belong to the broker-connection lifecycle, not to the
|
|
//! per-event publish loop.
|
|
|
|
#![cfg(feature = "std")]
|
|
|
|
use crate::{BfldEvent, PrivacyClass};
|
|
|
|
/// Per-topic MQTT message ready to feed into `Client::publish(topic, payload)`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TopicMessage {
|
|
/// Full MQTT topic, e.g. `ruview/seed-01/bfld/presence/state`.
|
|
pub topic: String,
|
|
/// UTF-8 payload bytes — single JSON scalar (`true`, `0.72`, `"living_room"`)
|
|
/// or a compact JSON object for diagnostics.
|
|
pub payload: String,
|
|
}
|
|
|
|
impl TopicMessage {
|
|
/// Build a topic of the form `ruview/<node_id>/bfld/<suffix>/state`.
|
|
#[must_use]
|
|
pub fn ruview_topic(node_id: &str, entity: &str) -> String {
|
|
let mut s = String::with_capacity(7 + node_id.len() + 6 + entity.len() + 6);
|
|
s.push_str("ruview/");
|
|
s.push_str(node_id);
|
|
s.push_str("/bfld/");
|
|
s.push_str(entity);
|
|
s.push_str("/state");
|
|
s
|
|
}
|
|
}
|
|
|
|
/// Abstract MQTT publisher boundary. The crate ships only the trait + a
|
|
/// capture-impl for tests; the production rumqttc-backed impl lands in a
|
|
/// follow-up iter behind a `mqtt` feature gate.
|
|
///
|
|
/// `publish` is synchronous so callers can hold a `&mut self` without an
|
|
/// async runtime; the rumqttc wrapper drives a tokio task internally.
|
|
pub trait Publish {
|
|
/// Error type — typically the broker's transport error.
|
|
type Error;
|
|
/// Publish a single rendered message. Implementations may buffer.
|
|
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error>;
|
|
}
|
|
|
|
/// Capture-impl for unit tests. Stores every published message in order.
|
|
#[derive(Debug, Default)]
|
|
pub struct CapturePublisher {
|
|
/// Every `publish()` call appends to this vec.
|
|
pub published: Vec<TopicMessage>,
|
|
}
|
|
|
|
impl Publish for CapturePublisher {
|
|
type Error = core::convert::Infallible;
|
|
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
|
self.published.push(msg.clone());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Forward `Publish` through a shared `Arc<Mutex<P>>` so a publisher owned by
|
|
/// a worker thread can still be inspected by the test or operator after the
|
|
/// fact. Lock-poisoning is treated as a panic — there is no recovery story.
|
|
impl<P: Publish> Publish for std::sync::Arc<std::sync::Mutex<P>> {
|
|
type Error = P::Error;
|
|
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
|
self.lock()
|
|
.expect("BFLD publish: inner publisher Mutex poisoned")
|
|
.publish(msg)
|
|
}
|
|
}
|
|
|
|
/// Publish every topic message rendered from `event`. Returns the number of
|
|
/// messages actually published (zero for Raw / Derived class events). Errors
|
|
/// short-circuit — the publisher state at error time may have partial output.
|
|
pub fn publish_event<P: Publish>(
|
|
publisher: &mut P,
|
|
event: &BfldEvent,
|
|
) -> Result<usize, P::Error> {
|
|
let mut count = 0;
|
|
for msg in render_events(event) {
|
|
publisher.publish(&msg)?;
|
|
count += 1;
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
/// Render an event into the per-entity MQTT messages it should publish. Returns
|
|
/// an empty vec for events that fail the class gate (e.g., raw class 0).
|
|
#[must_use]
|
|
pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
|
|
let class_byte = event.privacy_class.as_u8();
|
|
if class_byte < PrivacyClass::Anonymous.as_u8() {
|
|
// Raw + Derived stay local — never published on the public topic tree.
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut out = Vec::with_capacity(6);
|
|
let node = &event.node_id;
|
|
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "presence"),
|
|
payload: if event.presence { "true".into() } else { "false".into() },
|
|
});
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "motion"),
|
|
payload: format!("{:.6}", event.motion),
|
|
});
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "person_count"),
|
|
payload: format!("{}", event.person_count),
|
|
});
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "confidence"),
|
|
payload: format!("{:.6}", event.confidence),
|
|
});
|
|
|
|
if let Some(zone) = &event.zone_id {
|
|
// Emit a JSON string so consumers can distinguish "no zone" (omitted)
|
|
// from "single-zone deployment" (always the same zone string).
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "zone_activity"),
|
|
payload: format!("\"{zone}\""),
|
|
});
|
|
}
|
|
|
|
// Identity risk is only published at exactly class 2 (Anonymous). Class 3
|
|
// (Restricted) computes the score internally but never emits it.
|
|
if class_byte == PrivacyClass::Anonymous.as_u8() {
|
|
if let Some(score) = event.identity_risk_score {
|
|
out.push(TopicMessage {
|
|
topic: TopicMessage::ruview_topic(node, "identity_risk"),
|
|
payload: format!("{score:.6}"),
|
|
});
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|