Files
ruvnet--RuView/v2/crates/wifi-densepose-bfld/src/lib.rs
T
ruv 74807a60c8 feat(adr-118/p5.9): RumqttPublisher::connect_with_lwt — broker auto-publishes "offline" (220/220 GREEN with mqtt)
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>
2026-05-24 18:08:59 -04:00

194 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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. P2P6 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,
},
}