feat: implement ADR-270 vendor provider beta (#1360)
This commit is contained in:
parent
76c80c33d7
commit
6ee1b55896
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<SocketAddr>,
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
#[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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<RfCapability>,
|
||||
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<String, f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<VendorRfEvent>, 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<qualcomm_csi::QualcommCsiSnapshot>,
|
||||
/// Instant of the last validated Qualcomm CSI UDP frame.
|
||||
last_qualcomm_frame: Option<std::time::Instant>,
|
||||
/// Latest bounded ADR-270 event per vendor. Complex CSI uses dedicated transports.
|
||||
latest_vendor_rf: BTreeMap<String, wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot>,
|
||||
tx: broadcast::Sender<String>,
|
||||
// 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<SharedState>) -> Json<serde_jso
|
|||
}
|
||||
}
|
||||
|
||||
async fn vendor_descriptors() -> Json<serde_json::Value> {
|
||||
Json(
|
||||
serde_json::to_value(wifi_densepose_sensing_server::vendor_rf::descriptors())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn latest_vendor_events(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
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<SharedState>,
|
||||
Path(vendor): Path<String>,
|
||||
) -> 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<SharedState>,
|
||||
Path(vendor): Path<String>,
|
||||
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::<wifi_densepose_hardware::vendor_rf::VendorRfEvent>(&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.
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<Self, VendorEventError> {
|
||||
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<String>,
|
||||
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<String>,
|
||||
token: SecretToken,
|
||||
) -> Result<VendorRequestConfig, VendorEventError> {
|
||||
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<String>,
|
||||
token: SecretToken,
|
||||
) -> Result<VendorRequestConfig, VendorEventError> {
|
||||
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<VendorRfEvent>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct MistProvider;
|
||||
|
||||
impl MistProvider {
|
||||
pub fn decode_page(&self, payload: &[u8]) -> Result<DecodedVendorPage, VendorEventError> {
|
||||
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<Vec<VendorRfEvent>, 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<DecodedVendorPage, VendorEventError> {
|
||||
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<Vec<VendorRfEvent>, 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<String, Value>, usize) -> Result<VendorRfEvent, VendorEventError>;
|
||||
|
||||
fn decode_page(
|
||||
payload: &[u8],
|
||||
vendor: VendorId,
|
||||
descriptor: ProviderDescriptor,
|
||||
parser: RecordParser,
|
||||
) -> Result<DecodedVendorPage, VendorEventError> {
|
||||
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<String>), 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<Value>,
|
||||
#[serde(default, alias = "rssi_dbm")]
|
||||
rssi: Option<f64>,
|
||||
#[serde(default)]
|
||||
snr: Option<f64>,
|
||||
#[serde(default)]
|
||||
channel: Option<f64>,
|
||||
#[serde(default)]
|
||||
x: Option<f64>,
|
||||
#[serde(default)]
|
||||
y: Option<f64>,
|
||||
#[serde(default)]
|
||||
site_id: Option<String>,
|
||||
#[serde(default)]
|
||||
ap_id: Option<String>,
|
||||
#[serde(default, alias = "event_type", alias = "type")]
|
||||
event: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_mist_record(
|
||||
object: &Map<String, Value>,
|
||||
index: usize,
|
||||
) -> Result<VendorRfEvent, VendorEventError> {
|
||||
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<Value>,
|
||||
#[serde(default, alias = "signalStrength", alias = "rssi_dbm")]
|
||||
rssi: Option<f64>,
|
||||
#[serde(default, alias = "signalToNoiseRatio")]
|
||||
snr: Option<f64>,
|
||||
#[serde(default)]
|
||||
channel: Option<f64>,
|
||||
#[serde(default, alias = "txRateMbps", alias = "tx_rate")]
|
||||
tx_rate_mbps: Option<f64>,
|
||||
#[serde(default, alias = "rxRateMbps", alias = "rx_rate")]
|
||||
rx_rate_mbps: Option<f64>,
|
||||
#[serde(default, alias = "locationId")]
|
||||
location_id: Option<String>,
|
||||
#[serde(default, alias = "accessPointId", alias = "apId")]
|
||||
ap_id: Option<String>,
|
||||
#[serde(default, alias = "ssidName")]
|
||||
ssid: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_netgear_record(
|
||||
object: &Map<String, Value>,
|
||||
index: usize,
|
||||
) -> Result<VendorRfEvent, VendorEventError> {
|
||||
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<u64, VendorEventError> {
|
||||
let value = value.ok_or(VendorEventError::InvalidPayload)?;
|
||||
if let Some(text) = value.as_str() {
|
||||
if let Ok(raw) = text.parse::<u64>() {
|
||||
return normalize_integer_timestamp(raw);
|
||||
}
|
||||
if let Ok(raw) = text.parse::<f64>() {
|
||||
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<u64, VendorEventError> {
|
||||
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<u64, VendorEventError> {
|
||||
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<String, f64>,
|
||||
name: &str,
|
||||
value: Option<f64>,
|
||||
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<String>]) -> Result<Option<String>, VendorEventError> {
|
||||
let label = parts
|
||||
.iter()
|
||||
.filter_map(Option::as_deref)
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String>,
|
||||
pub credential_env: Option<String>,
|
||||
pub body: Option<Value>,
|
||||
}
|
||||
|
||||
#[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<VendorRequest, VendorEventError> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<VendorRequest, VendorEventError> {
|
||||
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<VendorRfEvent> {
|
||||
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<Vec<VendorRfEvent>, 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<OriginEvent>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
#[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<VendorRfEvent, VendorEventError> {
|
||||
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<VendorRfEvent> {
|
||||
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<Vec<VendorRfEvent>, 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<OpenSyncObservation>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
#[serde(default)]
|
||||
tx_rate_mbps: Option<f64>,
|
||||
#[serde(default)]
|
||||
rx_rate_mbps: Option<f64>,
|
||||
#[serde(default)]
|
||||
clients: Option<u16>,
|
||||
}
|
||||
|
||||
impl OpenSyncObservation {
|
||||
fn into_vendor_event(
|
||||
self,
|
||||
descriptor: &ProviderDescriptor,
|
||||
) -> Result<VendorRfEvent, VendorEventError> {
|
||||
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<String, f64>,
|
||||
key: &str,
|
||||
value: Option<f64>,
|
||||
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<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, VendorEventError> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScalarEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ScalarEvent {
|
||||
sequence: u64,
|
||||
timestamp_us: u64,
|
||||
source_id: String,
|
||||
synthetic: bool,
|
||||
metrics: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<Vec<VendorRfEvent>, 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<P: VendorRfProvider>(
|
||||
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::<Vec<_>>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ProviderDescriptor> {
|
||||
VendorId::ALL.into_iter().map(descriptor_for).collect()
|
||||
}
|
||||
|
||||
pub fn vendor_from_str(value: &str) -> Option<VendorId> {
|
||||
VendorId::ALL
|
||||
.into_iter()
|
||||
.find(|vendor| vendor.as_str() == value)
|
||||
}
|
||||
|
||||
pub fn decode_provider(
|
||||
vendor: VendorId,
|
||||
payload: &[u8],
|
||||
) -> Result<Vec<VendorRfEvent>, 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<Self, VendorEventError> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue