mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41:42 +00:00
74807a60c8
Iter 29. Wires rumqttc::MqttOptions::set_last_will so the broker
auto-publishes "offline" on ruview/<node>/bfld/availability (retained,
QoS 1) when the publisher's TCP session drops without a clean
DISCONNECT. Closes the iter-28 lifecycle loop: explicit "online" on
connect + LWT-driven "offline" on session loss + explicit "offline"
on graceful shutdown.
Added (in src/rumqttc_publisher.rs, gated on `feature = "mqtt"`):
- RumqttPublisher::connect_with_lwt(node_id, opts, capacity) -> (Self, Connection)
Convenience wrapping with_lwt(opts, node_id) then Self::connect(opts, capacity).
- with_lwt(opts, node_id) -> MqttOptions free helper for operators who
build their own opts (custom TLS, credentials) and want to opt in to
the LWT without using the connect_with_lwt shortcut.
- rumqttc 0.24 LastWill::new(topic, message, qos, retain) — 4-arg form;
retain = true so HA sees "offline" on next start even if it was down
when the session dropped.
- pub use with_lwt, RumqttPublisher from lib.rs
tests/rumqttc_lwt.rs (8 named tests, all green, gated on mqtt):
with_lwt_returns_options_without_panic
connect_with_lwt_constructs_publisher_and_connection
connect_with_lwt_uses_documented_availability_topic
(constructive proof — both LWT and discovery use the same
availability_topic() function so they can't drift)
connect_with_lwt_publisher_still_publishes_state_topics
(LWT is purely additive — state topics work as before)
publisher_trait_object_constructible_with_lwt_path
with_lwt_is_idempotent_against_double_call
(rumqttc replaces the will silently — useful for wrapper libraries)
caller_built_options_can_opt_in_via_with_lwt_then_pass_to_connect
(operator pattern: build opts with TLS/creds, attach LWT, then connect)
placeholder_topicmessage_path_unaffected_by_lwt
Test bug caught:
- Initial test asserted 4 topics for Anonymous + no zone; actual is 5
(presence + motion + person_count + confidence + identity_risk).
rf_signature_hash is a BfldEvent JSON field, not its own MQTT topic.
Fixed the assertion; documented the distinction in the test comment.
ACs progressed:
- ADR-122 §2.2 availability surface now fully operational. Three paths:
1. Explicit publish_availability_online (iter 28) on connect
2. LWT auto-publishes "offline" if connection drops (this iter)
3. Explicit publish_availability_offline (iter 28) on graceful stop
HA reads the same topic in all three cases; entities grey out
device-wide via the iter-28 discovery `availability_topic` field.
Test config:
- cargo test --no-default-features → 72 passed
- cargo test → 203 passed
- cargo test --features mqtt → 220 passed (212 + 8 new)
Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker service. With iter
24+29 now both depending on a live broker for full coverage, the
CI lift is the next highest-value step.
- Three operator-ready HA blueprints (ADR-122 §2.6): presence-driven
lighting, motion-aware HVAC, identity-risk anomaly notification.
Co-Authored-By: claude-flow <ruv@ruv.net>
194 lines
6.6 KiB
Rust
194 lines
6.6 KiB
Rust
//! # BFLD — Beamforming Feedback Layer for Detection
|
||
//!
|
||
//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming
|
||
//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||
//!
|
||
//! ## Three structural invariants
|
||
//!
|
||
//! - **I1**: Raw BFI never exits the node.
|
||
//! - **I2**: Identity embedding is in-RAM-only.
|
||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
||
//!
|
||
//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow.
|
||
|
||
#![cfg_attr(not(feature = "std"), no_std)]
|
||
|
||
pub mod coherence_gate;
|
||
pub mod embedding;
|
||
pub mod embedding_ring;
|
||
#[cfg(feature = "std")]
|
||
pub mod emitter;
|
||
#[cfg(feature = "std")]
|
||
pub mod availability;
|
||
#[cfg(feature = "std")]
|
||
pub mod event;
|
||
pub mod frame;
|
||
#[cfg(feature = "std")]
|
||
pub mod ha_discovery;
|
||
#[cfg(feature = "std")]
|
||
pub mod mqtt_topics;
|
||
#[cfg(feature = "std")]
|
||
pub mod identity_features;
|
||
pub mod identity_risk;
|
||
#[cfg(feature = "std")]
|
||
pub mod payload;
|
||
#[cfg(feature = "std")]
|
||
pub mod pipeline;
|
||
#[cfg(feature = "std")]
|
||
pub mod pipeline_handle;
|
||
#[cfg(feature = "std")]
|
||
pub mod privacy_gate;
|
||
#[cfg(feature = "mqtt")]
|
||
pub mod rumqttc_publisher;
|
||
pub mod signature_hasher;
|
||
pub mod sink;
|
||
|
||
pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle};
|
||
#[cfg(feature = "std")]
|
||
pub use emitter::{BfldEmitter, SensingInputs};
|
||
#[cfg(feature = "std")]
|
||
pub use event::BfldEvent;
|
||
#[cfg(feature = "std")]
|
||
pub use availability::{
|
||
availability_topic, offline_message, online_message, publish_availability_offline,
|
||
publish_availability_online, PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE,
|
||
};
|
||
#[cfg(feature = "std")]
|
||
pub use ha_discovery::{publish_discovery, render_discovery_payloads};
|
||
#[cfg(feature = "std")]
|
||
pub use mqtt_topics::{publish_event, render_events, CapturePublisher, Publish, TopicMessage};
|
||
#[cfg(feature = "mqtt")]
|
||
pub use rumqttc_publisher::{with_lwt, RumqttPublisher};
|
||
pub use embedding::{IdentityEmbedding, EMBEDDING_DIM};
|
||
pub use embedding_ring::{EmbeddingRing, RING_CAPACITY};
|
||
#[cfg(feature = "std")]
|
||
pub use identity_features::{IdentityFeatures, RISK_FACTOR_BYTES};
|
||
pub use identity_risk::{score as identity_risk_score, GateAction};
|
||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||
#[cfg(feature = "std")]
|
||
pub use frame::BfldFrame;
|
||
#[cfg(feature = "std")]
|
||
pub use payload::BfldPayload;
|
||
#[cfg(feature = "std")]
|
||
pub use pipeline::{BfldConfig, BfldPipeline};
|
||
#[cfg(feature = "std")]
|
||
pub use pipeline_handle::{BfldPipelineHandle, PipelineInput};
|
||
#[cfg(feature = "std")]
|
||
pub use privacy_gate::PrivacyGate;
|
||
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||
|
||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||
#[repr(u8)]
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum PrivacyClass {
|
||
/// Local-only research data including raw BFI matrix. Never networked.
|
||
Raw = 0,
|
||
/// Operator-acknowledged research mode over LAN. Downsampled angles +
|
||
/// identity_embedding + identity_risk_score available. Required for
|
||
/// Soul Signature deployments (ADR-120 §2.7).
|
||
Derived = 1,
|
||
/// Production default: aggregate sensing only, no identity-derived fields.
|
||
Anonymous = 2,
|
||
/// Care-home / regulated deployments: class 2 minus risk score and hash.
|
||
Restricted = 3,
|
||
}
|
||
|
||
impl PrivacyClass {
|
||
/// Returns `true` if frames of this class may cross a `NetworkSink`.
|
||
/// Class 0 (`Raw`) is local-only by structural invariant I1.
|
||
#[must_use]
|
||
pub const fn allows_network(self) -> bool {
|
||
!matches!(self, Self::Raw)
|
||
}
|
||
|
||
/// Returns `true` if frames of this class may cross the Matter boundary.
|
||
/// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4.
|
||
#[must_use]
|
||
pub const fn allows_matter(self) -> bool {
|
||
matches!(self, Self::Anonymous | Self::Restricted)
|
||
}
|
||
|
||
/// Returns the byte value of this class (0..=3) for serialization.
|
||
#[must_use]
|
||
pub const fn as_u8(self) -> u8 {
|
||
self as u8
|
||
}
|
||
}
|
||
|
||
impl TryFrom<u8> for PrivacyClass {
|
||
type Error = BfldError;
|
||
|
||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||
match value {
|
||
0 => Ok(Self::Raw),
|
||
1 => Ok(Self::Derived),
|
||
2 => Ok(Self::Anonymous),
|
||
3 => Ok(Self::Restricted),
|
||
other => Err(BfldError::InvalidPrivacyClass(other)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Errors produced by BFLD operations.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum BfldError {
|
||
/// Header magic did not match `BFLD_MAGIC`.
|
||
#[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")]
|
||
InvalidMagic(u32),
|
||
|
||
/// Header version unsupported.
|
||
#[error("unsupported BFLD version: {0}")]
|
||
UnsupportedVersion(u16),
|
||
|
||
/// Payload CRC32 mismatch — frame corrupted or tampered.
|
||
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
|
||
Crc {
|
||
/// CRC value the header declared.
|
||
expected: u32,
|
||
/// CRC value computed over the received payload.
|
||
actual: u32,
|
||
},
|
||
|
||
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
|
||
/// Enforces structural invariant I1.
|
||
#[error("privacy violation: {reason}")]
|
||
PrivacyViolation {
|
||
/// `Sink::KIND` of the sink that rejected the frame.
|
||
reason: &'static str,
|
||
},
|
||
|
||
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||
#[error("invalid PrivacyClass byte: {0}")]
|
||
InvalidPrivacyClass(u8),
|
||
|
||
/// Buffer too short for header (86 bytes) or header + declared payload.
|
||
#[error("truncated frame: got {got} bytes, need at least {need}")]
|
||
TruncatedFrame {
|
||
/// Bytes available in the input buffer.
|
||
got: usize,
|
||
/// Bytes the header indicates are required.
|
||
need: usize,
|
||
},
|
||
|
||
/// Payload section length-prefix decoding failed or trailing bytes left over.
|
||
#[error("malformed payload section at offset {offset}: {reason}")]
|
||
MalformedSection {
|
||
/// Byte offset within the payload where parsing failed.
|
||
offset: usize,
|
||
/// Human-readable reason for the failure.
|
||
reason: &'static str,
|
||
},
|
||
|
||
/// Attempted to demote a frame to a class with MORE information than the
|
||
/// current class (lower numerical value). `demote` is monotonic; the only
|
||
/// way to add information back is to receive a fresh frame.
|
||
#[error("invalid demote: cannot move from class {from} to class {to}")]
|
||
InvalidDemote {
|
||
/// Source class byte value.
|
||
from: u8,
|
||
/// Refused target class byte value.
|
||
to: u8,
|
||
},
|
||
}
|