diff --git a/README.md b/README.md index 50341ca2..691e30a0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie > | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($6–10) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. | > | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO | > | **Qualcomm CSI beta** ([ADR-268](docs/adr/ADR-268-qualcomm-atheros-csi-platform.md)) | QCA9300 now; QCN9074/QCN9274 experimental | ~$30-200 | Simulator now; hardware adapter gated | Rust `QCS1` codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export | +> | **Vendor provider beta** ([ADR-270](docs/adr/ADR-270-vendor-rf-sensing-integration-program.md)) | Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, Wifigarden | Varies | Capability-dependent | Bounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI | > | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) | > > No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py` diff --git a/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md b/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md index e68d3b16..311ca308 100644 --- a/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md +++ b/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md @@ -82,6 +82,30 @@ feature-gated FFI or sidecar boundary and are never redistributed without rights - A no-go or telemetry-only ADR is a completed research outcome, not a failed port. - Vendor status and APIs must be rechecked before each implementation begins. +## Implementation Status + +The ADR-270 provider contract is implemented in Rust. Each portfolio entry has +a descriptor, bounded decoder or explicit fail-closed access state, deterministic +contract fixtures where lawful, registry coverage, and API exposure: + +- Origin AI: contract-configured derived-sensing decoder and request plan. +- Plume/OpenSync: read-only OVSDB request plan and RF telemetry decoder. +- Mist/Juniper: regional request configuration, paginated RF/location decoder. +- NETGEAR Insight: regional partner request configuration and telemetry decoder. +- Electric Imp and RF Solutions: bounded scalar telemetry bridges. +- Luma: explicitly experimental generic OpenWrt telemetry bridge. +- Google Nest: network-only contract events; never represented as CSI. +- Linksys: `Unsupported` decoder because Linksys Aware is end-of-support. +- Wifigarden: `ContractRequired` decoder pending a disclosed SDK/schema. + +`vendor-rf-sim` generates deterministic, provenance-labelled events for the +eight providers with a defined event contract and refuses to fabricate Linksys +or Wifigarden events. The sensing server exposes provider descriptors and latest +events under `/api/v1/rf/vendors` and accepts validated canonical simulator +events over its existing UDP port. Physical/vendor-cloud validation remains +separate from implementation completeness and is reflected by +`hardware_validated: false` until performed. + ## Evidence and Links - [ADR-268: Qualcomm strategy](ADR-268-qualcomm-atheros-csi-platform.md) diff --git a/docs/releases/v0.9.3-vendor-providers-beta.1.md b/docs/releases/v0.9.3-vendor-providers-beta.1.md new file mode 100644 index 00000000..c6c1001d --- /dev/null +++ b/docs/releases/v0.9.3-vendor-providers-beta.1.md @@ -0,0 +1,21 @@ +# RuView v0.9.3 Vendor Providers Beta 1 + +This beta implements ADR-270 as a capability-safe Rust provider program across +all ten researched vendors. + +## Included + +- Shared `VendorRfProvider` contract with bounded event validation. +- Origin AI, Plume/OpenSync, Mist/Juniper, NETGEAR Insight, Electric Imp, + RF Solutions, Luma/OpenWrt and Google Nest contract adapters. +- Explicit fail-closed Linksys (`Unsupported`) and Wifigarden + (`ContractRequired`) providers. +- Deterministic `vendor-rf-sim` JSONL/UDP fixtures for defined contracts. +- Provider registry, descriptors, latest-event REST endpoints and WebSocket + summaries through the sensing server. + +## Boundary + +This release implements and validates software contracts. It does not claim +vendor-cloud credentials, commercial SDK rights, physical hardware validation, +or complex CSI support for telemetry-only providers. diff --git a/docs/vendor-rf-providers.md b/docs/vendor-rf-providers.md new file mode 100644 index 00000000..e7d07a7d --- /dev/null +++ b/docs/vendor-rf-providers.md @@ -0,0 +1,60 @@ +# ADR-270 Vendor RF Providers + +RuView exposes a capability-safe Rust provider layer for vendor sensing and RF +telemetry. It never converts RSSI, occupancy, location or network inventory into +complex CSI. + +## API + +- `GET /api/v1/rf/vendors` — all provider descriptors and access states. +- `GET /api/v1/rf/vendors/latest` — latest validated event per vendor. +- `GET /api/v1/rf/vendors/:vendor/latest` — latest event for one stable vendor ID. +- `POST /api/v1/rf/vendors/:vendor/events` — ingest the vendor's documented + sidecar/webhook payload through its strict provider decoder. This `/api/v1/*` + route uses the server's bearer-token policy when configured. + +Stable IDs are `origin_ai`, `plume`, `mist`, `netgear`, `electric_imp`, +`rf_solutions`, `linksys`, `luma`, `google_nest`, and `wifigarden`. + +## Deterministic simulator + +```bash +cd v2 +cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \ + --vendor plume --frames 100 --output plume.jsonl + +# Stream canonical synthetic events to the sensing server UDP port. +cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \ + --vendor mist --frames 100 --udp 127.0.0.1:5005 --realtime +``` + +Supported simulator names are `origin-ai`, `plume`, `mist`, `netgear`, +`electric-imp`, `rf-solutions`, `luma`, and `google-nest`. Linksys is refused +because its sensing service is discontinued. Wifigarden is refused until a +contracted event schema exists. + +Every synthetic event includes `synthetic: true`, a deterministic sequence and +timestamp, and a source ending in `-sim-01`. + +Canonical UDP JSON is accepted only when `synthetic: true`. Live vendor payloads +must use the HTTP ingestion route so provider-specific schemas, metric allowlists, +access states and bounds cannot be bypassed. + +## Live/provider payloads + +Provider decoders are strict, bounded and reject unknown schema fields. Origin +paths and credentials are supplied by the commercial contract. Plume uses a +read-only allow-listed OVSDB request plan. Mist and NETGEAR configurations use +regional HTTPS endpoints with redacted tokens. Electric Imp, RF Solutions and +Luma accept only allow-listed scalar metrics. Google Nest remains network-only. + +Credentials are never embedded in fixtures or descriptors. Linksys returns +`Unsupported`; Wifigarden returns `ContractRequired`. These are usable, +test-covered provider outcomes—not simulated integrations. + +## Hardware honesty + +All descriptors remain `hardware_validated: false` until exact hardware/cloud +versions, lawful access, repeatable captures, calibration where applicable, and +fixture publication rights have been verified. Passing the simulator and API +tests validates RuView software only. diff --git a/v2/crates/wifi-densepose-hardware/src/bin/vendor-rf-sim.rs b/v2/crates/wifi-densepose-hardware/src/bin/vendor-rf-sim.rs new file mode 100644 index 00000000..848f770c --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/bin/vendor-rf-sim.rs @@ -0,0 +1,232 @@ +//! Deterministic ADR-270 vendor event simulator. + +use clap::{Parser, ValueEnum}; +use std::{ + collections::BTreeMap, + fs::File, + io::{self, Write}, + net::{SocketAddr, UdpSocket}, + path::PathBuf, + thread, + time::Duration, +}; +use wifi_densepose_hardware::vendor_rf::{ + ProviderAvailability, ProviderDescriptor, RfCapability, VendorId, VendorRfEvent, +}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum Vendor { + OriginAi, + Plume, + Mist, + Netgear, + ElectricImp, + RfSolutions, + Luma, + GoogleNest, + Linksys, + Wifigarden, +} + +impl Vendor { + fn id(self) -> VendorId { + match self { + Self::OriginAi => VendorId::OriginAi, + Self::Plume => VendorId::Plume, + Self::Mist => VendorId::Mist, + Self::Netgear => VendorId::Netgear, + Self::ElectricImp => VendorId::ElectricImp, + Self::RfSolutions => VendorId::RfSolutions, + Self::Luma => VendorId::Luma, + Self::GoogleNest => VendorId::GoogleNest, + Self::Linksys => VendorId::Linksys, + Self::Wifigarden => VendorId::Wifigarden, + } + } + fn descriptor(self) -> ProviderDescriptor { + let (capabilities, availability, reason) = match self { + Self::OriginAi => ( + vec![RfCapability::DerivedSensing], + ProviderAvailability::ContractRequired, + "Origin partner API/SDK contract required", + ), + Self::Plume => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::CredentialsRequired, + "OpenSync telemetry; Plume Sense is separately gated", + ), + Self::Mist => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::CredentialsRequired, + "Mist REST/webhook telemetry", + ), + Self::Netgear => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::CredentialsRequired, + "Insight partner API telemetry", + ), + Self::ElectricImp => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::CredentialsRequired, + "impCentral scalar telemetry only", + ), + Self::RfSolutions => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::CredentialsRequired, + "RIoT environmental telemetry only", + ), + Self::Luma => ( + vec![RfCapability::RfTelemetry], + ProviderAvailability::Experimental, + "discontinued OpenWrt salvage fixture", + ), + Self::GoogleNest => ( + vec![RfCapability::NetworkOnly], + ProviderAvailability::Experimental, + "network infrastructure contract fixture only", + ), + Self::Linksys => ( + vec![RfCapability::Unsupported], + ProviderAvailability::Unsupported, + "Linksys Aware reached end of support", + ), + Self::Wifigarden => ( + vec![RfCapability::Unsupported], + ProviderAvailability::ContractRequired, + "technical SDK disclosure required", + ), + }; + ProviderDescriptor { + vendor: self.id(), + capabilities, + availability, + hardware_validated: false, + reason: reason.into(), + } + } +} + +#[derive(Debug, Parser)] +#[command( + name = "vendor-rf-sim", + about = "Emit deterministic ADR-270 vendor RF events" +)] +struct Args { + #[arg(long, value_enum)] + vendor: Vendor, + #[arg(long, default_value_t = 100)] + frames: u64, + #[arg(long, default_value_t = 0x5255_5645_4e44_4f52)] + seed: u64, + #[arg(long, default_value_t = 100)] + interval_ms: u64, + #[arg(long)] + udp: Option, + #[arg(long)] + output: Option, + #[arg(long)] + realtime: bool, +} + +fn next_random(state: &mut u64) -> f64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + (*state >> 11) as f64 / ((1u64 << 53) as f64) +} + +fn event(vendor: Vendor, sequence: u64, timestamp_us: u64, state: &mut u64) -> VendorRfEvent { + let capability = vendor.descriptor().capabilities[0]; + let wave = (sequence as f64 * 0.17).sin(); + let noise = next_random(state) - 0.5; + let metrics = match capability { + RfCapability::DerivedSensing => BTreeMap::from([ + ( + "motion_score".into(), + (0.5 + 0.35 * wave + 0.05 * noise).clamp(0.0, 1.0), + ), + ("occupancy_count".into(), if wave > 0.0 { 2.0 } else { 1.0 }), + ("confidence".into(), 0.92), + ]), + RfCapability::RfTelemetry => BTreeMap::from([ + ("rssi_dbm".into(), -52.0 + 5.0 * wave + noise), + ("client_count".into(), if wave > 0.0 { 4.0 } else { 3.0 }), + ( + "channel_utilization".into(), + (0.31 + 0.08 * wave).clamp(0.0, 1.0), + ), + ]), + RfCapability::NetworkOnly => { + BTreeMap::from([("reachable".into(), 1.0), ("device_count".into(), 5.0)]) + } + _ => BTreeMap::new(), + }; + VendorRfEvent { + vendor: vendor.id(), + capability, + sequence, + timestamp_us, + source_id: format!("{}-sim-01", vendor.id().as_str()), + synthetic: true, + metrics, + label: Some("deterministic_contract_fixture".into()), + } +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + if args.udp.is_none() && args.output.is_none() { + return Err("select at least one sink with --udp or --output".into()); + } + let descriptor = args.vendor.descriptor(); + descriptor.validate()?; + if matches!( + descriptor.availability, + ProviderAvailability::Unsupported | ProviderAvailability::ContractRequired + ) && descriptor.capabilities.contains(&RfCapability::Unsupported) + { + return Err(format!( + "{} has no simulatable event contract: {}", + descriptor.vendor.as_str(), + descriptor.reason + ) + .into()); + } + let socket = args.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?; + let mut output = args.output.as_ref().map(File::create).transpose()?; + let mut rng = args.seed; + let mut bytes = 0usize; + for sequence in 0..args.frames { + let value = event( + args.vendor, + sequence, + sequence * args.interval_ms * 1_000, + &mut rng, + ); + value.validate(&descriptor)?; + let wire = serde_json::to_vec(&value)?; + if let (Some(socket), Some(destination)) = (&socket, args.udp) { + if socket.send_to(&wire, destination)? != wire.len() { + return Err( + io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into(), + ); + } + } + if let Some(file) = &mut output { + file.write_all(&wire)?; + file.write_all(b"\n")?; + } + bytes += wire.len(); + if args.realtime { + thread::sleep(Duration::from_millis(args.interval_ms)); + } + } + eprintln!( + "emitted {} synthetic {} events ({} bytes, seed={:#x})", + args.frames, + args.vendor.id().as_str(), + bytes, + args.seed + ); + Ok(()) +} diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index 338b311e..bca23585 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -47,6 +47,8 @@ mod esp32_parser; // standardized report path until an OTA binding exists. pub mod ieee80211bf; pub mod sync_packet; +/// ADR-270 capability-safe vendor RF provider contract. +pub mod vendor_rf; // ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and // mesh sensing plane (L3). Lets host tests, simulators, and future @@ -100,3 +102,7 @@ pub use rtl8720f::{ pub use sync_packet::{ SyncPacket, SyncPacketFlags, SYNC_PACKET_MAGIC, SYNC_PACKET_PROTO_VER, SYNC_PACKET_SIZE, }; +pub use vendor_rf::{ + ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId, + VendorRfEvent, VendorRfProvider, +}; diff --git a/v2/crates/wifi-densepose-hardware/src/vendor_rf.rs b/v2/crates/wifi-densepose-hardware/src/vendor_rf.rs new file mode 100644 index 00000000..b6f752e9 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/vendor_rf.rs @@ -0,0 +1,207 @@ +//! ADR-270 vendor RF provider contract. +//! +//! This model prevents RSSI, cloud occupancy, or network inventory from being +//! represented as complex CSI. Vendor adapters may emit only declared capabilities. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use thiserror::Error; + +pub const MAX_VENDOR_METRICS: usize = 64; +pub const MAX_VENDOR_KEY_LEN: usize = 64; +pub const MAX_VENDOR_TEXT_LEN: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VendorId { + OriginAi, + Plume, + Mist, + Netgear, + ElectricImp, + RfSolutions, + Linksys, + Luma, + GoogleNest, + Wifigarden, +} + +impl VendorId { + pub const ALL: [Self; 10] = [ + Self::OriginAi, + Self::Plume, + Self::Mist, + Self::Netgear, + Self::ElectricImp, + Self::RfSolutions, + Self::Linksys, + Self::Luma, + Self::GoogleNest, + Self::Wifigarden, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::OriginAi => "origin_ai", + Self::Plume => "plume", + Self::Mist => "mist", + Self::Netgear => "netgear", + Self::ElectricImp => "electric_imp", + Self::RfSolutions => "rf_solutions", + Self::Linksys => "linksys", + Self::Luma => "luma", + Self::GoogleNest => "google_nest", + Self::Wifigarden => "wifigarden", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RfCapability { + ComplexCsi, + DerivedSensing, + RfTelemetry, + NetworkOnly, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderAvailability { + Available, + CredentialsRequired, + ContractRequired, + Experimental, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderDescriptor { + pub vendor: VendorId, + pub capabilities: Vec, + pub availability: ProviderAvailability, + pub hardware_validated: bool, + pub reason: String, +} + +impl ProviderDescriptor { + pub fn validate(&self) -> Result<(), VendorEventError> { + if self.capabilities.is_empty() + || self.reason.is_empty() + || self.reason.len() > MAX_VENDOR_TEXT_LEN + { + return Err(VendorEventError::InvalidDescriptor); + } + if self.hardware_validated && self.availability != ProviderAvailability::Available { + return Err(VendorEventError::InvalidDescriptor); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VendorRfEvent { + pub vendor: VendorId, + pub capability: RfCapability, + pub sequence: u64, + pub timestamp_us: u64, + pub source_id: String, + pub synthetic: bool, + pub metrics: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl VendorRfEvent { + pub fn validate(&self, descriptor: &ProviderDescriptor) -> Result<(), VendorEventError> { + descriptor.validate()?; + if self.vendor != descriptor.vendor || !descriptor.capabilities.contains(&self.capability) { + return Err(VendorEventError::CapabilityMismatch); + } + if matches!( + self.capability, + RfCapability::ComplexCsi | RfCapability::Unsupported + ) { + return Err(VendorEventError::InvalidEventCapability); + } + if self.source_id.is_empty() + || self.source_id.len() > MAX_VENDOR_TEXT_LEN + || self.metrics.is_empty() + || self.metrics.len() > MAX_VENDOR_METRICS + || self + .metrics + .iter() + .any(|(k, v)| k.is_empty() || k.len() > MAX_VENDOR_KEY_LEN || !v.is_finite()) + || self + .label + .as_ref() + .is_some_and(|v| v.len() > MAX_VENDOR_TEXT_LEN) + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) + } +} + +pub trait VendorRfProvider: Send + Sync { + fn descriptor(&self) -> ProviderDescriptor; + fn decode(&self, payload: &[u8]) -> Result, VendorEventError>; +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum VendorEventError { + #[error("invalid provider descriptor")] + InvalidDescriptor, + #[error("event capability does not match provider")] + CapabilityMismatch, + #[error("complex CSI and unsupported states cannot be represented as scalar vendor events")] + InvalidEventCapability, + #[error("invalid or unbounded vendor payload")] + InvalidPayload, + #[error("malformed provider payload: {0}")] + MalformedPayload(String), + #[error("provider credentials are required")] + CredentialsRequired, + #[error("commercial contract or SDK access is required")] + ContractRequired, + #[error("provider has no supported sensing interface")] + Unsupported, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_vendor_has_a_stable_identifier() { + let names: std::collections::BTreeSet<_> = + VendorId::ALL.iter().map(|v| v.as_str()).collect(); + assert_eq!(names.len(), VendorId::ALL.len()); + } + + #[test] + fn scalar_contract_rejects_csi_masquerading() { + let descriptor = ProviderDescriptor { + vendor: VendorId::Plume, + capabilities: vec![RfCapability::RfTelemetry], + availability: ProviderAvailability::CredentialsRequired, + hardware_validated: false, + reason: "OpenSync telemetry".into(), + }; + let event = VendorRfEvent { + vendor: VendorId::Plume, + capability: RfCapability::ComplexCsi, + sequence: 1, + timestamp_us: 1, + source_id: "pod-1".into(), + synthetic: true, + metrics: BTreeMap::from([("rssi_dbm".into(), -42.0)]), + label: None, + }; + assert_eq!( + event.validate(&descriptor), + Err(VendorEventError::CapabilityMismatch) + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 65d509f7..374f3802 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -34,3 +34,11 @@ pub mod sparse_inference; #[allow(dead_code)] pub mod trainer; pub mod vital_signs; +/// ADR-270 Mist and NETGEAR telemetry providers. +pub mod vendor_mist_netgear; +/// ADR-270 Origin AI and Plume/OpenSync providers. +pub mod vendor_origin_plume; +/// ADR-270 scalar, network-only, and fail-closed vendor providers. +pub mod vendor_remaining; +/// ADR-270 provider registry and canonical event helpers. +pub mod vendor_rf; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 78f3e98e..8acf6c79 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -33,13 +33,14 @@ use wifi_densepose_sensing_server::{ }; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use axum::{ + body::Bytes, extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, Path, Query, State, @@ -1043,6 +1044,8 @@ struct AppStateInner { latest_qualcomm_csi: Option, /// Instant of the last validated Qualcomm CSI UDP frame. last_qualcomm_frame: Option, + /// Latest bounded ADR-270 event per vendor. Complex CSI uses dedicated transports. + latest_vendor_rf: BTreeMap, tx: broadcast::Sender, // ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state + // a parallel broadcast topic (`/ws/introspection`) running alongside @@ -3411,6 +3414,89 @@ async fn latest_qualcomm_csi(State(state): State) -> Json Json { + Json( + serde_json::to_value(wifi_densepose_sensing_server::vendor_rf::descriptors()) + .unwrap_or_default(), + ) +} + +async fn latest_vendor_events(State(state): State) -> Json { + let state = state.read().await; + Json(serde_json::to_value(&state.latest_vendor_rf).unwrap_or_default()) +} + +async fn latest_vendor_event( + State(state): State, + Path(vendor): Path, +) -> impl IntoResponse { + let Some(vendor_id) = wifi_densepose_sensing_server::vendor_rf::vendor_from_str(&vendor) else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "unknown vendor"}))); + }; + let state = state.read().await; + let canonical_vendor = vendor_id.as_str(); + match state.latest_vendor_rf.get(canonical_vendor) { + Some(snapshot) => ( + StatusCode::OK, + Json(serde_json::to_value(snapshot).unwrap_or_default()), + ), + None => ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "no vendor RF data yet", + "vendor": canonical_vendor + })), + ), + } +} + +async fn ingest_vendor_events( + State(state): State, + Path(vendor): Path, + payload: Bytes, +) -> impl IntoResponse { + let Some(vendor_id) = wifi_densepose_sensing_server::vendor_rf::vendor_from_str(&vendor) else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "unknown vendor"}))); + }; + let events = match wifi_densepose_sensing_server::vendor_rf::decode_provider(vendor_id, &payload) { + Ok(events) => events, + Err(error) => { + let status = match error { + wifi_densepose_hardware::vendor_rf::VendorEventError::Unsupported => StatusCode::NOT_IMPLEMENTED, + wifi_densepose_hardware::vendor_rf::VendorEventError::ContractRequired + | wifi_densepose_hardware::vendor_rf::VendorEventError::CredentialsRequired => StatusCode::FORBIDDEN, + _ => StatusCode::BAD_REQUEST, + }; + return (status, Json(serde_json::json!({"error": error.to_string(), "vendor": vendor}))); + } + }; + let mut accepted = 0usize; + let mut state = state.write().await; + let canonical_vendor = vendor_id.as_str().to_string(); + for event in events { + match wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot::from_event(event) { + Ok(snapshot) => { + let json = serde_json::to_string(&snapshot).ok(); + state.source = snapshot.source.clone(); + state + .latest_vendor_rf + .insert(canonical_vendor.clone(), snapshot); + if let Some(json) = json { + let _ = state.tx.send(json); + } + accepted += 1; + } + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error.to_string(), "vendor": canonical_vendor})), + ) + } + } + } + (StatusCode::ACCEPTED, Json(serde_json::json!({"vendor": vendor, "accepted": accepted}))) +} + /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure @@ -5518,6 +5604,24 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { + if len > 0 && buf[0] == b'{' { + match serde_json::from_slice::(&buf[..len]) + .map_err(|error| error.to_string()) + .and_then(|event| wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot::from_event(event).map_err(|error| error.to_string())) + { + Ok(snapshot) if snapshot.event.synthetic => { + debug!("Vendor RF event from {src}: vendor={} capability={:?} seq={}", snapshot.event.vendor.as_str(), snapshot.event.capability, snapshot.event.sequence); + let json = serde_json::to_string(&snapshot).ok(); + let mut state = state.write().await; + state.source = snapshot.source.clone(); + state.latest_vendor_rf.insert(snapshot.event.vendor.as_str().to_string(), snapshot); + if let Some(json) = json { let _ = state.tx.send(json); } + } + Ok(_) => warn!("Rejected non-synthetic canonical vendor event from {src}; live payloads must use the provider decoder HTTP route"), + Err(error) => warn!("Rejected ADR-270 vendor event from {src}: {error}"), + } + continue; + } if len >= 4 && u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice")) == wifi_densepose_hardware::qualcomm_csi::QUALCOMM_CSI_MAGIC @@ -7680,6 +7784,7 @@ async fn main() { last_mediatek_frame: None, latest_qualcomm_csi: None, last_qualcomm_frame: None, + latest_vendor_rf: BTreeMap::new(), tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, @@ -7899,6 +8004,10 @@ async fn main() { .route("/api/v1/radar/latest", get(latest_realtek_radar)) .route("/api/v1/csi/mediatek/latest", get(latest_mediatek_csi)) .route("/api/v1/csi/qualcomm/latest", get(latest_qualcomm_csi)) + .route("/api/v1/rf/vendors", get(vendor_descriptors)) + .route("/api/v1/rf/vendors/latest", get(latest_vendor_events)) + .route("/api/v1/rf/vendors/:vendor/latest", get(latest_vendor_event)) + .route("/api/v1/rf/vendors/:vendor/events", post(ingest_vendor_events)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) // ADR-110 iter 29 — per-node mesh sync state for HTTP clients. diff --git a/v2/crates/wifi-densepose-sensing-server/src/vendor_mist_netgear.rs b/v2/crates/wifi-densepose-sensing-server/src/vendor_mist_netgear.rs new file mode 100644 index 00000000..835dbc12 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/vendor_mist_netgear.rs @@ -0,0 +1,712 @@ +//! ADR-270 adapters for Mist/Juniper and NETGEAR Insight. +//! +//! These cloud APIs expose client RF telemetry and location/network context. +//! They do not expose complex channel state information (CSI), and this module +//! deliberately cannot construct a `ComplexCsi` event. + +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::fmt; +use wifi_densepose_hardware::vendor_rf::{ + ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId, + VendorRfEvent, VendorRfProvider, +}; + +pub const MAX_VENDOR_PAYLOAD_BYTES: usize = 1024 * 1024; +pub const MAX_EVENTS_PER_PAGE: usize = 1_000; +pub const MAX_CURSOR_BYTES: usize = 512; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MistRegion { + Global, + Europe, + AsiaPacific, + Australia, +} + +impl MistRegion { + pub const fn base_url(self) -> &'static str { + match self { + Self::Global => "https://api.mist.com", + Self::Europe => "https://api.eu.mist.com", + Self::AsiaPacific => "https://api.ac2.mist.com", + Self::Australia => "https://api.gc1.mist.com", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetgearRegion { + NorthAmerica, + Europe, + Australia, +} + +impl NetgearRegion { + pub const fn base_url(self) -> &'static str { + match self { + Self::NorthAmerica => "https://insight.netgear.com", + Self::Europe => "https://eu.insight.netgear.com", + Self::Australia => "https://au.insight.netgear.com", + } + } +} + +/// An authentication value whose `Debug` output is always redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretToken(String); + +impl SecretToken { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > 4_096 || value.chars().any(char::is_control) { + return Err(VendorEventError::InvalidPayload); + } + Ok(Self(value)) + } + + /// Intended only for constructing an HTTP authorization header. + pub fn expose_for_header(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretToken([REDACTED])") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct VendorRequestConfig { + pub base_url: &'static str, + pub path: String, + pub cursor: Option, + pub token: SecretToken, +} + +impl VendorRequestConfig { + pub fn validate(&self) -> Result<(), VendorEventError> { + if !self.base_url.starts_with("https://") + || !self.path.starts_with('/') + || self.path.contains("..") + || self.path.chars().any(char::is_control) + || self.cursor.as_deref().is_some_and(|cursor| { + cursor.is_empty() + || cursor.len() > MAX_CURSOR_BYTES + || cursor + .chars() + .any(|c| c.is_control() || c == '&' || c == '?' || c == '#') + }) + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) + } +} + +impl fmt::Debug for VendorRequestConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VendorRequestConfig") + .field("base_url", &self.base_url) + .field("path", &self.path) + .field("cursor", &self.cursor.as_ref().map(|_| "[PRESENT]")) + .field("token", &"[REDACTED]") + .finish() + } +} + +pub fn mist_request( + region: MistRegion, + site_id: &str, + cursor: Option, + token: SecretToken, +) -> Result { + validate_identifier(site_id)?; + let request = VendorRequestConfig { + base_url: region.base_url(), + path: format!("/api/v1/sites/{site_id}/stats/clients"), + cursor, + token, + }; + request.validate()?; + Ok(request) +} + +pub fn netgear_request( + region: NetgearRegion, + location_id: &str, + cursor: Option, + token: SecretToken, +) -> Result { + validate_identifier(location_id)?; + let request = VendorRequestConfig { + base_url: region.base_url(), + path: format!("/api/v1/locations/{location_id}/clients"), + cursor, + token, + }; + request.validate()?; + Ok(request) +} + +fn validate_identifier(value: &str) -> Result<(), VendorEventError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DecodedVendorPage { + pub events: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct MistProvider; + +impl MistProvider { + pub fn decode_page(&self, payload: &[u8]) -> Result { + decode_page( + payload, + VendorId::Mist, + mist_descriptor(), + parse_mist_record, + ) + } +} + +impl VendorRfProvider for MistProvider { + fn descriptor(&self) -> ProviderDescriptor { + mist_descriptor() + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + Ok(self.decode_page(payload)?.events) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct NetgearInsightProvider; + +impl NetgearInsightProvider { + pub fn decode_page(&self, payload: &[u8]) -> Result { + decode_page( + payload, + VendorId::Netgear, + netgear_descriptor(), + parse_netgear_record, + ) + } +} + +impl VendorRfProvider for NetgearInsightProvider { + fn descriptor(&self) -> ProviderDescriptor { + netgear_descriptor() + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + Ok(self.decode_page(payload)?.events) + } +} + +pub fn mist_descriptor() -> ProviderDescriptor { + ProviderDescriptor { + vendor: VendorId::Mist, + capabilities: vec![RfCapability::RfTelemetry, RfCapability::NetworkOnly], + availability: ProviderAvailability::CredentialsRequired, + hardware_validated: false, + reason: "Mist cloud client RF telemetry and location context; never CSI".into(), + } +} + +pub fn netgear_descriptor() -> ProviderDescriptor { + ProviderDescriptor { + vendor: VendorId::Netgear, + capabilities: vec![RfCapability::RfTelemetry, RfCapability::NetworkOnly], + availability: ProviderAvailability::CredentialsRequired, + hardware_validated: false, + reason: "NETGEAR Insight client RF and network telemetry; never CSI".into(), + } +} + +type RecordParser = fn(&Map, usize) -> Result; + +fn decode_page( + payload: &[u8], + vendor: VendorId, + descriptor: ProviderDescriptor, + parser: RecordParser, +) -> Result { + if payload.is_empty() || payload.len() > MAX_VENDOR_PAYLOAD_BYTES { + return Err(VendorEventError::InvalidPayload); + } + let root: Value = serde_json::from_slice(payload) + .map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?; + let (records, next_cursor) = extract_records_and_cursor(&root)?; + if records.is_empty() || records.len() > MAX_EVENTS_PER_PAGE { + return Err(VendorEventError::InvalidPayload); + } + + let mut events = Vec::with_capacity(records.len()); + for (index, value) in records.iter().enumerate() { + let object = value.as_object().ok_or(VendorEventError::InvalidPayload)?; + let event = parser(object, index)?; + if event.vendor != vendor || event.capability != RfCapability::RfTelemetry { + return Err(VendorEventError::CapabilityMismatch); + } + event.validate(&descriptor)?; + events.push(event); + } + Ok(DecodedVendorPage { + events, + next_cursor, + }) +} + +fn extract_records_and_cursor( + root: &Value, +) -> Result<(&[Value], Option), VendorEventError> { + if let Some(records) = root.as_array() { + return Ok((records, None)); + } + let object = root.as_object().ok_or(VendorEventError::InvalidPayload)?; + let records = ["results", "data", "clients", "events", "items"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_array)) + .ok_or(VendorEventError::InvalidPayload)?; + let cursor_value = object + .get("next_cursor") + .or_else(|| object.get("nextPageToken")) + .or_else(|| object.get("next_page_token")) + .or_else(|| { + object + .get("pagination") + .and_then(Value::as_object) + .and_then(|p| p.get("next").or_else(|| p.get("cursor"))) + }); + let next_cursor = match cursor_value { + None | Some(Value::Null) => None, + Some(Value::String(value)) if valid_cursor(value) => Some(value.clone()), + Some(_) => return Err(VendorEventError::InvalidPayload), + }; + Ok((records, next_cursor)) +} + +fn valid_cursor(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CURSOR_BYTES + && !value + .chars() + .any(|c| c.is_control() || c == '&' || c == '?' || c == '#') +} + +#[derive(Debug, Deserialize)] +struct MistRecord { + #[serde(alias = "client_id", alias = "mac")] + id: String, + #[serde(default, alias = "last_seen", alias = "lastSeen")] + timestamp: Option, + #[serde(default, alias = "rssi_dbm")] + rssi: Option, + #[serde(default)] + snr: Option, + #[serde(default)] + channel: Option, + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + site_id: Option, + #[serde(default)] + ap_id: Option, + #[serde(default, alias = "event_type", alias = "type")] + event: Option, +} + +fn parse_mist_record( + object: &Map, + index: usize, +) -> Result { + let record: MistRecord = serde_json::from_value(Value::Object(object.clone())) + .map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?; + validate_source(&record.id)?; + validate_optional_text(record.site_id.as_deref())?; + validate_optional_text(record.ap_id.as_deref())?; + validate_optional_text(record.event.as_deref())?; + let timestamp_us = parse_timestamp(record.timestamp.as_ref())?; + let mut metrics = BTreeMap::new(); + push_metric(&mut metrics, "rssi_dbm", record.rssi, -150.0, 20.0)?; + push_metric(&mut metrics, "snr_db", record.snr, -50.0, 100.0)?; + push_metric(&mut metrics, "channel", record.channel, 1.0, 7_000.0)?; + push_metric(&mut metrics, "x_m", record.x, -1_000_000.0, 1_000_000.0)?; + push_metric(&mut metrics, "y_m", record.y, -1_000_000.0, 1_000_000.0)?; + if metrics.is_empty() { + return Err(VendorEventError::InvalidPayload); + } + Ok(VendorRfEvent { + vendor: VendorId::Mist, + capability: RfCapability::RfTelemetry, + sequence: stable_sequence(&record.id, timestamp_us, index), + timestamp_us, + source_id: record.id, + synthetic: false, + metrics, + label: context_label(&[record.site_id, record.ap_id, record.event])?, + }) +} + +#[derive(Debug, Deserialize)] +struct NetgearRecord { + #[serde( + alias = "clientId", + alias = "mac", + alias = "macAddress", + alias = "deviceId" + )] + id: String, + #[serde(default, alias = "lastSeen", alias = "observedAt")] + timestamp: Option, + #[serde(default, alias = "signalStrength", alias = "rssi_dbm")] + rssi: Option, + #[serde(default, alias = "signalToNoiseRatio")] + snr: Option, + #[serde(default)] + channel: Option, + #[serde(default, alias = "txRateMbps", alias = "tx_rate")] + tx_rate_mbps: Option, + #[serde(default, alias = "rxRateMbps", alias = "rx_rate")] + rx_rate_mbps: Option, + #[serde(default, alias = "locationId")] + location_id: Option, + #[serde(default, alias = "accessPointId", alias = "apId")] + ap_id: Option, + #[serde(default, alias = "ssidName")] + ssid: Option, +} + +fn parse_netgear_record( + object: &Map, + index: usize, +) -> Result { + let record: NetgearRecord = serde_json::from_value(Value::Object(object.clone())) + .map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?; + validate_source(&record.id)?; + validate_optional_text(record.location_id.as_deref())?; + validate_optional_text(record.ap_id.as_deref())?; + validate_optional_text(record.ssid.as_deref())?; + let timestamp_us = parse_timestamp(record.timestamp.as_ref())?; + let mut metrics = BTreeMap::new(); + push_metric(&mut metrics, "rssi_dbm", record.rssi, -150.0, 20.0)?; + push_metric(&mut metrics, "snr_db", record.snr, -50.0, 100.0)?; + push_metric(&mut metrics, "channel", record.channel, 1.0, 7_000.0)?; + push_metric( + &mut metrics, + "tx_rate_mbps", + record.tx_rate_mbps, + 0.0, + 100_000.0, + )?; + push_metric( + &mut metrics, + "rx_rate_mbps", + record.rx_rate_mbps, + 0.0, + 100_000.0, + )?; + if metrics.is_empty() { + return Err(VendorEventError::InvalidPayload); + } + Ok(VendorRfEvent { + vendor: VendorId::Netgear, + capability: RfCapability::RfTelemetry, + sequence: stable_sequence(&record.id, timestamp_us, index), + timestamp_us, + source_id: record.id, + synthetic: false, + metrics, + label: context_label(&[record.location_id, record.ap_id, record.ssid])?, + }) +} + +fn parse_timestamp(value: Option<&Value>) -> Result { + let value = value.ok_or(VendorEventError::InvalidPayload)?; + if let Some(text) = value.as_str() { + if let Ok(raw) = text.parse::() { + return normalize_integer_timestamp(raw); + } + if let Ok(raw) = text.parse::() { + return normalize_fractional_timestamp(raw); + } + let parsed = chrono::DateTime::parse_from_rfc3339(text) + .map_err(|_| VendorEventError::InvalidPayload)?; + return u64::try_from(parsed.timestamp_micros()) + .map_err(|_| VendorEventError::InvalidPayload); + } + if let Some(raw) = value.as_u64() { + return normalize_integer_timestamp(raw); + } + normalize_fractional_timestamp(value.as_f64().ok_or(VendorEventError::InvalidPayload)?) +} + +fn normalize_integer_timestamp(raw: u64) -> Result { + if raw == 0 { + return Err(VendorEventError::InvalidPayload); + } + // Normalize seconds, milliseconds, or microseconds to microseconds. + if raw < 10_000_000_000 { + raw.checked_mul(1_000_000) + .ok_or(VendorEventError::InvalidPayload) + } else if raw < 10_000_000_000_000 { + raw.checked_mul(1_000) + .ok_or(VendorEventError::InvalidPayload) + } else if raw < 10_000_000_000_000_000 { + Ok(raw) + } else { + Err(VendorEventError::InvalidPayload) + } +} + +fn normalize_fractional_timestamp(raw: f64) -> Result { + if !raw.is_finite() || raw <= 0.0 { + return Err(VendorEventError::InvalidPayload); + } + let micros = if raw < 10_000_000_000.0 { + raw * 1_000_000.0 + } else if raw < 10_000_000_000_000.0 { + raw * 1_000.0 + } else if raw < 10_000_000_000_000_000.0 { + raw + } else { + return Err(VendorEventError::InvalidPayload); + }; + if !micros.is_finite() || micros > u64::MAX as f64 { + return Err(VendorEventError::InvalidPayload); + } + Ok(micros.round() as u64) +} + +fn push_metric( + metrics: &mut BTreeMap, + name: &str, + value: Option, + minimum: f64, + maximum: f64, +) -> Result<(), VendorEventError> { + if let Some(value) = value { + if !value.is_finite() || !(minimum..=maximum).contains(&value) { + return Err(VendorEventError::InvalidPayload); + } + metrics.insert(name.into(), value); + } + Ok(()) +} + +fn validate_source(value: &str) -> Result<(), VendorEventError> { + if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +fn validate_optional_text(value: Option<&str>) -> Result<(), VendorEventError> { + if value.is_some_and(|v| v.is_empty() || v.len() > 256 || v.chars().any(char::is_control)) { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +fn context_label(parts: &[Option]) -> Result, VendorEventError> { + let label = parts + .iter() + .filter_map(Option::as_deref) + .collect::>() + .join("/"); + if label.len() > 256 { + return Err(VendorEventError::InvalidPayload); + } + Ok((!label.is_empty()).then_some(label)) +} + +fn stable_sequence(source: &str, timestamp_us: u64, index: usize) -> u64 { + // FNV-1a gives a deterministic correlation key without process-random state. + let mut hash = 0xcbf29ce484222325_u64; + for byte in source + .bytes() + .chain(timestamp_us.to_le_bytes()) + .chain((index as u64).to_le_bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + const MIST_FIXTURE: &[u8] = br#"{ + "results": [ + {"id":"aa:bb:cc:dd:ee:ff","timestamp":1710000000,"rssi":-47,"snr":31,"channel":44,"x":12.5,"y":8.25,"site_id":"site-1","ap_id":"ap-7"}, + {"client_id":"station-2","last_seen":1710000000123,"rssi_dbm":-62,"event_type":"client-info"} + ], + "next_cursor":"page-2" + }"#; + + const NETGEAR_FIXTURE: &[u8] = br#"{ + "data": [ + {"clientId":"client-1","observedAt":"1710000000000000","signalStrength":-53,"signalToNoiseRatio":24,"channel":149,"txRateMbps":866.7,"rxRateMbps":721.2,"locationId":"office","accessPointId":"ap-2","ssidName":"lab"} + ], + "pagination":{"next":"cursor-2"} + }"#; + + #[test] + fn descriptors_are_honest_and_valid() { + for descriptor in [mist_descriptor(), netgear_descriptor()] { + descriptor.validate().unwrap(); + assert!(!descriptor.hardware_validated); + assert_eq!( + descriptor.availability, + ProviderAvailability::CredentialsRequired + ); + assert!(!descriptor.capabilities.contains(&RfCapability::ComplexCsi)); + } + } + + #[test] + fn mist_rest_and_webhook_fixture_is_deterministic() { + let provider = MistProvider; + let first = provider.decode_page(MIST_FIXTURE).unwrap(); + let second = provider.decode_page(MIST_FIXTURE).unwrap(); + assert_eq!(first, second); + assert_eq!(first.next_cursor.as_deref(), Some("page-2")); + assert_eq!(first.events.len(), 2); + assert_eq!(first.events[0].timestamp_us, 1_710_000_000_000_000); + assert_eq!(first.events[1].timestamp_us, 1_710_000_000_123_000); + assert_eq!(first.events[0].metrics["x_m"], 12.5); + assert!(!first.events[0].synthetic); + } + + #[test] + fn netgear_page_fixture_normalizes_aliases() { + let page = NetgearInsightProvider.decode_page(NETGEAR_FIXTURE).unwrap(); + assert_eq!(page.next_cursor.as_deref(), Some("cursor-2")); + assert_eq!(page.events.len(), 1); + let event = &page.events[0]; + assert_eq!(event.vendor, VendorId::Netgear); + assert_eq!(event.capability, RfCapability::RfTelemetry); + assert_eq!(event.metrics["tx_rate_mbps"], 866.7); + assert_eq!(event.label.as_deref(), Some("office/ap-2/lab")); + } + + #[test] + fn top_level_array_is_supported_without_pagination() { + let payload = br#"[{"mac":"a","timestamp":1710000000,"rssi":-40}]"#; + let page = MistProvider.decode_page(payload).unwrap(); + assert_eq!(page.events.len(), 1); + assert_eq!(page.next_cursor, None); + } + + #[test] + fn payload_and_page_bounds_are_enforced() { + assert_eq!( + MistProvider.decode(&vec![b' '; MAX_VENDOR_PAYLOAD_BYTES + 1]), + Err(VendorEventError::InvalidPayload) + ); + let values = (0..=MAX_EVENTS_PER_PAGE) + .map(|_| serde_json::json!({"id":"a","timestamp":1710000000,"rssi":-40})) + .collect::>(); + let bytes = serde_json::to_vec(&values).unwrap(); + assert_eq!( + MistProvider.decode(&bytes), + Err(VendorEventError::InvalidPayload) + ); + } + + #[test] + fn missing_identity_timestamp_or_metrics_fails_closed() { + for payload in [ + br#"[{"timestamp":1710000000,"rssi":-40}]"#.as_slice(), + br#"[{"id":"a","rssi":-40}]"#.as_slice(), + br#"[{"id":"a","timestamp":1710000000}]"#.as_slice(), + ] { + assert!(MistProvider.decode(payload).is_err()); + } + } + + #[test] + fn invalid_metric_cursor_and_record_schema_fail_closed() { + assert!(MistProvider + .decode(br#"[{"id":"a","timestamp":1710000000,"rssi":999}]"#) + .is_err()); + assert!(NetgearInsightProvider.decode(br#"{"data":[42]}"#).is_err()); + assert!(MistProvider + .decode_page( + br#"{"results":[{"id":"a","timestamp":1710000000,"rssi":-40}],"next_cursor":"x&admin=true"}"# + ) + .is_err()); + } + + #[test] + fn request_configuration_is_regional_validated_and_redacted() { + let token = SecretToken::new("super-secret").unwrap(); + let mist = mist_request( + MistRegion::Europe, + "site_1", + Some("page-2".into()), + token.clone(), + ) + .unwrap(); + assert_eq!(mist.base_url, "https://api.eu.mist.com"); + assert_eq!(mist.path, "/api/v1/sites/site_1/stats/clients"); + let netgear = netgear_request(NetgearRegion::Australia, "location-7", None, token).unwrap(); + assert_eq!(netgear.base_url, "https://au.insight.netgear.com"); + assert!(!format!("{netgear:?}").contains("super-secret")); + assert!(!format!("{:?}", netgear.token).contains("super-secret")); + assert!(mist_request( + MistRegion::Global, + "../other-site", + None, + SecretToken::new("token").unwrap() + ) + .is_err()); + } + + #[test] + fn token_rejects_header_injection() { + assert!(SecretToken::new("token\r\nX-Injected: yes").is_err()); + assert!(SecretToken::new("").is_err()); + } + + #[test] + fn timestamp_units_are_normalized_and_extremes_rejected() { + assert_eq!( + parse_timestamp(Some(&serde_json::json!(1_710_000_000))).unwrap(), + 1_710_000_000_000_000 + ); + assert_eq!( + parse_timestamp(Some(&serde_json::json!(1_710_000_000_123_u64))).unwrap(), + 1_710_000_000_123_000 + ); + assert!(parse_timestamp(Some(&serde_json::json!(0))).is_err()); + assert!(parse_timestamp(Some(&serde_json::json!(-1))).is_err()); + assert!(parse_timestamp(Some(&serde_json::json!(u64::MAX))).is_err()); + assert_eq!( + parse_timestamp(Some(&serde_json::json!("2024-03-09T16:00:00Z"))).unwrap(), + 1_710_000_000_000_000 + ); + assert_eq!( + parse_timestamp(Some(&serde_json::json!(1710000000.25))).unwrap(), + 1_710_000_000_250_000 + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/vendor_origin_plume.rs b/v2/crates/wifi-densepose-sensing-server/src/vendor_origin_plume.rs new file mode 100644 index 00000000..a793f59f --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/vendor_origin_plume.rs @@ -0,0 +1,598 @@ +//! Capability-safe ADR-270 adapters for Origin AI and Plume/OpenSync. +//! +//! Origin publishes derived sensing results through contract-gated APIs and +//! webhooks. Public documentation does not define stable paths, so paths are +//! supplied by the contracted deployment configuration. OpenSync exports RF +//! and network telemetry; neither adapter promotes scalar data to complex CSI. + +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use wifi_densepose_hardware::vendor_rf::{ + ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId, + VendorRfEvent, VendorRfProvider, +}; + +const MAX_PAYLOAD_BYTES: usize = 256 * 1024; +const MAX_EVENTS_PER_PAYLOAD: usize = 256; +const MAX_ENDPOINT_LEN: usize = 2048; +const MAX_ENV_NAME_LEN: usize = 128; + +/// A request plan deliberately containing a credential *reference*, not a secret. +#[derive(Debug, Clone, PartialEq)] +pub struct VendorRequest { + pub method: &'static str, + pub endpoint: String, + pub headers: BTreeMap, + pub credential_env: Option, + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OriginAiConfig { + /// Contract-provided HTTPS sensing-server base URL. + pub base_url: String, + /// Contract-provided relative event API path. No public path is assumed. + pub event_path: String, + /// Name of the environment variable holding the partner bearer token. + pub token_env: String, +} + +impl OriginAiConfig { + pub fn validate(&self) -> Result<(), VendorEventError> { + validate_https_base(&self.base_url)?; + validate_relative_path(&self.event_path)?; + validate_env_name(&self.token_env) + } + + /// Constructs a GET plan. The caller resolves `credential_env` at execution time. + pub fn events_request(&self) -> Result { + self.validate()?; + Ok(VendorRequest { + method: "GET", + endpoint: join_endpoint(&self.base_url, &self.event_path), + headers: BTreeMap::from([("accept".into(), "application/json".into())]), + credential_env: Some(self.token_env.clone()), + body: None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlumeOpenSyncConfig { + /// HTTPS northbound/sandbox URL supplied by the operator. + pub base_url: String, + /// Relative endpoint accepting OVSDB JSON-RPC for this deployment. + pub ovsdb_path: String, + /// Optional environment variable used by a protected northbound endpoint. + pub token_env: Option, +} + +impl PlumeOpenSyncConfig { + pub fn validate(&self) -> Result<(), VendorEventError> { + validate_https_base(&self.base_url)?; + validate_relative_path(&self.ovsdb_path)?; + if let Some(name) = &self.token_env { + validate_env_name(name)?; + } + Ok(()) + } + + /// Builds a read-only OVSDB `select` transaction for an allow-listed table. + pub fn select_request(&self, table: &str) -> Result { + self.validate()?; + if !matches!( + table, + "Wifi_Radio_State" | "Wifi_VIF_State" | "Wifi_Associated_Clients" + ) { + return Err(VendorEventError::InvalidPayload); + } + Ok(VendorRequest { + method: "POST", + endpoint: join_endpoint(&self.base_url, &self.ovsdb_path), + headers: BTreeMap::from([ + ("accept".into(), "application/json".into()), + ("content-type".into(), "application/json".into()), + ]), + credential_env: self.token_env.clone(), + body: Some(json!({ + "jsonrpc": "2.0", + "id": "ruview-read-only", + "method": "transact", + "params": ["Open_vSwitch", {"op": "select", "table": table, "where": []}] + })), + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct OriginAiProvider; + +impl OriginAiProvider { + pub fn synthetic_fixture(seed: u64, count: usize) -> Vec { + let count = count.min(MAX_EVENTS_PER_PAYLOAD); + (0..count) + .map(|index| { + let state = splitmix64(seed.wrapping_add(index as u64)); + let motion = (state & 1) as f64; + let confidence = 0.70 + ((state >> 8) % 300) as f64 / 1000.0; + VendorRfEvent { + vendor: VendorId::OriginAi, + capability: RfCapability::DerivedSensing, + sequence: index as u64, + timestamp_us: 1_700_000_000_000_000 + index as u64 * 100_000, + source_id: format!("origin-sim-{:08x}", seed as u32), + synthetic: true, + metrics: BTreeMap::from([ + ("motion".into(), motion), + ("confidence".into(), confidence), + ]), + label: Some(if motion == 1.0 { "motion" } else { "clear" }.into()), + } + }) + .collect() + } +} + +impl VendorRfProvider for OriginAiProvider { + fn descriptor(&self) -> ProviderDescriptor { + ProviderDescriptor { + vendor: VendorId::OriginAi, + capabilities: vec![RfCapability::DerivedSensing], + availability: ProviderAvailability::ContractRequired, + hardware_validated: false, + reason: "Origin partner API/SDK access is contract-gated; adapter accepts derived sensing only" + .into(), + } + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + check_payload(payload)?; + let envelope: OriginEnvelope = decode_json(payload)?; + if envelope.events.is_empty() || envelope.events.len() > MAX_EVENTS_PER_PAYLOAD { + return Err(VendorEventError::InvalidPayload); + } + envelope + .events + .into_iter() + .map(|event| event.into_vendor_event(&self.descriptor())) + .collect() + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OriginEnvelope { + events: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OriginEvent { + sequence: u64, + timestamp_us: u64, + source_id: String, + kind: OriginKind, + confidence: f64, + #[serde(default)] + value: Option, + #[serde(default)] + label: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum OriginKind { + Motion, + Occupancy, + Presence, + Fall, + BreathingRate, +} + +impl OriginEvent { + fn into_vendor_event( + self, + descriptor: &ProviderDescriptor, + ) -> Result { + if self.timestamp_us == 0 || !(0.0..=1.0).contains(&self.confidence) { + return Err(VendorEventError::InvalidPayload); + } + let (metric, requires_value) = match self.kind { + OriginKind::Motion => ("motion", false), + OriginKind::Occupancy => ("occupancy", false), + OriginKind::Presence => ("presence", false), + OriginKind::Fall => ("fall", false), + OriginKind::BreathingRate => ("breathing_rate_bpm", true), + }; + let value = self.value.ok_or(VendorEventError::InvalidPayload)?; + if !value.is_finite() + || (requires_value && !(1.0..=120.0).contains(&value)) + || (!requires_value && value != 0.0 && value != 1.0) + { + return Err(VendorEventError::InvalidPayload); + } + let event = VendorRfEvent { + vendor: VendorId::OriginAi, + capability: RfCapability::DerivedSensing, + sequence: self.sequence, + timestamp_us: self.timestamp_us, + source_id: self.source_id, + synthetic: false, + metrics: BTreeMap::from([ + (metric.into(), value), + ("confidence".into(), self.confidence), + ]), + label: self.label, + }; + event.validate(descriptor)?; + Ok(event) + } +} + +#[derive(Debug, Clone, Default)] +pub struct PlumeOpenSyncProvider; + +impl PlumeOpenSyncProvider { + pub fn synthetic_fixture(seed: u64, count: usize) -> Vec { + let count = count.min(MAX_EVENTS_PER_PAYLOAD); + (0..count) + .map(|index| { + let state = splitmix64(seed.wrapping_add(index as u64)); + VendorRfEvent { + vendor: VendorId::Plume, + capability: RfCapability::RfTelemetry, + sequence: index as u64, + timestamp_us: 1_700_000_000_000_000 + index as u64 * 250_000, + source_id: format!("opensync-sim-{:08x}", seed as u32), + synthetic: true, + metrics: BTreeMap::from([ + ("rssi_dbm".into(), -30.0 - (state % 55) as f64), + ("channel".into(), 1.0 + ((state >> 8) % 165) as f64), + ( + "noise_floor_dbm".into(), + -100.0 + ((state >> 16) % 12) as f64, + ), + ]), + label: None, + } + }) + .collect() + } +} + +impl VendorRfProvider for PlumeOpenSyncProvider { + fn descriptor(&self) -> ProviderDescriptor { + ProviderDescriptor { + vendor: VendorId::Plume, + capabilities: vec![RfCapability::RfTelemetry], + availability: ProviderAvailability::CredentialsRequired, + hardware_validated: false, + reason: "OpenSync radio/client telemetry only; Plume Sense is a separate gated service" + .into(), + } + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + check_payload(payload)?; + let envelope: OpenSyncEnvelope = decode_json(payload)?; + if envelope.observations.is_empty() || envelope.observations.len() > MAX_EVENTS_PER_PAYLOAD + { + return Err(VendorEventError::InvalidPayload); + } + envelope + .observations + .into_iter() + .map(|event| event.into_vendor_event(&self.descriptor())) + .collect() + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenSyncEnvelope { + observations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenSyncObservation { + sequence: u64, + timestamp_us: u64, + source_id: String, + rssi_dbm: f64, + channel: u16, + #[serde(default)] + noise_floor_dbm: Option, + #[serde(default)] + tx_rate_mbps: Option, + #[serde(default)] + rx_rate_mbps: Option, + #[serde(default)] + clients: Option, +} + +impl OpenSyncObservation { + fn into_vendor_event( + self, + descriptor: &ProviderDescriptor, + ) -> Result { + if self.timestamp_us == 0 + || !(-127.0..=0.0).contains(&self.rssi_dbm) + || self.channel == 0 + || self.channel > 233 + { + return Err(VendorEventError::InvalidPayload); + } + let mut metrics = BTreeMap::from([ + ("rssi_dbm".into(), self.rssi_dbm), + ("channel".into(), self.channel as f64), + ]); + insert_optional( + &mut metrics, + "noise_floor_dbm", + self.noise_floor_dbm, + -127.0, + 0.0, + )?; + insert_optional( + &mut metrics, + "tx_rate_mbps", + self.tx_rate_mbps, + 0.0, + 100_000.0, + )?; + insert_optional( + &mut metrics, + "rx_rate_mbps", + self.rx_rate_mbps, + 0.0, + 100_000.0, + )?; + if let Some(clients) = self.clients { + metrics.insert("clients".into(), clients as f64); + } + let event = VendorRfEvent { + vendor: VendorId::Plume, + capability: RfCapability::RfTelemetry, + sequence: self.sequence, + timestamp_us: self.timestamp_us, + source_id: self.source_id, + synthetic: false, + metrics, + label: None, + }; + event.validate(descriptor)?; + Ok(event) + } +} + +fn insert_optional( + metrics: &mut BTreeMap, + key: &str, + value: Option, + minimum: f64, + maximum: f64, +) -> Result<(), VendorEventError> { + if let Some(value) = value { + if !value.is_finite() || !(minimum..=maximum).contains(&value) { + return Err(VendorEventError::InvalidPayload); + } + metrics.insert(key.into(), value); + } + Ok(()) +} + +fn check_payload(payload: &[u8]) -> Result<(), VendorEventError> { + if payload.is_empty() || payload.len() > MAX_PAYLOAD_BYTES { + Err(VendorEventError::InvalidPayload) + } else { + Ok(()) + } +} + +fn decode_json Deserialize<'de>>(payload: &[u8]) -> Result { + serde_json::from_slice(payload).map_err(|error| { + let message = error.to_string(); + VendorEventError::MalformedPayload(message.chars().take(160).collect()) + }) +} + +fn validate_https_base(value: &str) -> Result<(), VendorEventError> { + let authority = value + .strip_prefix("https://") + .and_then(|rest| rest.split('/').next()) + .unwrap_or_default(); + if value.len() > MAX_ENDPOINT_LEN + || authority.is_empty() + || authority.contains('@') + || authority.chars().any(char::is_whitespace) + || value.contains(['\r', '\n', '#', '?']) + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +fn validate_relative_path(value: &str) -> Result<(), VendorEventError> { + if value.len() > MAX_ENDPOINT_LEN + || !value.starts_with('/') + || value.starts_with("//") + || value.contains(['\r', '\n', '#', '?']) + || value.split('/').any(|segment| segment == "..") + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +fn validate_env_name(value: &str) -> Result<(), VendorEventError> { + if value.is_empty() + || value.len() > MAX_ENV_NAME_LEN + || !value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') + || value.as_bytes()[0].is_ascii_digit() + { + return Err(VendorEventError::InvalidPayload); + } + Ok(()) +} + +fn join_endpoint(base: &str, path: &str) -> String { + format!("{}{}", base.trim_end_matches('/'), path) +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_hardware::vendor_rf::MAX_VENDOR_TEXT_LEN; + + #[test] + fn descriptors_are_honest_about_capabilities_and_access() { + let origin = OriginAiProvider.descriptor(); + assert_eq!(origin.capabilities, vec![RfCapability::DerivedSensing]); + assert_eq!(origin.availability, ProviderAvailability::ContractRequired); + let plume = PlumeOpenSyncProvider.descriptor(); + assert_eq!(plume.capabilities, vec![RfCapability::RfTelemetry]); + assert_eq!( + plume.availability, + ProviderAvailability::CredentialsRequired + ); + assert!(!origin.capabilities.contains(&RfCapability::ComplexCsi)); + assert!(!plume.capabilities.contains(&RfCapability::ComplexCsi)); + } + + #[test] + fn origin_decodes_only_derived_sensing() { + let payload = br#"{"events":[{"sequence":7,"timestamp_us":42,"source_id":"zone-a","kind":"occupancy","confidence":0.91,"value":1,"label":"occupied"}]}"#; + let events = OriginAiProvider.decode(payload).unwrap(); + assert_eq!(events[0].capability, RfCapability::DerivedSensing); + assert_eq!(events[0].metrics["occupancy"], 1.0); + assert!(!events[0].synthetic); + } + + #[test] + fn origin_rejects_unknown_raw_csi_and_bad_values() { + let raw = br#"{"events":[{"sequence":1,"timestamp_us":2,"source_id":"z","kind":"motion","confidence":1,"value":1,"raw_csi":[[1,2]]}]}"#; + assert!(OriginAiProvider.decode(raw).is_err()); + let bad_confidence = br#"{"events":[{"sequence":1,"timestamp_us":2,"source_id":"z","kind":"motion","confidence":1.1,"value":1}]}"#; + assert_eq!( + OriginAiProvider.decode(bad_confidence), + Err(VendorEventError::InvalidPayload) + ); + } + + #[test] + fn plume_decodes_telemetry_without_inventing_csi() { + let payload = br#"{"observations":[{"sequence":9,"timestamp_us":10,"source_id":"pod-a","rssi_dbm":-54,"channel":36,"noise_floor_dbm":-94,"tx_rate_mbps":1200,"clients":4}]}"#; + let events = PlumeOpenSyncProvider.decode(payload).unwrap(); + assert_eq!(events[0].capability, RfCapability::RfTelemetry); + assert_eq!(events[0].metrics["rssi_dbm"], -54.0); + assert!(!events[0].metrics.contains_key("csi")); + } + + #[test] + fn malformed_empty_oversized_and_unbounded_arrays_fail_closed() { + assert!(OriginAiProvider.decode(b"{").is_err()); + assert_eq!( + OriginAiProvider.decode(b""), + Err(VendorEventError::InvalidPayload) + ); + assert_eq!( + PlumeOpenSyncProvider.decode(&vec![b' '; MAX_PAYLOAD_BYTES + 1]), + Err(VendorEventError::InvalidPayload) + ); + let event = r#"{"sequence":1,"timestamp_us":2,"source_id":"p","rssi_dbm":-50,"channel":1}"#; + let payload = format!("{{\"observations\":[{}]}}", vec![event; 257].join(",")); + assert_eq!( + PlumeOpenSyncProvider.decode(payload.as_bytes()), + Err(VendorEventError::InvalidPayload) + ); + } + + #[test] + fn telemetry_ranges_are_enforced() { + let payload = br#"{"observations":[{"sequence":1,"timestamp_us":2,"source_id":"p","rssi_dbm":4,"channel":36}]}"#; + assert_eq!( + PlumeOpenSyncProvider.decode(payload), + Err(VendorEventError::InvalidPayload) + ); + } + + #[test] + fn fixtures_are_deterministic_bounded_and_marked_synthetic() { + let a = OriginAiProvider::synthetic_fixture(19, 4); + assert_eq!(a, OriginAiProvider::synthetic_fixture(19, 4)); + assert!(a.iter().all(|event| event.synthetic)); + let p = PlumeOpenSyncProvider::synthetic_fixture(23, usize::MAX); + assert_eq!(p.len(), MAX_EVENTS_PER_PAYLOAD); + assert!(p.iter().all(|event| event.synthetic)); + assert_eq!(p, PlumeOpenSyncProvider::synthetic_fixture(23, usize::MAX)); + } + + #[test] + fn request_plans_reference_secrets_without_embedding_them() { + let origin = OriginAiConfig { + base_url: "https://partner.example".into(), + event_path: "/contract/v1/events".into(), + token_env: "ORIGIN_AI_TOKEN".into(), + } + .events_request() + .unwrap(); + assert_eq!( + origin.endpoint, + "https://partner.example/contract/v1/events" + ); + assert_eq!(origin.credential_env.as_deref(), Some("ORIGIN_AI_TOKEN")); + assert!(format!("{origin:?}").find("Bearer ").is_none()); + + let plume = PlumeOpenSyncConfig { + base_url: "https://sandbox.example".into(), + ovsdb_path: "/ovsdb".into(), + token_env: Some("OPENSYNC_TOKEN".into()), + } + .select_request("Wifi_Radio_State") + .unwrap(); + assert_eq!(plume.body.as_ref().unwrap()["method"], "transact"); + assert_eq!(plume.credential_env.as_deref(), Some("OPENSYNC_TOKEN")); + } + + #[test] + fn request_validation_rejects_injection_and_write_tables() { + let config = PlumeOpenSyncConfig { + base_url: "https://sandbox.example".into(), + ovsdb_path: "/ovsdb".into(), + token_env: None, + }; + assert_eq!( + config.select_request("AWLAN_Node"), + Err(VendorEventError::InvalidPayload) + ); + let bad = OriginAiConfig { + base_url: "http://insecure.example".into(), + event_path: "/events\r\nx: y".into(), + token_env: "token".into(), + }; + assert_eq!(bad.events_request(), Err(VendorEventError::InvalidPayload)); + } + + #[test] + fn labels_and_source_ids_obey_shared_contract_bounds() { + let label = "x".repeat(MAX_VENDOR_TEXT_LEN + 1); + let payload = format!( + "{{\"events\":[{{\"sequence\":1,\"timestamp_us\":2,\"source_id\":\"z\",\"kind\":\"motion\",\"confidence\":1,\"value\":1,\"label\":\"{label}\"}}]}}" + ); + assert_eq!( + OriginAiProvider.decode(payload.as_bytes()), + Err(VendorEventError::InvalidPayload) + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/vendor_remaining.rs b/v2/crates/wifi-densepose-sensing-server/src/vendor_remaining.rs new file mode 100644 index 00000000..12b740b9 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/vendor_remaining.rs @@ -0,0 +1,481 @@ +//! ADR-270 providers whose useful integration surface is scalar telemetry, +//! network-only metadata, or an explicit no-go decision. +//! +//! None of these providers emits complex CSI. The small JSON contract in this +//! module is intended for sidecars and deterministic replay fixtures; it is not +//! a claim that a vendor exposes this exact wire format. + +use serde::Deserialize; +use std::collections::BTreeMap; +use wifi_densepose_hardware::vendor_rf::{ + ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId, + VendorRfEvent, VendorRfProvider, +}; + +/// Upper bound applied before JSON decoding, limiting parser allocation. +pub const MAX_REMAINING_VENDOR_PAYLOAD_BYTES: usize = 64 * 1024; +/// Upper bound on events accepted in one sidecar envelope. +pub const MAX_REMAINING_VENDOR_EVENTS: usize = 256; + +const ELECTRIC_IMP_METRICS: &[MetricRule] = &[ + MetricRule::new("battery_v", 0.0, 100.0, false), + MetricRule::new("humidity_percent", 0.0, 100.0, false), + MetricRule::new("rssi_dbm", -127.0, 0.0, false), + MetricRule::new("temperature_c", -100.0, 200.0, false), + MetricRule::new("voltage_v", 0.0, 1_000.0, false), +]; +const RF_SOLUTIONS_METRICS: &[MetricRule] = &[ + MetricRule::new("battery_v", 0.0, 100.0, false), + MetricRule::new("humidity_percent", 0.0, 100.0, false), + MetricRule::new("relay_state", 0.0, 1.0, true), + MetricRule::new("rssi_dbm", -127.0, 0.0, false), + MetricRule::new("temperature_c", -100.0, 200.0, false), +]; +const LUMA_METRICS: &[MetricRule] = &[ + MetricRule::new("client_count", 0.0, 1_000_000.0, true), + MetricRule::new("noise_dbm", -127.0, 0.0, false), + MetricRule::new("rssi_dbm", -127.0, 0.0, false), + MetricRule::new("rx_bytes", 0.0, 9_007_199_254_740_991.0, true), + MetricRule::new("tx_bytes", 0.0, 9_007_199_254_740_991.0, true), +]; +const GOOGLE_NEST_METRICS: &[MetricRule] = &[ + MetricRule::new("client_count", 0.0, 1_000_000.0, true), + MetricRule::new("probe_count", 0.0, 1_000_000_000.0, true), + MetricRule::new("rx_bytes", 0.0, 9_007_199_254_740_991.0, true), + MetricRule::new("tx_bytes", 0.0, 9_007_199_254_740_991.0, true), +]; + +#[derive(Debug, Clone, Copy)] +struct MetricRule { + name: &'static str, + minimum: f64, + maximum: f64, + integer: bool, +} + +impl MetricRule { + const fn new(name: &'static str, minimum: f64, maximum: f64, integer: bool) -> Self { + Self { + name, + minimum, + maximum, + integer, + } + } + + fn accepts(self, value: f64) -> bool { + value.is_finite() + && (self.minimum..=self.maximum).contains(&value) + && (!self.integer || value.fract() == 0.0) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ScalarEnvelope { + events: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ScalarEvent { + sequence: u64, + timestamp_us: u64, + source_id: String, + synthetic: bool, + metrics: BTreeMap, + #[serde(default)] + label: Option, +} + +fn descriptor( + vendor: VendorId, + capability: RfCapability, + availability: ProviderAvailability, + reason: &str, +) -> ProviderDescriptor { + ProviderDescriptor { + vendor, + capabilities: vec![capability], + availability, + hardware_validated: false, + reason: reason.to_owned(), + } +} + +fn decode_bounded_scalar_events( + payload: &[u8], + provider: &ProviderDescriptor, + capability: RfCapability, + allowed_metrics: &[MetricRule], +) -> Result, VendorEventError> { + provider.validate()?; + if !provider.capabilities.contains(&capability) + || matches!( + capability, + RfCapability::ComplexCsi | RfCapability::Unsupported + ) + { + return Err(VendorEventError::CapabilityMismatch); + } + if payload.is_empty() || payload.len() > MAX_REMAINING_VENDOR_PAYLOAD_BYTES { + return Err(VendorEventError::InvalidPayload); + } + + let envelope: ScalarEnvelope = serde_json::from_slice(payload).map_err(|error| { + VendorEventError::MalformedPayload(error.to_string().chars().take(160).collect()) + })?; + if envelope.events.is_empty() || envelope.events.len() > MAX_REMAINING_VENDOR_EVENTS { + return Err(VendorEventError::InvalidPayload); + } + + envelope + .events + .into_iter() + .map(|event| { + // An allowlist keeps arbitrary scalar fields from silently changing + // the meaning of a provider contract (and rejects CSI-shaped data). + if event.timestamp_us == 0 + || event.source_id.chars().any(char::is_control) + || event + .label + .as_deref() + .is_some_and(|label| label.chars().any(char::is_control)) + { + return Err(VendorEventError::InvalidPayload); + } + for (key, value) in &event.metrics { + let rule = allowed_metrics + .iter() + .find(|rule| rule.name == key) + .ok_or(VendorEventError::CapabilityMismatch)?; + if !rule.accepts(*value) { + return Err(VendorEventError::InvalidPayload); + } + } + let event = VendorRfEvent { + vendor: provider.vendor, + capability, + sequence: event.sequence, + timestamp_us: event.timestamp_us, + source_id: event.source_id, + synthetic: event.synthetic, + metrics: event.metrics, + label: event.label, + }; + event.validate(provider)?; + Ok(event) + }) + .collect() +} + +/// Electric Imp agent/impCentral scalar telemetry bridge for existing fleets. +#[derive(Debug, Clone, Copy, Default)] +pub struct ElectricImpProvider; + +impl VendorRfProvider for ElectricImpProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::ElectricImp, + RfCapability::RfTelemetry, + ProviderAvailability::CredentialsRequired, + "Optional authenticated agent/impCentral scalar telemetry bridge; never CSI", + ) + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + let descriptor = self.descriptor(); + decode_bounded_scalar_events( + payload, + &descriptor, + RfCapability::RfTelemetry, + ELECTRIC_IMP_METRICS, + ) + } +} + +/// RF Solutions environmental/RIoT scalar telemetry boundary. +#[derive(Debug, Clone, Copy, Default)] +pub struct RfSolutionsProvider; + +impl VendorRfProvider for RfSolutionsProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::RfSolutions, + RfCapability::RfTelemetry, + ProviderAvailability::Experimental, + "Optional non-Wi-Fi environmental telemetry fusion; excluded as a CSI source", + ) + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + let descriptor = self.descriptor(); + decode_bounded_scalar_events( + payload, + &descriptor, + RfCapability::RfTelemetry, + RF_SOLUTIONS_METRICS, + ) + } +} + +/// Generic OpenWrt telemetry fixture for already-owned discontinued Luma units. +#[derive(Debug, Clone, Copy, Default)] +pub struct LumaOpenWrtProvider; + +impl VendorRfProvider for LumaOpenWrtProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::Luma, + RfCapability::RfTelemetry, + ProviderAvailability::Experimental, + "Generic OpenWrt scalar telemetry fixture for already-owned Luma hardware; no Luma CSI claim", + ) + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + let descriptor = self.descriptor(); + decode_bounded_scalar_events( + payload, + &descriptor, + RfCapability::RfTelemetry, + LUMA_METRICS, + ) + } +} + +/// Google Nest Wifi may participate as network infrastructure, not a sensor. +#[derive(Debug, Clone, Copy, Default)] +pub struct GoogleNestProvider; + +impl VendorRfProvider for GoogleNestProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::GoogleNest, + RfCapability::NetworkOnly, + ProviderAvailability::Experimental, + "Network-only replay events; Device Access exposes no router CSI or RF telemetry", + ) + } + + fn decode(&self, payload: &[u8]) -> Result, VendorEventError> { + let descriptor = self.descriptor(); + decode_bounded_scalar_events( + payload, + &descriptor, + RfCapability::NetworkOnly, + GOOGLE_NEST_METRICS, + ) + } +} + +/// Linksys Aware reached end of support; there is no supported sensing API. +#[derive(Debug, Clone, Copy, Default)] +pub struct LinksysProvider; + +impl VendorRfProvider for LinksysProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::Linksys, + RfCapability::Unsupported, + ProviderAvailability::Unsupported, + "Linksys Aware reached end of support in 2024; no supported sensing interface", + ) + } + + fn decode(&self, _payload: &[u8]) -> Result, VendorEventError> { + Err(VendorEventError::Unsupported) + } +} + +/// Wifigarden remains gated until its commercial SDK contract is disclosed. +#[derive(Debug, Clone, Copy, Default)] +pub struct WifigardenProvider; + +impl VendorRfProvider for WifigardenProvider { + fn descriptor(&self) -> ProviderDescriptor { + descriptor( + VendorId::Wifigarden, + RfCapability::Unsupported, + ProviderAvailability::ContractRequired, + "Commercial SDK, chipset, schema, calibration and data-rights disclosure required", + ) + } + + fn decode(&self, _payload: &[u8]) -> Result, VendorEventError> { + Err(VendorEventError::ContractRequired) + } +} + +/// Deterministic synthetic Electric Imp sidecar contract fixture. +pub const ELECTRIC_IMP_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":7,"timestamp_us":1700000000000000,"source_id":"imp005-fixture","synthetic":true,"metrics":{"rssi_dbm":-48.0,"temperature_c":21.5},"label":"lab"}]}"#; + +/// Deterministic synthetic RF Solutions sidecar contract fixture. +pub const RF_SOLUTIONS_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":8,"timestamp_us":1700000000000100,"source_id":"riot-fixture","synthetic":true,"metrics":{"battery_v":3.1,"humidity_percent":44.0},"label":"lab"}]}"#; + +/// Deterministic synthetic generic OpenWrt/Luma contract fixture. +pub const LUMA_OPENWRT_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":9,"timestamp_us":1700000000000200,"source_id":"luma-openwrt-fixture","synthetic":true,"metrics":{"client_count":3.0,"noise_dbm":-91.0,"rx_bytes":1024.0},"label":"openwrt"}]}"#; + +/// Deterministic synthetic Google Nest network-only contract fixture. +pub const GOOGLE_NEST_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":10,"timestamp_us":1700000000000300,"source_id":"nest-fixture","synthetic":true,"metrics":{"client_count":4.0,"probe_count":2.0},"label":"network_activity"}]}"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_valid_fixture( + provider: P, + fixture: &[u8], + vendor: VendorId, + capability: RfCapability, + ) { + let descriptor = provider.descriptor(); + descriptor.validate().expect("descriptor must be valid"); + let first = provider + .decode(fixture) + .expect("fixture must decode deterministically"); + let second = provider.decode(fixture).expect("fixture must replay"); + assert_eq!(first, second); + assert_eq!(first.len(), 1); + assert_eq!(first[0].vendor, vendor); + assert_eq!(first[0].capability, capability); + assert!(first[0].synthetic); + first[0] + .validate(&descriptor) + .expect("fixture event must satisfy provider contract"); + } + + #[test] + fn scalar_and_network_fixtures_are_deterministic_and_honest() { + assert_valid_fixture( + ElectricImpProvider, + ELECTRIC_IMP_CONTRACT_FIXTURE, + VendorId::ElectricImp, + RfCapability::RfTelemetry, + ); + assert_valid_fixture( + RfSolutionsProvider, + RF_SOLUTIONS_CONTRACT_FIXTURE, + VendorId::RfSolutions, + RfCapability::RfTelemetry, + ); + assert_valid_fixture( + LumaOpenWrtProvider, + LUMA_OPENWRT_CONTRACT_FIXTURE, + VendorId::Luma, + RfCapability::RfTelemetry, + ); + assert_valid_fixture( + GoogleNestProvider, + GOOGLE_NEST_CONTRACT_FIXTURE, + VendorId::GoogleNest, + RfCapability::NetworkOnly, + ); + } + + #[test] + fn unavailable_providers_fail_before_interpreting_payloads() { + let linksys = LinksysProvider; + assert_eq!( + linksys.descriptor().availability, + ProviderAvailability::Unsupported + ); + assert_eq!( + linksys.decode(b"not json"), + Err(VendorEventError::Unsupported) + ); + + let wifigarden = WifigardenProvider; + assert_eq!( + wifigarden.descriptor().availability, + ProviderAvailability::ContractRequired + ); + assert_eq!( + wifigarden.decode(ELECTRIC_IMP_CONTRACT_FIXTURE), + Err(VendorEventError::ContractRequired) + ); + } + + #[test] + fn rejects_empty_oversized_and_excess_event_payloads() { + let provider = ElectricImpProvider; + assert_eq!(provider.decode(b""), Err(VendorEventError::InvalidPayload)); + assert_eq!( + provider.decode(&vec![b' '; MAX_REMAINING_VENDOR_PAYLOAD_BYTES + 1]), + Err(VendorEventError::InvalidPayload) + ); + + let events = (0..=MAX_REMAINING_VENDOR_EVENTS) + .map(|sequence| { + format!( + r#"{{"sequence":{sequence},"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{{"rssi_dbm":-40.0}}}}"# + ) + }) + .collect::>() + .join(","); + let payload = format!(r#"{{"events":[{events}]}}"#); + assert_eq!( + provider.decode(payload.as_bytes()), + Err(VendorEventError::InvalidPayload) + ); + } + + #[test] + fn rejects_csi_or_cross_provider_metric_masquerading() { + let fake_csi = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"csi_real":1.0}}]}"#; + assert_eq!( + ElectricImpProvider.decode(fake_csi), + Err(VendorEventError::CapabilityMismatch) + ); + + let rf_metric_in_network_event = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":-40.0}}]}"#; + assert_eq!( + GoogleNestProvider.decode(rf_metric_in_network_event), + Err(VendorEventError::CapabilityMismatch) + ); + } + + #[test] + fn rejects_invalid_values_bounds_and_schema_extensions() { + let non_finite = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":1e999}}]}"#; + assert!(matches!( + ElectricImpProvider.decode(non_finite), + Err(VendorEventError::MalformedPayload(_)) | Err(VendorEventError::InvalidPayload) + )); + + let empty_metrics = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{}}]}"#; + assert_eq!( + ElectricImpProvider.decode(empty_metrics), + Err(VendorEventError::InvalidPayload) + ); + + let unknown_field = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":-40.0},"csi":[]}]}"#; + assert!(matches!( + ElectricImpProvider.decode(unknown_field), + Err(VendorEventError::MalformedPayload(_)) + )); + + let missing_provenance = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","metrics":{"rssi_dbm":-40.0}}]}"#; + assert!(matches!( + ElectricImpProvider.decode(missing_provenance), + Err(VendorEventError::MalformedPayload(_)) + )); + } + + #[test] + fn no_remaining_provider_claims_complex_csi_or_hardware_validation() { + let descriptors = [ + ElectricImpProvider.descriptor(), + RfSolutionsProvider.descriptor(), + LumaOpenWrtProvider.descriptor(), + GoogleNestProvider.descriptor(), + LinksysProvider.descriptor(), + WifigardenProvider.descriptor(), + ]; + for descriptor in descriptors { + descriptor.validate().expect("descriptor must be valid"); + assert!(!descriptor.hardware_validated); + assert!(!descriptor.capabilities.contains(&RfCapability::ComplexCsi)); + } + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/vendor_rf.rs b/v2/crates/wifi-densepose-sensing-server/src/vendor_rf.rs new file mode 100644 index 00000000..36b1ce00 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/vendor_rf.rs @@ -0,0 +1,104 @@ +//! ADR-270 provider registry and canonical event helpers. + +use serde::Serialize; +use wifi_densepose_hardware::vendor_rf::{ + ProviderDescriptor, VendorEventError, VendorId, VendorRfEvent, VendorRfProvider, +}; + +use crate::vendor_mist_netgear::{MistProvider, NetgearInsightProvider}; +use crate::vendor_origin_plume::{OriginAiProvider, PlumeOpenSyncProvider}; +use crate::vendor_remaining::{ + ElectricImpProvider, GoogleNestProvider, LinksysProvider, LumaOpenWrtProvider, + RfSolutionsProvider, WifigardenProvider, +}; + +pub fn descriptor_for(vendor: VendorId) -> ProviderDescriptor { + match vendor { + VendorId::OriginAi => OriginAiProvider.descriptor(), + VendorId::Plume => PlumeOpenSyncProvider.descriptor(), + VendorId::Mist => MistProvider.descriptor(), + VendorId::Netgear => NetgearInsightProvider.descriptor(), + VendorId::ElectricImp => ElectricImpProvider.descriptor(), + VendorId::RfSolutions => RfSolutionsProvider.descriptor(), + VendorId::Linksys => LinksysProvider.descriptor(), + VendorId::Luma => LumaOpenWrtProvider.descriptor(), + VendorId::GoogleNest => GoogleNestProvider.descriptor(), + VendorId::Wifigarden => WifigardenProvider.descriptor(), + } +} + +pub fn descriptors() -> Vec { + VendorId::ALL.into_iter().map(descriptor_for).collect() +} + +pub fn vendor_from_str(value: &str) -> Option { + VendorId::ALL + .into_iter() + .find(|vendor| vendor.as_str() == value) +} + +pub fn decode_provider( + vendor: VendorId, + payload: &[u8], +) -> Result, VendorEventError> { + match vendor { + VendorId::OriginAi => OriginAiProvider.decode(payload), + VendorId::Plume => PlumeOpenSyncProvider.decode(payload), + VendorId::Mist => MistProvider.decode(payload), + VendorId::Netgear => NetgearInsightProvider.decode(payload), + VendorId::ElectricImp => ElectricImpProvider.decode(payload), + VendorId::RfSolutions => RfSolutionsProvider.decode(payload), + VendorId::Linksys => LinksysProvider.decode(payload), + VendorId::Luma => LumaOpenWrtProvider.decode(payload), + VendorId::GoogleNest => GoogleNestProvider.decode(payload), + VendorId::Wifigarden => WifigardenProvider.decode(payload), + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct VendorEventSnapshot { + pub event_type: &'static str, + pub source: String, + #[serde(flatten)] + pub event: VendorRfEvent, +} + +impl VendorEventSnapshot { + pub fn from_event(event: VendorRfEvent) -> Result { + let descriptor = descriptor_for(event.vendor); + event.validate(&descriptor)?; + let provenance = if event.synthetic { "simulated" } else { "live" }; + Ok(Self { + event_type: "vendor_rf", + source: format!("vendor:{}:{provenance}", event.vendor.as_str()), + event, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_exactly_one_valid_descriptor_per_vendor() { + let values = descriptors(); + assert_eq!(values.len(), VendorId::ALL.len()); + for (vendor, descriptor) in VendorId::ALL.into_iter().zip(values) { + assert_eq!(descriptor.vendor, vendor); + descriptor.validate().unwrap(); + } + } + + #[test] + fn unsupported_providers_fail_closed() { + assert_eq!( + decode_provider(VendorId::Linksys, b"{}"), + Err(VendorEventError::Unsupported) + ); + assert_eq!( + decode_provider(VendorId::Wifigarden, b"{}"), + Err(VendorEventError::ContractRequired) + ); + } +}