From 6fc15b1cad1bb02aa2082a09562c996952f2fb3e Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Sat, 26 Sep 2026 11:36:18 +0200 Subject: [PATCH 01/10] feat(telemetry): carry optional twin geometry in simulation-start --- .github/workflows/ci-simulators.yml | 3 + CLAUDE.md | 1 + backend/telemetry/CLAUDE.md | 3 +- .../src/adapters/driven/simulators.rs | 85 +++++++++++-- backend/telemetry/src/kernel/fakes.rs | 3 +- backend/telemetry/src/kernel/ports.rs | 5 +- backend/telemetry/src/kernel/simulation.rs | 4 +- backend/telemetry/src/types/simulation.rs | 27 ++++ documentation/config/sensor-simulator.qd | 4 +- documentation/contributing/cicd.qd | 5 +- documentation/packages/telemetry-schema.qd | 7 +- documentation/services/telemetry.qd | 4 +- just/test.just | 4 + schemas/CLAUDE.md | 10 +- schemas/fixtures/simulation-start.json | 118 +++++++++++++++++- schemas/json/simulation-start.schema.json | 46 ++++++- .../tests/simulation_start_schema.rs | 4 +- simulators/ap-simulator/moon.yml | 15 ++- simulators/ap-simulator/pyproject.toml | 5 + simulators/ap-simulator/schemas.py | 58 ++++++++- .../ap-simulator/tests/test_start_contract.py | 29 +++++ simulators/ap-simulator/uv.lock | 63 ++++++++++ simulators/aq-simulator/moon.yml | 2 + simulators/aq-simulator/schemas.py | 33 +++++ .../aq-simulator/tests/test_start_contract.py | 3 +- .../sensor-simulator/src/models/signal.ts | 84 +++++++++++-- 26 files changed, 579 insertions(+), 46 deletions(-) create mode 100644 simulators/ap-simulator/tests/test_start_contract.py diff --git a/.github/workflows/ci-simulators.yml b/.github/workflows/ci-simulators.yml index cecc2537..aefdf03e 100644 --- a/.github/workflows/ci-simulators.yml +++ b/.github/workflows/ci-simulators.yml @@ -89,5 +89,8 @@ jobs: - name: Audit Dependencies run: uv audit + - name: Run Tests + run: uv run pytest + - name: Smoke test run: uv run python smoke.py diff --git a/CLAUDE.md b/CLAUDE.md index f0072455..13d80cea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ just test all just test # chat telemetry twin notification socket dashboard frontend # agent ap-collector claims-gateway provisioner registry tenancy # aq-simulator sensor-simulator (assert the ingest-batch fixture) + # ap-simulator (asserts the simulation-start fixture) just test -integration # throwaway DB/broker, composed, then torn down # Go (registry, tenancy): testcontainers behind `-tags=integration` just test integration # full backend acceptance suite diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 47477a08..9b5876e8 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -88,7 +88,8 @@ claims (body: `schemas/fixtures/simulation-start.json`). Every simulator is told empty list — empty means stop, so a removed sensor stops being simulated. Routers (no simulator claims them) and outdoor sensors are never sent. Calls run concurrently with a 5 s timeout, all are tried, then any failure is `502`. `Simulation::new` refuses a kind that is not a device, or -one claimed twice (doubled readings). No simulator configured → `404`. +one claimed twice (doubled readings). No simulator configured → `404`. Geometry (sensor +`position`, `rooms`) is optional in the body and omitted when absent — coordinates are twin's. **Registration**: telemetry consumes `building-registration-requested` and answers `building-registration-completed` (both from `twin_schema`). `maxTemperature` is read here diff --git a/backend/telemetry/src/adapters/driven/simulators.rs b/backend/telemetry/src/adapters/driven/simulators.rs index 20038ff3..d0d765a7 100644 --- a/backend/telemetry/src/adapters/driven/simulators.rs +++ b/backend/telemetry/src/adapters/driven/simulators.rs @@ -1,6 +1,8 @@ use crate::adapters::driven::dispatch::is_http; use crate::kernel::ports::{DispatchError, SimulatorControl}; -use crate::types::simulation::{SimulatedSensor, SimulatorSpec}; +use crate::types::simulation::{ + Coordinates, Dimensions, SimulatedRoom, SimulatedSensor, SimulatorSpec, +}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -21,6 +23,8 @@ struct Configured { struct StartBody<'a> { building_id: &'a str, sensors: Vec>, + #[serde(skip_serializing_if = "Vec::is_empty")] + rooms: Vec>, } #[derive(Serialize)] @@ -29,6 +33,16 @@ struct SensorBody<'a> { sensor_id: &'a str, sensor_type: &'a str, room_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + position: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RoomBody<'a> { + room_id: &'a str, + position: Coordinates, + dimensions: Dimensions, } #[derive(Serialize)] @@ -108,6 +122,7 @@ impl SimulatorControl for HttpSimulators { simulator: &str, building_id: &str, sensors: &[SimulatedSensor], + rooms: &[SimulatedRoom], ) -> Result<(), DispatchError> { let body = StartBody { building_id, @@ -117,6 +132,15 @@ impl SimulatorControl for HttpSimulators { sensor_id: &sensor.sensor_id, sensor_type: &sensor.sensor_type, room_id: &sensor.room_id, + position: sensor.position, + }) + .collect(), + rooms: rooms + .iter() + .map(|room| RoomBody { + room_id: &room.room_id, + position: room.position, + dimensions: room.dimensions, }) .collect(), }; @@ -149,7 +173,7 @@ impl SimulatorControl for HttpSimulators { mod tests { use super::*; use crate::kernel::ports::{DispatchError, SimulatorControl}; - use crate::types::simulation::SimulatedSensor; + use crate::types::simulation::{Coordinates, Dimensions, SimulatedRoom, SimulatedSensor}; use serde_json::{Value, json}; use std::collections::HashMap; use std::time::Duration; @@ -173,10 +197,16 @@ mod tests { .clone() } - #[tokio::test] - async fn start_posts_the_body_the_fixture_pins() { - let body = fixture_case("sensor-simulator"); - let sensors: Vec = body["sensors"] + fn coordinates(value: &Value) -> Coordinates { + Coordinates { + x: value["x"].as_f64().unwrap(), + y: value["y"].as_f64().unwrap(), + z: value["z"].as_f64().unwrap(), + } + } + + fn sensors_of(body: &Value) -> Vec { + body["sensors"] .as_array() .unwrap() .iter() @@ -184,8 +214,30 @@ mod tests { sensor_id: s["sensorId"].as_str().unwrap().to_owned(), sensor_type: s["sensorType"].as_str().unwrap().to_owned(), room_id: s["roomId"].as_str().unwrap().to_owned(), + position: s.get("position").map(coordinates), }) - .collect(); + .collect() + } + + fn rooms_of(body: &Value) -> Vec { + body.get("rooms") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| SimulatedRoom { + room_id: r["roomId"].as_str().unwrap().to_owned(), + position: coordinates(&r["position"]), + dimensions: Dimensions { + width: r["dimensions"]["width"].as_f64().unwrap(), + height: r["dimensions"]["height"].as_f64().unwrap(), + depth: r["dimensions"]["depth"].as_f64().unwrap(), + }, + }) + .collect() + } + + async fn assert_start_posts(consumer: &str) { + let body = fixture_case(consumer); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/control/start")) @@ -196,11 +248,26 @@ mod tests { .await; simulators(&server, Duration::from_secs(5)) - .start("sim", body["buildingId"].as_str().unwrap(), &sensors) + .start( + "sim", + body["buildingId"].as_str().unwrap(), + &sensors_of(&body), + &rooms_of(&body), + ) .await .unwrap(); } + #[tokio::test] + async fn start_posts_the_body_the_fixture_pins() { + assert_start_posts("sensor-simulator").await; + } + + #[tokio::test] + async fn start_posts_positions_and_rooms_as_the_fixture_pins() { + assert_start_posts("ap-simulator").await; + } + #[tokio::test] async fn an_empty_start_still_sends_the_list() { let body = fixture_case("any"); @@ -214,7 +281,7 @@ mod tests { .await; simulators(&server, Duration::from_secs(5)) - .start("sim", body["buildingId"].as_str().unwrap(), &[]) + .start("sim", body["buildingId"].as_str().unwrap(), &[], &[]) .await .unwrap(); } diff --git a/backend/telemetry/src/kernel/fakes.rs b/backend/telemetry/src/kernel/fakes.rs index 21917e3a..8397bf4c 100644 --- a/backend/telemetry/src/kernel/fakes.rs +++ b/backend/telemetry/src/kernel/fakes.rs @@ -10,7 +10,7 @@ use crate::types::plugin::{ use crate::types::query::Bucket; use crate::types::reading::Reading; use crate::types::sensor::{Command, Sensor, SensorChanges}; -use crate::types::simulation::SimulatedSensor; +use crate::types::simulation::{SimulatedRoom, SimulatedSensor}; use crate::types::threshold::{Bounds, RoomTemperatureLimit, TemperatureLimits}; use async_trait::async_trait; use serde_json::{Map, Value}; @@ -603,6 +603,7 @@ impl SimulatorControl for FakeSimulators { simulator: &str, building_id: &str, sensors: &[SimulatedSensor], + _rooms: &[SimulatedRoom], ) -> Result<(), DispatchError> { self.answer(simulator)?; self.started.lock().unwrap().push(( diff --git a/backend/telemetry/src/kernel/ports.rs b/backend/telemetry/src/kernel/ports.rs index 572969c9..17233de6 100644 --- a/backend/telemetry/src/kernel/ports.rs +++ b/backend/telemetry/src/kernel/ports.rs @@ -3,7 +3,7 @@ use crate::types::event::{AlertPayload, TelemetryEvent}; use crate::types::query::Bucket; use crate::types::reading::Reading; use crate::types::sensor::{Command, Sensor, SensorChanges}; -use crate::types::simulation::SimulatedSensor; +use crate::types::simulation::{SimulatedRoom, SimulatedSensor}; use crate::types::threshold::{Bounds, TemperatureLimits}; use async_trait::async_trait; @@ -86,12 +86,13 @@ pub trait ActionDispatch: Send + Sync { /// The simulators standing in for a building's devices, addressed by their configured name. #[async_trait] pub trait SimulatorControl: Send + Sync { - /// Replaces everything the simulator held for the building; an empty list stops it. + /// Replaces everything the simulator held for the building; no sensors stops it, no rooms sends no geometry. async fn start( &self, simulator: &str, building_id: &str, sensors: &[SimulatedSensor], + rooms: &[SimulatedRoom], ) -> Result<(), DispatchError>; async fn stop(&self, simulator: &str, building_id: &str) -> Result<(), DispatchError>; async fn is_running(&self, simulator: &str, building_id: &str) -> Result; diff --git a/backend/telemetry/src/kernel/simulation.rs b/backend/telemetry/src/kernel/simulation.rs index 53626aaf..8a1acc2b 100644 --- a/backend/telemetry/src/kernel/simulation.rs +++ b/backend/telemetry/src/kernel/simulation.rs @@ -74,6 +74,7 @@ impl Simulation { sensor_id: sensor.sensor_id.clone(), sensor_type: sensor.sensor_type.clone(), room_id: room_id.clone(), + position: None, }), _ => plan.unsimulated.push(sensor.sensor_id.clone()), } @@ -93,7 +94,7 @@ impl Simulation { let results = join_all(plan.by_simulator.iter().map(|(name, sensors)| async move { ( name.as_str(), - self.control.start(name, building_id, sensors).await, + self.control.start(name, building_id, sensors, &[]).await, ) })) .await; @@ -240,6 +241,7 @@ mod tests { sensor_id: id.to_owned(), sensor_type: kind.to_owned(), room_id: room.to_owned(), + position: None, } } diff --git a/backend/telemetry/src/types/simulation.rs b/backend/telemetry/src/types/simulation.rs index ef633dfe..00792198 100644 --- a/backend/telemetry/src/types/simulation.rs +++ b/backend/telemetry/src/types/simulation.rs @@ -1,11 +1,38 @@ +use serde::Serialize; use std::collections::BTreeMap; +/// A point in the twin's frame, y up. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct Coordinates { + pub x: f64, + pub y: f64, + pub z: f64, +} + +/// A room's extent: width along x, height up y, depth along z. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct Dimensions { + pub width: f64, + pub height: f64, + pub depth: f64, +} + /// A sensor as a simulator sees it: always in a room, because readings are room-keyed. #[derive(Debug, Clone, PartialEq)] pub struct SimulatedSensor { pub sensor_id: String, pub sensor_type: String, pub room_id: String, + /// `None` until the sensor is placed in the twin. + pub position: Option, +} + +/// A room as a box; `position` is its footprint centre at floor level. +#[derive(Debug, Clone, PartialEq)] +pub struct SimulatedRoom { + pub room_id: String, + pub position: Coordinates, + pub dimensions: Dimensions, } /// A simulator and the device kinds it produces readings for. diff --git a/documentation/config/sensor-simulator.qd b/documentation/config/sensor-simulator.qd index 71e99619..8f610b61 100644 --- a/documentation/config/sensor-simulator.qd +++ b/documentation/config/sensor-simulator.qd @@ -75,11 +75,11 @@ Routers have no simulator: `ap-simulator` fakes access points for `devices/ap-co ## Control API -Called by telemetry only. The start body is pinned by `schemas/fixtures/simulation-start.json`, which both simulators' tests assert. +Called by telemetry only. The start body is pinned by `schemas/fixtures/simulation-start.json`, which every simulator's tests assert. | Method | Endpoint | Body | Effect | |---|---|---|---| -| `POST` | `/control/start` | `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }` | Replaces what the building simulates. Empty `sensors` stops it. Any other key (e.g. the old `targetUrl`) is refused. | +| `POST` | `/control/start` | `{ buildingId, sensors: [{ sensorId, sensorType, roomId, position? }], rooms? }` | Replaces what the building simulates. Empty `sensors` stops it. Geometry is optional; a simulator that does not need it ignores it. Any other key (e.g. the old `targetUrl`) is refused. | | `POST` | `/control/stop` | `{ buildingId }` | Stops the building. Stopping one it never simulated succeeds. | | `GET` | `/control/status?buildingId=X` | — | `{ isRunning }` | diff --git a/documentation/contributing/cicd.qd b/documentation/contributing/cicd.qd index 6a06a052..f63cdda3 100644 --- a/documentation/contributing/cicd.qd +++ b/documentation/contributing/cicd.qd @@ -179,8 +179,11 @@ The two device simulators ship as GHCR images but are not application services, - - `aq-simulator` - Python 3.12 - `ruff check` (default rules: undefined names, unused imports), `pyright`, `uv audit`, then `pytest` — which asserts the ingest-batch and simulation-start fixtures. + - - `ap-simulator` + - Python 3.12 + - `ruff check`, `pyright`, `uv audit`, `pytest` — which asserts the simulation-start fixture — then `smoke.py`. -Both were previously ungated despite shipping images until this workflow gave them a real bucket. +sensor-simulator and aq-simulator were previously ungated despite shipping images until this workflow gave them a real bucket. --- diff --git a/documentation/packages/telemetry-schema.qd b/documentation/packages/telemetry-schema.qd index 2064bafb..3490a3a9 100644 --- a/documentation/packages/telemetry-schema.qd +++ b/documentation/packages/telemetry-schema.qd @@ -90,12 +90,15 @@ Two telemetry shapes have no type here: one side walks raw JSON or is the only R | Fixture | Direction | Built by | Parsed by | |---|---|---|---| | `ingest-batch.json` | device → telemetry `/ingest` | ap-collector, aq-simulator, sensor-simulator | telemetry | -| `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator | +| `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator, ap-simulator | -`simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }`. +`simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId, position? }], rooms? }`. - `sensors` replaces what the simulator held for the building; empty = stop simulating it. - `roomId` is always a room: readings are room-keyed, so outdoor sensors are never sent. +- `position` (`{ x, y, z }`, the twin's frame, y up): where the sensor is placed; absent when unplaced. +- `rooms` (`[{ roomId, position, dimensions: { width, height, depth } }]`): every room of the building, sensors or not. Room `position` = footprint centre at floor level. Absent = no geometry; never empty; every side > 0. +- Every parser accepts geometry, so one body fits every simulator. ap-simulator needs it to derive RSSI from distance. - No `targetUrl`: a simulator posts to its own `TELEMETRY_INGEST_URL`, so no caller can redirect its signed readings. The schema refuses extra keys, so the old body fails. --- diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index 354aad99..64a0c905 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -178,7 +178,9 @@ Positions are **not** stored here: they are physical layout and belong to digita - - Routers (no simulator claims them) and outdoor sensors are never sent - Nothing simulates a router; readings are room-keyed, so an outdoor reading has nowhere to go. - - Body pinned by `schemas/fixtures/simulation-start.json`; no `targetUrl` - - Two simulators in two languages parse it; a simulator posts to its own `TELEMETRY_INGEST_URL`. + - Three simulators in two languages parse it; a simulator posts to its own `TELEMETRY_INGEST_URL`. + - - Sensor `position` and building `rooms` are optional; omitted when telemetry has none + - Coordinates are the twin's, not telemetry's. An empty `rooms` is never sent: it would claim a building with no rooms. - - Calls run concurrently, 5 s timeout each; all are tried, then any failure is `502` - One dead simulator must not leave the others stale. A retry is safe: start replaces. - - Status is `running` when any simulator runs the building; an unreachable one counts as not running diff --git a/just/test.just b/just/test.just index 156c0584..6520a833 100644 --- a/just/test.just +++ b/just/test.just @@ -102,6 +102,10 @@ agent: ap-collector: mise exec -- moon run ap-collector:test +[doc("Asserts the AP simulator still parses the start body telemetry sends.")] +ap-simulator: + mise exec -- moon run ap-simulator:test + [doc("Asserts the air-quality simulator still builds the batch shape telemetry pins.")] aq-simulator: mise exec -- moon run aq-simulator:test diff --git a/schemas/CLAUDE.md b/schemas/CLAUDE.md index 3c4d129d..d226cc8e 100644 --- a/schemas/CLAUDE.md +++ b/schemas/CLAUDE.md @@ -16,9 +16,11 @@ sensor-simulator in three languages, and a rename in any of them stops readings nothing failing to compile. **So does the mirror: one producer, several parsers.** `fixtures/simulation-start.json` is -built by telemetry in Rust and parsed by sensor-simulator and aq-simulator. Telemetry is its -only Rust party, so its serialiser stays in telemetry and the crate holds no type. Each case -names its `consumer` (`sensor-simulator`, `aq-simulator`, `any`). +built by telemetry in Rust and parsed by sensor-simulator, aq-simulator and ap-simulator. +Telemetry is its only Rust party, so its serialiser stays in telemetry and the crate holds no +type. Each case names its `consumer` (`sensor-simulator`, `aq-simulator`, `ap-simulator`, `any`). +Geometry (`position`, `rooms`) is optional and every parser accepts it, so telemetry can send one +shape to all of them. **The Cedar bundle is the exception — it stays in `backend/libs/auth-policy`.** Its fixture holds golden *decisions*, not a wire shape, is meaningless without `policy.cedar` and @@ -32,7 +34,7 @@ them has a wire quirk a generator would flatten. | Layer | Catches | Where | |---|---|---| | Rust path deps | Rust↔Rust drift, at compile time | `Cargo.toml` `path = "../../schemas/…"` | -| `fixtures/*.json` | one language's parser disagreeing with the others | Go `conformance_test.go`, Rust `tests/*conformance*.rs`, Python `tests/unit/test_*_conformance.py`, TS `frontend/src/utils/**/*.spec.ts`; `simulation-start` by sensor-simulator's `__tests__/startContract.test.ts` and aq-simulator's `tests/test_start_contract.py` (every case parses, every rejection is refused) | +| `fixtures/*.json` | one language's parser disagreeing with the others | Go `conformance_test.go`, Rust `tests/*conformance*.rs`, Python `tests/unit/test_*_conformance.py`, TS `frontend/src/utils/**/*.spec.ts`; `simulation-start` by sensor-simulator's `__tests__/startContract.test.ts` and aq-simulator's and ap-simulator's `tests/test_start_contract.py` (every case parses, every rejection is refused) | | `json/*.schema.json` | a fixture drifting from the written contract | `twin-schema/tests/building_schema.rs`, `claims-schema/tests/{tenancy_domains,chat_conversation}_schema.rs`, `telemetry-schema/tests/{metric_contract,ingest_batch,simulation_start,telemetry_envelope}_schema.rs`, `notification-schema/tests/notification{,_preferences}_schema.rs`, agent's `test_schema_conformance.py` (claims, building, agent-stream) | | the served bytes | a producer drifting from the fixture both sides agreed on | telemetry `tests/api.rs` compares `/contracts` against `fixtures/metric-contract.json`, and posts every `fixtures/ingest-batch.json` case; notification's `controllers.rs` posts every `fixtures/notification-preferences.json` request and rejection | | the producer's own output | a hand-built payload drifting from the fixture | agent's `test_stream_conformance.py` runs `stream_answer` and compares the frames; chat's `agent.rs` tests replay them through the real `SseReader`; chat's `conversation.rs` round-trips every `fixtures/chat-conversation.json` shape through its own types; telemetry's `redis_fanout.rs` publishes every `fixtures/telemetry-envelope.json` tick byte for byte; notification's `alerts.rs` publishes a breach and compares it to `fixtures/notification.json`; socket's `relay.rs` routes every case and skips every rejection; telemetry's `simulators.rs` posts the `fixtures/simulation-start.json` bodies and wiremock matches them exactly | diff --git a/schemas/fixtures/simulation-start.json b/schemas/fixtures/simulation-start.json index 00014599..b79d8725 100644 --- a/schemas/fixtures/simulation-start.json +++ b/schemas/fixtures/simulation-start.json @@ -1,10 +1,12 @@ { - "description": "The body telemetry POSTs to a simulator's /control/start. Built by telemetry from the building's sensors; parsed by sensor-simulator (TypeScript) and aq-simulator (Python). Each simulator receives only the sensors whose device kind it simulates.", + "description": "The body telemetry POSTs to a simulator's /control/start. Built by telemetry from the building's sensors; parsed by sensor-simulator (TypeScript), aq-simulator and ap-simulator (Python). Each simulator receives only the sensors whose device kind it simulates.", "rules": { "sensors": "Replaces everything the simulator held for the building. May be empty: the simulator then stops simulating the building, so a sensor removed in the twin stops producing readings.", "roomId": "Always a room of the building. Readings are room-keyed, so telemetry never sends an outdoor sensor.", "sensorType": "A device kind from telemetry's catalog (`GET /telemetry/devices`), never a metric. A simulator ignores a kind it does not simulate.", - "targetUrl": "Absent on purpose. A simulator reads where to post readings from its own TELEMETRY_INGEST_URL, so no caller can redirect its signed batches." + "targetUrl": "Absent on purpose. A simulator reads where to post readings from its own TELEMETRY_INGEST_URL, so no caller can redirect its signed batches.", + "position": "Optional. Where the twin places the sensor, in the twin's frame (y up). Absent when the sensor is not placed yet.", + "rooms": "Optional. Every room of the building, including those with no sensor: simulated people walk through rooms nobody measures. Room `position` is the footprint centre at floor level; `dimensions` extend width along x, height up y, depth along z. Absent means the caller has no geometry; never empty." }, "cases": [ { @@ -45,6 +47,65 @@ ] } }, + { + "name": "two placed routers in a building of three rooms, one room without a router", + "consumer": "ap-simulator", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "sensorType": "router", + "roomId": "room-lab-2", + "position": { "x": 3.5, "y": 2.6, "z": -1.0 } + }, + { + "sensorId": "f7081920-3b4c-4d5e-9f60-718293a4b5c6", + "sensorType": "router", + "roomId": "room-aula-magna", + "position": { "x": 14.0, "y": 3.5, "z": 0.5 } + } + ], + "rooms": [ + { + "roomId": "room-lab-2", + "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, + "dimensions": { "width": 8.0, "height": 3.0, "depth": 6.0 } + }, + { + "roomId": "room-aula-magna", + "position": { "x": 12.0, "y": 0.0, "z": 0.0 }, + "dimensions": { "width": 12.0, "height": 4.0, "depth": 10.0 } + }, + { + "roomId": "room-corridor", + "position": { "x": 7.0, "y": 0.0, "z": 6.0 }, + "dimensions": { "width": 22.0, "height": 3.0, "depth": 2.0 } + } + ] + } + }, + { + "name": "a router not placed yet carries no position", + "consumer": "ap-simulator", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "sensorType": "router", + "roomId": "room-lab-2" + } + ], + "rooms": [ + { + "roomId": "room-lab-2", + "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, + "dimensions": { "width": 8.0, "height": 3.0, "depth": 6.0 } + } + ] + } + }, { "name": "no sensors -- the building stops being simulated", "consumer": "any", @@ -84,6 +145,59 @@ "sensors": [], "targetUrl": "http://localhost/telemetry/" } + }, + { + "name": "a position missing a coordinate", + "reason": "position: needs x, y and z", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "sensorType": "router", + "roomId": "room-lab-2", + "position": { "x": 3.5, "z": -1.0 } + } + ] + } + }, + { + "name": "an empty rooms list", + "reason": "rooms: absent means no geometry; an empty list would claim a building with no rooms", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [], + "rooms": [] + } + }, + { + "name": "a room with no dimensions", + "reason": "rooms: a room is a box, so it needs position and dimensions", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [], + "rooms": [ + { + "roomId": "room-lab-2", + "position": { "x": 2.0, "y": 0.0, "z": 0.0 } + } + ] + } + }, + { + "name": "a room with no width", + "reason": "dimensions: every side must be greater than zero", + "body": { + "buildingId": "bldg-3f2b4c5d", + "sensors": [], + "rooms": [ + { + "roomId": "room-lab-2", + "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, + "dimensions": { "width": 0.0, "height": 3.0, "depth": 6.0 } + } + ] + } } ] } diff --git a/schemas/json/simulation-start.schema.json b/schemas/json/simulation-start.schema.json index 81d04831..890e1d1f 100644 --- a/schemas/json/simulation-start.schema.json +++ b/schemas/json/simulation-start.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://crowdvision.dev/schemas/simulation-start.schema.json", "title": "SimulationStart", - "description": "The body telemetry POSTs to a simulator's /control/start: the building and the sensors that simulator is to simulate, replacing whatever it held for the building. Built by telemetry in Rust, parsed by sensor-simulator in TypeScript and aq-simulator in Python.", + "description": "The body telemetry POSTs to a simulator's /control/start: the building and the sensors that simulator is to simulate, replacing whatever it held for the building. Built by telemetry in Rust, parsed by sensor-simulator in TypeScript and aq-simulator and ap-simulator in Python.", "type": "object", "required": ["buildingId", "sensors"], "additionalProperties": false, @@ -13,6 +13,12 @@ "type": "array", "description": "May be empty: the simulator then stops simulating the building.", "items": { "$ref": "#/$defs/sensor" } + }, + "rooms": { + "type": "array", + "minItems": 1, + "description": "Optional building geometry: every room, including rooms with no sensor. Absent means the caller has none; never empty.", + "items": { "$ref": "#/$defs/room" } } }, "$defs": { @@ -31,6 +37,44 @@ "type": "string", "minLength": 1, "description": "Readings are room-keyed, so an outdoor sensor is never sent." + }, + "position": { + "$ref": "#/$defs/coordinates", + "description": "Where the twin places the sensor; absent when it is not placed yet." + } + } + }, + "coordinates": { + "type": "object", + "required": ["x", "y", "z"], + "additionalProperties": false, + "description": "The twin's frame: y up.", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "z": { "type": "number" } + } + }, + "room": { + "type": "object", + "required": ["roomId", "position", "dimensions"], + "additionalProperties": false, + "properties": { + "roomId": { "type": "string", "minLength": 1 }, + "position": { + "$ref": "#/$defs/coordinates", + "description": "The footprint centre at floor level." + }, + "dimensions": { + "type": "object", + "required": ["width", "height", "depth"], + "additionalProperties": false, + "description": "Width along x, height up y, depth along z.", + "properties": { + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 }, + "depth": { "type": "number", "exclusiveMinimum": 0 } + } } } } diff --git a/schemas/telemetry-schema/tests/simulation_start_schema.rs b/schemas/telemetry-schema/tests/simulation_start_schema.rs index 0c85c94c..47bc795d 100644 --- a/schemas/telemetry-schema/tests/simulation_start_schema.rs +++ b/schemas/telemetry-schema/tests/simulation_start_schema.rs @@ -1,7 +1,7 @@ const FIXTURE: &str = include_str!("../../fixtures/simulation-start.json"); const SCHEMA: &str = include_str!("../../json/simulation-start.schema.json"); -// Telemetry builds this body and two simulators parse it in two other languages, so no +// Telemetry builds this body and three simulators parse it in two other languages, so no // compiler sees all three. This crate hosts the check because it already carries `jsonschema`. #[test] fn every_start_the_fixture_accepts_matches_the_schema() { @@ -37,7 +37,7 @@ fn every_case_names_who_parses_it() { for case in cases("cases") { let consumer = case["consumer"].as_str().unwrap_or_default(); assert!( - ["sensor-simulator", "aq-simulator", "any"].contains(&consumer), + ["sensor-simulator", "aq-simulator", "ap-simulator", "any"].contains(&consumer), "{}: unknown consumer {consumer:?}", case["name"] ); diff --git a/simulators/ap-simulator/moon.yml b/simulators/ap-simulator/moon.yml index 403c363c..2f7be37b 100644 --- a/simulators/ap-simulator/moon.yml +++ b/simulators/ap-simulator/moon.yml @@ -1,15 +1,18 @@ # `deps` / `audit` / `relock` are inherited from .moon/tasks/python.yml. The inherited # `test` / `lint` shell out to npm, which this package cannot do because the root # .gitignore ignores every package.json; they call uv directly instead, matching CI. -# No unit-test suite here yet, so `test` is not inherited. language: 'python' -workspace: - inheritedTasks: - exclude: - - 'test' - tasks: + test: + script: 'uv run pytest' + inputs: + - '*.py' + - 'tests/**/*' + - 'pyproject.toml' + # Contract tests read the shared fixtures; without these a fixture edit replays a cached pass. + - '/schemas/fixtures/**/*' + lint: script: 'uv run ruff check . && uv run pyright' inputs: diff --git a/simulators/ap-simulator/pyproject.toml b/simulators/ap-simulator/pyproject.toml index 727d250b..8a4602cc 100644 --- a/simulators/ap-simulator/pyproject.toml +++ b/simulators/ap-simulator/pyproject.toml @@ -11,10 +11,15 @@ dependencies = [ [dependency-groups] dev = [ + "pytest>=8.3.0", "ruff>=0.8.0", "pyright>=1.1.390", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/simulators/ap-simulator/schemas.py b/simulators/ap-simulator/schemas.py index 99958e4a..1232f510 100644 --- a/simulators/ap-simulator/schemas.py +++ b/simulators/ap-simulator/schemas.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class ApConfig(BaseModel): @@ -76,3 +76,57 @@ def exactly_one(self) -> ScenarioRequest: if (self.preset is None) == (self.config is None): raise ValueError("give exactly one of `preset` or `config`") return self + + +NonEmpty = Annotated[str, Field(min_length=1)] +Finite = Annotated[float, Field(allow_inf_nan=False)] +Side = Annotated[float, Field(gt=0, allow_inf_nan=False)] + + +class Coordinates(BaseModel): + """A point in the twin's frame, y up.""" + + model_config = ConfigDict(extra="forbid") + + x: Finite + y: Finite + z: Finite + + +class Dimensions(BaseModel): + model_config = ConfigDict(extra="forbid") + + width: Side + height: Side + depth: Side + + +class Room(BaseModel): + """A room as a box; `position` is its footprint centre at floor level.""" + + model_config = ConfigDict(extra="forbid") + + roomId: NonEmpty + position: Coordinates + dimensions: Dimensions + + +class SimulatedSensor(BaseModel): + model_config = ConfigDict(extra="forbid") + + sensorId: NonEmpty + sensorType: NonEmpty + roomId: NonEmpty + position: Coordinates | None = None + + +class BuildingStart(BaseModel): + """The body telemetry POSTs to /control/start; an empty `sensors` stops the building.""" + + # An extra key is refused so the old `targetUrl` can never choose where readings go. + model_config = ConfigDict(extra="forbid") + + buildingId: NonEmpty + sensors: list[SimulatedSensor] + # Absent means no geometry; an empty list would claim a building with no rooms. + rooms: Annotated[list[Room], Field(min_length=1)] | None = None diff --git a/simulators/ap-simulator/tests/test_start_contract.py b/simulators/ap-simulator/tests/test_start_contract.py new file mode 100644 index 00000000..70176247 --- /dev/null +++ b/simulators/ap-simulator/tests/test_start_contract.py @@ -0,0 +1,29 @@ +"""Telemetry builds the start body in Rust and this simulator parses it with Pydantic. +`schemas/fixtures/simulation-start.json` is the only thing holding the two to one shape. +""" + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from schemas import BuildingStart + +_FIXTURE = json.loads( + ( + Path(__file__).resolve().parents[3] / "schemas" / "fixtures" / "simulation-start.json" + ).read_text() +) + + +@pytest.mark.parametrize("case", _FIXTURE["cases"], ids=lambda c: c["name"]) +def test_every_start_the_fixture_accepts_validates(case): + start = BuildingStart.model_validate(case["body"]) + assert start.model_dump(exclude_none=True) == case["body"] + + +@pytest.mark.parametrize("case", _FIXTURE["rejected"], ids=lambda c: c["name"]) +def test_every_start_the_fixture_rejects_is_refused(case): + with pytest.raises(ValidationError): + BuildingStart.model_validate(case["body"]) diff --git a/simulators/ap-simulator/uv.lock b/simulators/ap-simulator/uv.lock index 3752dfe2..71553d57 100644 --- a/simulators/ap-simulator/uv.lock +++ b/simulators/ap-simulator/uv.lock @@ -46,6 +46,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -59,6 +60,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pyright", specifier = ">=1.1.390" }, + { name = "pytest", specifier = ">=8.3.0" }, { name = "ruff", specifier = ">=0.8.0" }, ] @@ -71,6 +73,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -105,6 +116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -114,6 +134,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -204,6 +242,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyright" version = "1.1.411" @@ -217,6 +264,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "ruff" version = "0.16.6" diff --git a/simulators/aq-simulator/moon.yml b/simulators/aq-simulator/moon.yml index 74686859..2f7be37b 100644 --- a/simulators/aq-simulator/moon.yml +++ b/simulators/aq-simulator/moon.yml @@ -10,6 +10,8 @@ tasks: - '*.py' - 'tests/**/*' - 'pyproject.toml' + # Contract tests read the shared fixtures; without these a fixture edit replays a cached pass. + - '/schemas/fixtures/**/*' lint: script: 'uv run ruff check . && uv run pyright' diff --git a/simulators/aq-simulator/schemas.py b/simulators/aq-simulator/schemas.py index f7f84939..40d9ca4b 100644 --- a/simulators/aq-simulator/schemas.py +++ b/simulators/aq-simulator/schemas.py @@ -13,6 +13,36 @@ def _strip_newlines(value: str) -> str: NonEmpty = Annotated[str, Field(min_length=1)] +Finite = Annotated[float, Field(allow_inf_nan=False)] +Side = Annotated[float, Field(gt=0, allow_inf_nan=False)] + + +class Coordinates(BaseModel): + """A point in the twin's frame, y up.""" + + model_config = ConfigDict(extra="forbid") + + x: Finite + y: Finite + z: Finite + + +class Dimensions(BaseModel): + model_config = ConfigDict(extra="forbid") + + width: Side + height: Side + depth: Side + + +class Room(BaseModel): + """A room as a box; `position` is its footprint centre at floor level.""" + + model_config = ConfigDict(extra="forbid") + + roomId: NonEmpty + position: Coordinates + dimensions: Dimensions class SimulatedSensor(BaseModel): @@ -21,6 +51,7 @@ class SimulatedSensor(BaseModel): sensorId: NonEmpty sensorType: NonEmpty roomId: NonEmpty + position: Coordinates | None = None @field_validator("sensorId", "sensorType", "roomId") @classmethod @@ -36,6 +67,8 @@ class BuildingConfig(BaseModel): buildingId: NonEmpty sensors: list[SimulatedSensor] + # Absent means no geometry; an empty list would claim a building with no rooms. + rooms: Annotated[list[Room], Field(min_length=1)] | None = None scenario: Scenario = Scenario.CLEAN_INDOOR interval_seconds: float = 10.0 diff --git a/simulators/aq-simulator/tests/test_start_contract.py b/simulators/aq-simulator/tests/test_start_contract.py index 854881f0..003365c3 100644 --- a/simulators/aq-simulator/tests/test_start_contract.py +++ b/simulators/aq-simulator/tests/test_start_contract.py @@ -29,7 +29,8 @@ def _body(consumer: str) -> dict: def test_every_start_the_fixture_accepts_validates(case): config = BuildingConfig.model_validate(case["body"]) assert config.buildingId == case["body"]["buildingId"] - assert [s.model_dump() for s in config.sensors] == case["body"]["sensors"] + assert [s.model_dump(exclude_none=True) for s in config.sensors] == case["body"]["sensors"] + assert [r.model_dump() for r in config.rooms or []] == case["body"].get("rooms", []) @pytest.mark.parametrize("case", _FIXTURE["rejected"], ids=lambda c: c["name"]) diff --git a/simulators/sensor-simulator/src/models/signal.ts b/simulators/sensor-simulator/src/models/signal.ts index dbdb0eb0..e79d33c6 100644 --- a/simulators/sensor-simulator/src/models/signal.ts +++ b/simulators/sensor-simulator/src/models/signal.ts @@ -13,15 +13,29 @@ export const mySimulationBuildings: SimulationBuildings = { }, }; +export interface ICoordinates { + x: number; + y: number; + z: number; +} + export interface ISimulatedSensor { sensorId: string; sensorType: string; roomId: string; + position?: ICoordinates; +} + +export interface IRoom { + roomId: string; + position: ICoordinates; + dimensions: { width: number; height: number; depth: number }; } export interface IBuilding { buildingId: string; sensors: ISimulatedSensor[]; + rooms?: IRoom[]; } const isRecord = (value: unknown): value is Record => @@ -33,25 +47,74 @@ const onlyKeys = (value: Record, keys: string[]) => const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.length > 0; +const numbers = ( + value: unknown, + keys: readonly K[], + what: string, +): Record => { + if ( + !isRecord(value) || + !onlyKeys(value, [...keys]) || + !keys.every((key) => Number.isFinite(value[key])) + ) { + throw new Error(`${what} must carry exactly ${keys.join(", ")} as numbers`); + } + return value as Record; +}; + +const parsePosition = (value: unknown): ICoordinates => + numbers(value, ["x", "y", "z"] as const, "a position"); + const parseSensor = (value: unknown): ISimulatedSensor => { - const keys = ["sensorId", "sensorType", "roomId"]; - if (!isRecord(value) || !onlyKeys(value, keys)) { - throw new Error(`a sensor must carry exactly ${keys.join(", ")}`); + if ( + !isRecord(value) || + !onlyKeys(value, ["sensorId", "sensorType", "roomId", "position"]) + ) { + throw new Error( + "a sensor must carry sensorId, sensorType, roomId and an optional position", + ); } - const { sensorId, sensorType, roomId } = value; + const { sensorId, sensorType, roomId, position } = value; if (!nonEmpty(sensorId) || !nonEmpty(sensorType) || !nonEmpty(roomId)) { throw new Error( "a sensor's sensorId, sensorType and roomId must be non-empty strings", ); } - return { sensorId, sensorType, roomId }; + return position === undefined + ? { sensorId, sensorType, roomId } + : { sensorId, sensorType, roomId, position: parsePosition(position) }; +}; + +const parseRoom = (value: unknown): IRoom => { + if ( + !isRecord(value) || + !onlyKeys(value, ["roomId", "position", "dimensions"]) || + !nonEmpty(value["roomId"]) + ) { + throw new Error("a room must carry roomId, position and dimensions"); + } + const dimensions = numbers( + value["dimensions"], + ["width", "height", "depth"] as const, + "a room's dimensions", + ); + if (Object.values(dimensions).some((side) => side <= 0)) { + throw new Error("every side of a room must be greater than zero"); + } + return { + roomId: value["roomId"], + position: parsePosition(value["position"]), + dimensions, + }; }; /** Reads the body telemetry sends to /control/start, throwing on any other shape. */ export const parseStart = (body: unknown): IBuilding => { // An extra key is refused so the old `targetUrl` can never choose where readings go. - if (!isRecord(body) || !onlyKeys(body, ["buildingId", "sensors"])) { - throw new Error("the body must carry exactly buildingId and sensors"); + if (!isRecord(body) || !onlyKeys(body, ["buildingId", "sensors", "rooms"])) { + throw new Error( + "the body must carry buildingId, sensors and optional rooms", + ); } if (!nonEmpty(body["buildingId"])) { throw new Error("buildingId must be a non-empty string"); @@ -59,10 +122,15 @@ export const parseStart = (body: unknown): IBuilding => { if (!Array.isArray(body["sensors"])) { throw new Error("sensors must be an array"); } - return { + const start: IBuilding = { buildingId: body["buildingId"], sensors: body["sensors"].map(parseSensor), }; + if (body["rooms"] === undefined) return start; + if (!Array.isArray(body["rooms"]) || body["rooms"].length === 0) { + throw new Error("rooms, when sent, must be a non-empty array"); + } + return { ...start, rooms: body["rooms"].map(parseRoom) }; }; export interface ISignalPeopleCount { From 19a02e72eaf754845106d98a0404ddd30f5b46a9 Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Sat, 26 Sep 2026 11:46:42 +0200 Subject: [PATCH 02/10] feat(telemetry): simulate routers as twin-placed APs in ap-simulator --- backend/telemetry/CLAUDE.md | 4 +- backend/telemetry/docker-compose.yml | 4 +- documentation/config/sensor-simulator.qd | 27 ++++- documentation/config/toolchain.qd | 2 +- documentation/services/telemetry.qd | 4 +- simulators/ap-simulator/api.py | 95 ++++++++++++--- simulators/ap-simulator/building.py | 95 +++++++++++++++ simulators/ap-simulator/docker-compose.yml | 1 + simulators/ap-simulator/pyproject.toml | 1 + simulators/ap-simulator/scenarios.py | 2 +- simulators/ap-simulator/schemas.py | 13 +++ .../ap-simulator/tests/test_building.py | 110 ++++++++++++++++++ simulators/ap-simulator/tests/test_control.py | 80 +++++++++++++ simulators/ap-simulator/uv.lock | 39 +++++++ simulators/ap-simulator/world.py | 58 +++++---- 15 files changed, 481 insertions(+), 54 deletions(-) create mode 100644 simulators/ap-simulator/building.py create mode 100644 simulators/ap-simulator/tests/test_building.py create mode 100644 simulators/ap-simulator/tests/test_control.py diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 9b5876e8..3e0e16ff 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -85,8 +85,8 @@ unreachable. **Simulators are told what to simulate at start, and only then.** `PUT /simulation/buildings/{id}` reads the building's sensors and sends each simulator in `SIMULATORS` the ones whose kind it claims (body: `schemas/fixtures/simulation-start.json`). Every simulator is told, even with an -empty list — empty means stop, so a removed sensor stops being simulated. Routers (no simulator -claims them) and outdoor sensors are never sent. Calls run concurrently with a 5 s timeout, all +empty list — empty means stop, so a removed sensor stops being simulated. Kinds no simulator +claims, and outdoor sensors, are never sent. Calls run concurrently with a 5 s timeout, all are tried, then any failure is `502`. `Simulation::new` refuses a kind that is not a device, or one claimed twice (doubled readings). No simulator configured → `404`. Geometry (sensor `position`, `rooms`) is optional in the body and omitted when absent — coordinates are twin's. diff --git a/backend/telemetry/docker-compose.yml b/backend/telemetry/docker-compose.yml index 96cd3bb9..6f2e8da5 100644 --- a/backend/telemetry/docker-compose.yml +++ b/backend/telemetry/docker-compose.yml @@ -13,8 +13,8 @@ services: - REDIS_URL=redis://redis:6379 - KAFKA_BROKERS=kafka:9092 - DIGITAL_TWIN_URL=http://digital-twin:3000 - # Which simulator simulates which device kind; kinds no simulator claims (router) are never sent. - - 'SIMULATORS={"sensor-simulator":{"url":"http://sensor-simulator:3000","kinds":["temperature","peopleCount"]},"aq-simulator":{"url":"http://aq-simulator:3000","kinds":["airQuality"]}}' + # Which simulator simulates which device kind; a kind no simulator claims is never sent. + - 'SIMULATORS={"sensor-simulator":{"url":"http://sensor-simulator:3000","kinds":["temperature","peopleCount"]},"aq-simulator":{"url":"http://aq-simulator:3000","kinds":["airQuality"]},"ap-simulator":{"url":"http://ap-simulator:3000","kinds":["router"]}}' - TELEMETRY_INGEST_SECRET=${TELEMETRY_INGEST_SECRET} - PORT=3000 - RUST_LOG=info diff --git a/documentation/config/sensor-simulator.qd b/documentation/config/sensor-simulator.qd index 8f610b61..e8cfcfc7 100644 --- a/documentation/config/sensor-simulator.qd +++ b/documentation/config/sensor-simulator.qd @@ -1,14 +1,13 @@ .docname {External Simulators} .include {docs} -CrowdVision ships two simulators that exist **outside the main application cluster**. They are not microservices — they are test stand-ins for systems that in production would be real external hardware or third-party services. +CrowdVision ships three simulators that exist **outside the main application cluster**. They are not microservices — they are test stand-ins for systems that in production would be real external hardware or third-party services. | Simulator | Location | Simulates | |---|---|---| | **Sensor Simulator** | `simulators/sensor-simulator/` | Thermostats (`temperature`) and people counters (`peopleCount`) | | **Air Quality Simulator** | `simulators/aq-simulator/` | Air-quality stations (`airQuality`) | - -Routers have no simulator: `ap-simulator` fakes access points for `devices/ap-collector`, which polls them itself and is not started from the dashboard. +| **AP Simulator** | `simulators/ap-simulator/` | Routers (`router`), as OpenWrt access points `devices/ap-collector` polls | --- @@ -33,7 +32,7 @@ Routers have no simulator: `ap-simulator` fakes access points for `devices/ap-co | `temperature` | sensor-simulator | one `temperature`, in the sensor's room | | `peopleCount` | sensor-simulator | one `peopleCount`, in the sensor's room | | `airQuality` | aq-simulator | one `airQuality`, in the sensor's room, with its own drifting state | -| `router` | — | nothing | +| `router` | ap-simulator | none: serves `(mac, rssi)` station lists over ubus; ap-collector turns them into `totalDeviceCount` / `ratioDeviceCount` | - **Outdoor sensors** (no room) are never simulated: readings are room-keyed. - **Two sensors of one kind in one room** give two readings for that room each tick. @@ -54,6 +53,8 @@ Routers have no simulator: `ap-simulator` fakes access points for `devices/ap-co participant T as telemetry participant S as sensor-simulator participant A as aq-simulator + participant P as ap-simulator + participant C as ap-collector UI->>T: PUT /telemetry/simulation/buildings/b1 T->>T: read b1's sensors, split by kind @@ -61,12 +62,16 @@ Routers have no simulator: `ap-simulator` fakes access points for `devices/ap-co T->>S: POST /control/start {b1, [thermostats, people counters]} and T->>A: POST /control/start {b1, [air-quality stations]} + and + T->>P: POST /control/start {b1, [routers], rooms?} end T-->>UI: 204 loop Every 10 seconds S->>T: POST TELEMETRY_INGEST_URL (signed batch) A->>T: POST TELEMETRY_INGEST_URL (signed batch) + C->>P: POST //ubus (station list per AP) + C->>T: POST TELEMETRY_INGEST_URL (signed batch) end **Stop** (`DELETE`) tells every simulator to drop the building. **Status** (`GET`) is `running` when any simulator runs it. The browser never calls a simulator. @@ -85,6 +90,20 @@ Called by telemetry only. The start body is pinned by `schemas/fixtures/simulati --- +## AP Simulator + +Fakes the routers, not their readings: the collector stays the only thing that turns stations into counts, so its zone logic runs on simulated data too. + +- **One AP per router**, served at `//ubus` (credentials `collector`/`collector`). A restart drops the building's sessions; the collector logs in again. +- **Placement**: a placed router sits at its twin `position`; an unplaced one at its room's centre, 0.3 m under the ceiling. +- **No `rooms`** in the body: each router room becomes an 8 × 8 m box in a row, and twin positions are ignored — they have nothing to be measured against. +- **People**: `AP_SIM_DEVICES_PER_ROOM` (4) phones per room, over every room, including rooms without a router. Each walks 2–4 random stops, holds 2–15 min, and is in the building about 08:30–17:30 local time. Seeded by `buildingId`: a restart walks the same people. +- **Signal**: log-distance path loss over 3D distance (floors attenuate by height), 2 dB noise, −85 dBm cutoff. Every AP in range hears a phone, each at its own strength. +- **ap-collector must list the same APs** to produce readings. Today its list is static (`collector.dev.json`), so the dashboard's start alone yields no router readings yet. +- The preset topology (`AP_SIM_SCENARIO`) keeps serving the static collector config. `/debug/status`, `/debug/scenario`, `/debug/kill/{ap}`, `/debug/revive/{ap}`, `/debug/ground-truth` inspect and drive it. + +--- + ## Configuration | Variable | Default | Purpose | diff --git a/documentation/config/toolchain.qd b/documentation/config/toolchain.qd index 2b50dddd..77ba7c80 100644 --- a/documentation/config/toolchain.qd +++ b/documentation/config/toolchain.qd @@ -187,7 +187,7 @@ Because the pipeline is inherited, a standard service's entire moon configuratio - - `sensor-simulator`, `aq-simulator` - Each asserts `schemas/fixtures/ingest-batch.json`: they hand-build the batch telemetry hand-parses, so the fixture is the only thing holding the three languages to one shape. The Python one overrides `test`/`lint` to call `uv` directly, because the inherited tasks shell out to `npm` and the root `.gitignore` ignores every `package.json`. - - `ap-simulator` - - Serves a fake ubus endpoint rather than producing telemetry, so it has no fixture to assert and excludes the inherited `test` task; its CI leg runs `smoke.py` instead. + - Serves a fake ubus endpoint rather than producing telemetry, so it asserts only `schemas/fixtures/simulation-start.json`. Overrides `test`/`lint` to call `uv` directly, like aq-simulator; its CI leg also runs `smoke.py`. - - `tooling`, `eslint-config` - Tagged `language: 'javascript'` so they inherit only `deps`. (`acceptance` is `language: 'python'` and excludes the inherited `test`/`lint` tasks: its pytest suite runs via `just test integration` against a composed stack, never as a moon `:test`.) - - `claims-gateway`, `tenancy`, `registry`, `provisioner` diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index 64a0c905..cc85ae3d 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -175,8 +175,8 @@ Positions are **not** stored here: they are physical layout and belong to digita - The simulators stay dumb: they simulate exactly what they are told. - - Every configured simulator is told, even with an empty list; empty = stop the building - A sensor removed in the twin must stop producing readings. - - - Routers (no simulator claims them) and outdoor sensors are never sent - - Nothing simulates a router; readings are room-keyed, so an outdoor reading has nowhere to go. + - - Kinds no simulator claims, and outdoor sensors, are never sent + - Readings are room-keyed, so an outdoor reading has nowhere to go. Compose gives routers to ap-simulator. - - Body pinned by `schemas/fixtures/simulation-start.json`; no `targetUrl` - Three simulators in two languages parse it; a simulator posts to its own `TELEMETRY_INGEST_URL`. - - Sensor `position` and building `rooms` are optional; omitted when telemetry has none diff --git a/simulators/ap-simulator/api.py b/simulators/ap-simulator/api.py index 14e0584b..0b78f72b 100644 --- a/simulators/ap-simulator/api.py +++ b/simulators/ap-simulator/api.py @@ -1,23 +1,42 @@ -"""Fake OpenWrt ubus endpoint, one path per simulated AP. Points a real -`ref/collector/app/ubus.py`-shaped client at `http://ap-simulator:3000//ubus` -instead of real hardware for phases 1-4 of the wireless-flow plan.""" +"""Fake OpenWrt ubus endpoint, one path per simulated AP: `//ubus`. + +`/control/*` is telemetry's: each started building serves one AP per router, id = sensorId. +The preset topology keeps serving the static collector config; `/debug/*` inspects it. +""" from __future__ import annotations +import logging import os from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse import scenarios -from schemas import ScenarioRequest, StatusResponse +from building import layout +from schemas import ( + BuildingStart, + BuildingStatus, + ScenarioRequest, + StatusResponse, + StopRequest, +) from ubus import NULL_SESSION, UBUS_OK, UBUS_PERMISSION_DENIED, envelope, error_envelope from world import World +logger = logging.getLogger(__name__) + app = FastAPI(title="AP Simulator (fake ubus)") +DEVICES_PER_ROOM = int(os.environ.get("AP_SIM_DEVICES_PER_ROOM", "4")) + _scenario_name = os.environ.get("AP_SIM_SCENARIO", "corridor") world = World(scenarios.PRESETS[_scenario_name]()) +buildings: dict[str, World] = {} + + +def _world_of(ap_id: str) -> World | None: + return next((w for w in (*buildings.values(), world) if ap_id in w.aps), None) @app.post("/{ap_id}/ubus") @@ -25,9 +44,10 @@ async def ubus_rpc(ap_id: str, request: Request) -> JSONResponse: body = await request.json() request_id = body.get("id", 1) - if ap_id not in world.aps: + ap_world = _world_of(ap_id) + if ap_world is None: return JSONResponse(error_envelope(request_id, f"unknown AP '{ap_id}'"), status_code=404) - if world.is_down(ap_id): + if ap_world.is_down(ap_id): return JSONResponse( error_envelope(request_id, "AP unreachable (simulated)"), status_code=503 ) @@ -38,16 +58,18 @@ async def ubus_rpc(ap_id: str, request: Request) -> JSONResponse: session_id, obj, method, call_params = params if obj == "session" and method == "login": - token = world.login(ap_id, call_params.get("username", ""), call_params.get("password", "")) + token = ap_world.login( + ap_id, call_params.get("username", ""), call_params.get("password", "") + ) if token is None: return JSONResponse(envelope(request_id, UBUS_PERMISSION_DENIED)) return JSONResponse(envelope(request_id, UBUS_OK, {"ubus_rpc_session": token})) - if session_id == NULL_SESSION or not world.check_session(ap_id, session_id): + if session_id == NULL_SESSION or not ap_world.check_session(ap_id, session_id): return JSONResponse(envelope(request_id, UBUS_PERMISSION_DENIED)) - ap = world.aps[ap_id] - clients = world.clients(ap_id) + ap = ap_world.aps[ap_id] + clients = ap_world.clients(ap_id) if obj == f"hostapd.{ap.iface}" and method == "get_clients" and ap.reader == "hostapd": payload = {"clients": {mac: {"signal": rssi} for mac, rssi in clients}} @@ -67,19 +89,54 @@ async def ubus_rpc(ap_id: str, request: Request) -> JSONResponse: return JSONResponse(error_envelope(request_id, f"no such object/method on {ap_id}")) -@app.post("/control/kill/{ap_id}") +@app.post("/control/start") +def start(body: BuildingStart) -> dict: + """Replaces what the building simulates; no router means stop simulating it.""" + config = layout(body, DEVICES_PER_ROOM) + if config is None: + return stop(StopRequest(buildingId=body.buildingId)) + buildings[body.buildingId] = World(config) + logger.info( + "building=%r aps=%d devices=%d", body.buildingId, len(config.aps), len(config.devices) + ) + return {"message": f"Simulator started for {body.buildingId}"} + + +@app.post("/control/stop") +def stop(body: StopRequest) -> dict: + """Stops one building; one it never simulated is already stopped.""" + buildings.pop(body.buildingId, None) + return {"message": f"Simulator stopped for {body.buildingId}"} + + +@app.get("/control/status") +def building_status(buildingId: str | None = None) -> BuildingStatus: + running = buildingId in buildings if buildingId else bool(buildings) + return BuildingStatus(isRunning=running, activeBuildings=sorted(buildings)) + + +def _ap_world(ap_id: str) -> World: + found = _world_of(ap_id) + if found is None: + raise HTTPException(status_code=404, detail=f"unknown AP '{ap_id}'") + return found + + +@app.post("/debug/kill/{ap_id}") def kill(ap_id: str) -> dict: - world.kill(ap_id) - return {"down": sorted(world.down)} + ap_world = _ap_world(ap_id) + ap_world.kill(ap_id) + return {"down": sorted(ap_world.down)} -@app.post("/control/revive/{ap_id}") +@app.post("/debug/revive/{ap_id}") def revive(ap_id: str) -> dict: - world.revive(ap_id) - return {"down": sorted(world.down)} + ap_world = _ap_world(ap_id) + ap_world.revive(ap_id) + return {"down": sorted(ap_world.down)} -@app.get("/control/status") +@app.get("/debug/status") def status() -> StatusResponse: return StatusResponse( scenario=_scenario_name, @@ -89,7 +146,7 @@ def status() -> StatusResponse: ) -@app.post("/control/scenario") +@app.post("/debug/scenario") def set_scenario(req: ScenarioRequest) -> StatusResponse: """Swap topology/devices at runtime — no restart, but resets all sessions and killed-AP state since it's a fresh World.""" @@ -108,6 +165,6 @@ def set_scenario(req: ScenarioRequest) -> StatusResponse: return status() -@app.get("/control/ground-truth") +@app.get("/debug/ground-truth") def ground_truth(mac: str) -> dict: return {"mac": mac, "zone": world.ground_truth_zone(mac)} diff --git a/simulators/ap-simulator/building.py b/simulators/ap-simulator/building.py new file mode 100644 index 00000000..0e89d51c --- /dev/null +++ b/simulators/ap-simulator/building.py @@ -0,0 +1,95 @@ +"""Turns telemetry's start body into a World: one AP per router, people walking the rooms. + +The twin is y-up; the World's floor plane is (x, y) with z as height, so twin (x, y, z) +maps to World (x, z, y). +""" + +from __future__ import annotations + +import random + +from schemas import ( + ApConfig, + BuildingStart, + Coordinates, + DeviceRoute, + Dimensions, + Room, + ScenarioConfig, + SimulatedSensor, + Waypoint, +) + +ROUTER = "router" +PHONE_HEIGHT_M = 1.2 +CEILING_GAP_M = 0.3 +WALL_MARGIN_M = 0.5 +FALLBACK_ROOM = Dimensions(width=8.0, height=3.0, depth=8.0) + + +def layout(start: BuildingStart, devices_per_room: int) -> ScenarioConfig | None: + """The building's APs and devices; None when it has no router to simulate.""" + routers = [s for s in start.sensors if s.sensorType == ROUTER] + if not routers: + return None + # Without rooms a twin position has nothing to be measured against, so it is ignored. + geometry = start.rooms is not None + rooms = start.rooms or _fallback_rooms(routers) + by_id = {room.roomId: room for room in rooms} + aps = [ + _ap(router, by_id[router.roomId], geometry) for router in routers if router.roomId in by_id + ] + # Seeded by building so a restart walks the same people through the same rooms. + rng = random.Random(start.buildingId) + devices = [_device(rng, rooms) for _ in range(devices_per_room * len(rooms))] + return ScenarioConfig(aps=aps, devices=devices) + + +def _fallback_rooms(routers: list[SimulatedSensor]) -> list[Room]: + room_ids = sorted({router.roomId for router in routers}) + return [ + Room( + roomId=room_id, + position=Coordinates(x=index * FALLBACK_ROOM.width, y=0.0, z=0.0), + dimensions=FALLBACK_ROOM, + ) + for index, room_id in enumerate(room_ids) + ] + + +def _ap(router: SimulatedSensor, room: Room, geometry: bool) -> ApConfig: + at = router.position if geometry and router.position is not None else None + if at is None: + ceiling = room.position.y + room.dimensions.height - CEILING_GAP_M + at = Coordinates(x=room.position.x, y=ceiling, z=room.position.z) + return ApConfig(id=router.sensorId, zone_id=router.roomId, x=at.x, y=at.z, z=at.y) + + +def _device(rng: random.Random, rooms: list[Room]) -> DeviceRoute: + stops = [rng.choice(rooms) for _ in range(rng.randint(2, 4))] + return DeviceRoute( + mac=_mac(rng), + waypoints=[_spot(rng, room, hold_s=rng.uniform(120.0, 900.0)) for room in stops], + speed_mps=rng.uniform(0.9, 1.4), + phase_offset_s=rng.uniform(0.0, 3600.0), + present_h=(rng.gauss(8.5, 1.0), rng.gauss(17.5, 1.2)), + ) + + +def _spot(rng: random.Random, room: Room, hold_s: float) -> Waypoint: + def along(centre: float, side: float) -> float: + reach = max(side / 2 - WALL_MARGIN_M, 0.0) + return rng.uniform(centre - reach, centre + reach) + + return Waypoint( + x=along(room.position.x, room.dimensions.width), + y=along(room.position.z, room.dimensions.depth), + z=room.position.y + PHONE_HEIGHT_M, + hold_s=hold_s, + ) + + +def _mac(rng: random.Random) -> str: + # Locally administered, as a phone's randomized per-SSID MAC is. + octets = [0x02] + [rng.randrange(256) for _ in range(5)] + return ":".join(f"{octet:02x}" for octet in octets) diff --git a/simulators/ap-simulator/docker-compose.yml b/simulators/ap-simulator/docker-compose.yml index 0e74c3d7..6d379672 100644 --- a/simulators/ap-simulator/docker-compose.yml +++ b/simulators/ap-simulator/docker-compose.yml @@ -5,5 +5,6 @@ services: context: . environment: - AP_SIM_SCENARIO=${AP_SIM_SCENARIO:-corridor} + - AP_SIM_DEVICES_PER_ROOM=${AP_SIM_DEVICES_PER_ROOM:-4} ports: - "3003:3000" diff --git a/simulators/ap-simulator/pyproject.toml b/simulators/ap-simulator/pyproject.toml index 8a4602cc..a8bbe717 100644 --- a/simulators/ap-simulator/pyproject.toml +++ b/simulators/ap-simulator/pyproject.toml @@ -14,6 +14,7 @@ dev = [ "pytest>=8.3.0", "ruff>=0.8.0", "pyright>=1.1.390", + "httpx>=0.28.1", ] [tool.pytest.ini_options] diff --git a/simulators/ap-simulator/scenarios.py b/simulators/ap-simulator/scenarios.py index 46762e64..440a6c5f 100644 --- a/simulators/ap-simulator/scenarios.py +++ b/simulators/ap-simulator/scenarios.py @@ -1,6 +1,6 @@ """Topology presets. `corridor` mirrors phase 1's MAC-stability walk (two APs, one phone, no stops). `grid` mirrors phase 2's zone-separation walk (three -APs, stand-still holds so /control/ground-truth can back-fill +APs, stand-still holds so /debug/ground-truth can back-fill notes/ground-truth.csv).""" from __future__ import annotations diff --git a/simulators/ap-simulator/schemas.py b/simulators/ap-simulator/schemas.py index 1232f510..fdcc0ff2 100644 --- a/simulators/ap-simulator/schemas.py +++ b/simulators/ap-simulator/schemas.py @@ -13,6 +13,7 @@ class ApConfig(BaseModel): zone_id: str = "" x: float = 0.0 y: float = 0.0 + z: float = 0.0 username: str = "collector" password: str = "collector" session_ttl_s: float = 300.0 @@ -34,6 +35,7 @@ def model_post_init(self, _context: Any, /) -> None: class Waypoint(BaseModel): x: float y: float + z: float = 0.0 hold_s: float = 0.0 @@ -42,6 +44,8 @@ class DeviceRoute(BaseModel): waypoints: list[Waypoint] speed_mps: float = 1.2 phase_offset_s: float = 0.0 + # Local hours [from, until) the device is in the building; None means always. + present_h: tuple[float, float] | None = None @field_validator("waypoints") @classmethod @@ -60,6 +64,15 @@ class ScenarioConfig(BaseModel): sensitivity_dbm: float = -85.0 +class BuildingStatus(BaseModel): + isRunning: bool + activeBuildings: list[str] + + +class StopRequest(BaseModel): + buildingId: str + + class StatusResponse(BaseModel): scenario: str aps: list[str] diff --git a/simulators/ap-simulator/tests/test_building.py b/simulators/ap-simulator/tests/test_building.py new file mode 100644 index 00000000..9f31994f --- /dev/null +++ b/simulators/ap-simulator/tests/test_building.py @@ -0,0 +1,110 @@ +import json +import math +import time +from pathlib import Path + +from building import layout +from schemas import BuildingStart +from world import World + +_FIXTURE = json.loads( + ( + Path(__file__).resolve().parents[3] / "schemas" / "fixtures" / "simulation-start.json" + ).read_text() +) + + +def _start(name_part: str) -> BuildingStart: + case = next(c for c in _FIXTURE["cases"] if name_part in c["name"]) + return BuildingStart.model_validate(case["body"]) + + +def _at(hour: int) -> float: + return time.mktime((2026, 9, 23, hour, 0, 0, 0, 0, -1)) + + +PLACED = _start("two placed routers") +UNPLACED = _start("not placed yet") + + +def test_each_router_becomes_an_ap_in_its_room(): + config = layout(PLACED, devices_per_room=4) + assert config is not None + assert {(ap.id, ap.zone_id) for ap in config.aps} == { + (s.sensorId, s.roomId) for s in PLACED.sensors + } + + +def test_a_placed_router_sits_where_the_twin_put_it(): + config = layout(PLACED, devices_per_room=4) + assert config is not None + ap = next(ap for ap in config.aps if ap.id == PLACED.sensors[0].sensorId) + assert (ap.x, ap.y, ap.z) == (3.5, -1.0, 2.6) + + +def test_an_unplaced_router_hangs_under_its_room_ceiling_at_the_centre(): + config = layout(UNPLACED, devices_per_room=4) + assert config is not None + (ap,) = config.aps + assert (ap.x, ap.y) == (2.0, 0.0) + assert 2.0 < ap.z < 3.0 + + +def test_other_kinds_get_no_ap_and_no_router_means_nothing_to_simulate(): + body = PLACED.model_dump(exclude_none=True) + for sensor in body["sensors"]: + sensor["sensorType"] = "temperature" + assert layout(BuildingStart.model_validate(body), devices_per_room=4) is None + + +def test_devices_walk_through_every_room_even_one_without_a_router(): + config = layout(PLACED, devices_per_room=4) + assert config is not None + assert len(config.devices) == 4 * len(PLACED.rooms or []) + corridor = next(r for r in PLACED.rooms or [] if r.roomId == "room-corridor") + inside = [ + wp + for d in config.devices + for wp in d.waypoints + if abs(wp.x - corridor.position.x) <= corridor.dimensions.width / 2 + and abs(wp.y - corridor.position.z) <= corridor.dimensions.depth / 2 + ] + assert inside + + +def test_one_building_always_gets_the_same_layout(): + assert layout(PLACED, devices_per_room=4) == layout(PLACED, devices_per_room=4) + + +def test_without_rooms_each_router_room_gets_its_own_box(): + body = PLACED.model_dump(exclude_none=True) + del body["rooms"] + config = layout(BuildingStart.model_validate(body), devices_per_room=4) + assert config is not None + a, b = config.aps + assert math.dist((a.x, a.y), (b.x, b.y)) > 0 + assert len(config.devices) == 8 + + +def test_a_device_between_two_routers_is_heard_by_both_louder_by_the_nearer(): + config = layout(PLACED, devices_per_room=4) + assert config is not None + world = World(config.model_copy(update={"noise_stddev_db": 0.0}), seed=1) + lab, aula = (s.sensorId for s in PLACED.sensors) + noon = _at(11) + heard_lab = dict(world.clients(lab, now_s=noon)) + heard_aula = dict(world.clients(aula, now_s=noon)) + both = heard_lab.keys() & heard_aula.keys() + assert both + assert any(heard_lab[mac] != heard_aula[mac] for mac in both) + + +def test_the_building_empties_at_night(): + config = layout(PLACED, devices_per_room=4) + assert config is not None + world = World(config, seed=1) + heard = { + hour: {mac for ap in world.aps for mac, _ in world.clients(ap, now_s=_at(hour))} + for hour in (3, 11) + } + assert len(heard[3]) < len(heard[11]) diff --git a/simulators/ap-simulator/tests/test_control.py b/simulators/ap-simulator/tests/test_control.py new file mode 100644 index 00000000..80e9f9a7 --- /dev/null +++ b/simulators/ap-simulator/tests/test_control.py @@ -0,0 +1,80 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +import api + +_FIXTURE = json.loads( + ( + Path(__file__).resolve().parents[3] / "schemas" / "fixtures" / "simulation-start.json" + ).read_text() +) +PLACED = next(c for c in _FIXTURE["cases"] if "two placed routers" in c["name"])["body"] +BUILDING = PLACED["buildingId"] +ROUTER = PLACED["sensors"][0]["sensorId"] + + +def _client() -> TestClient: + api.buildings.clear() + return TestClient(api.app) + + +def _running(client: TestClient) -> bool: + return client.get("/control/status", params={"buildingId": BUILDING}).json()["isRunning"] + + +def _login(client: TestClient, ap_id: str) -> int: + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "call", + "params": [ + "0" * 32, + "session", + "login", + {"username": "collector", "password": "collector"}, + ], + } + return client.post(f"/{ap_id}/ubus", json=body).status_code + + +def test_start_runs_the_building_and_serves_one_ap_per_router(): + client = _client() + assert client.post("/control/start", json=PLACED).status_code == 200 + assert _running(client) + assert _login(client, ROUTER) == 200 + + +def test_an_empty_start_stops_the_building(): + client = _client() + client.post("/control/start", json=PLACED) + client.post("/control/start", json={"buildingId": BUILDING, "sensors": []}) + assert not _running(client) + assert _login(client, ROUTER) == 404 + + +def test_a_router_removed_before_a_restart_stops_answering(): + client = _client() + client.post("/control/start", json=PLACED) + client.post("/control/start", json={**PLACED, "sensors": PLACED["sensors"][1:]}) + assert _login(client, ROUTER) == 404 + + +def test_stop_ends_the_building_and_an_unknown_one_is_already_stopped(): + client = _client() + client.post("/control/start", json=PLACED) + assert client.post("/control/stop", json={"buildingId": BUILDING}).status_code == 200 + assert client.post("/control/stop", json={"buildingId": "ghost"}).status_code == 200 + assert not _running(client) + + +def test_the_old_body_that_chose_its_own_ingest_target_is_refused(): + body = {**PLACED, "targetUrl": "http://localhost/telemetry/"} + assert _client().post("/control/start", json=body).status_code == 422 + + +def test_the_preset_topology_is_still_served_and_inspected_under_debug(): + client = _client() + assert _login(client, "ap-a") == 200 + assert "ap-a" in client.get("/debug/status").json()["aps"] diff --git a/simulators/ap-simulator/uv.lock b/simulators/ap-simulator/uv.lock index 71553d57..4a7bc16a 100644 --- a/simulators/ap-simulator/uv.lock +++ b/simulators/ap-simulator/uv.lock @@ -45,6 +45,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, @@ -59,11 +60,21 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest", specifier = ">=8.3.0" }, { name = "ruff", specifier = ">=0.8.0" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -107,6 +118,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.19" diff --git a/simulators/ap-simulator/world.py b/simulators/ap-simulator/world.py index 65c3ee04..a0ffcc33 100644 --- a/simulators/ap-simulator/world.py +++ b/simulators/ap-simulator/world.py @@ -15,12 +15,13 @@ from schemas import ApConfig, DeviceRoute, ScenarioConfig +Point = tuple[float, float, float] + + @dataclass class _Segment: - start_x: float - start_y: float - end_x: float - end_y: float + start: Point + end: Point hold_s: float travel_s: float @@ -34,34 +35,44 @@ def _route_segments(route: DeviceRoute) -> list[_Segment]: segments: list[_Segment] = [] for i, wp in enumerate(points): nxt = points[(i + 1) % len(points)] - dist = math.hypot(nxt.x - wp.x, nxt.y - wp.y) - travel_s = dist / route.speed_mps if route.speed_mps > 0 else 0.0 - segments.append(_Segment(wp.x, wp.y, nxt.x, nxt.y, wp.hold_s, travel_s)) + start, end = (wp.x, wp.y, wp.z), (nxt.x, nxt.y, nxt.z) + travel_s = math.dist(start, end) / route.speed_mps if route.speed_mps > 0 else 0.0 + segments.append(_Segment(start, end, wp.hold_s, travel_s)) return segments +def _local_hour(now_s: float) -> float: + local = time.localtime(now_s) + return local.tm_hour + local.tm_min / 60 + + @dataclass class _Device: mac: str segments: list[_Segment] cycle_s: float start_offset_s: float + present_h: tuple[float, float] | None + + def present_at(self, now_s: float) -> bool: + if self.present_h is None: + return True + arrive, leave = self.present_h + return arrive <= _local_hour(now_s) < leave - def position_at(self, now_s: float) -> tuple[float, float]: + def position_at(self, now_s: float) -> Point: t = (now_s - self.start_offset_s) % self.cycle_s for seg in self.segments: if t < seg.hold_s: - return seg.start_x, seg.start_y + return seg.start t -= seg.hold_s if t < seg.travel_s: frac = t / seg.travel_s if seg.travel_s > 0 else 1.0 - return ( - seg.start_x + (seg.end_x - seg.start_x) * frac, - seg.start_y + (seg.end_y - seg.start_y) * frac, - ) + x0, y0, z0 = seg.start + x1, y1, z1 = seg.end + return x0 + (x1 - x0) * frac, y0 + (y1 - y0) * frac, z0 + (z1 - z0) * frac t -= seg.travel_s - last = self.segments[-1] - return last.end_x, last.end_y + return self.segments[-1].end @dataclass @@ -83,7 +94,7 @@ def __init__(self, config: ScenarioConfig, seed: int | None = None) -> None: def _build_device(route: DeviceRoute) -> _Device: segments = _route_segments(route) cycle_s = sum(seg.duration_s for seg in segments) or 1.0 - return _Device(route.mac, segments, cycle_s, route.phase_offset_s) + return _Device(route.mac, segments, cycle_s, route.phase_offset_s, route.present_h) @property def device_macs(self) -> list[str]: @@ -118,14 +129,15 @@ def check_session(self, ap_id: str, token: str) -> bool: and time.monotonic() < session.expires_at ) - def clients(self, ap_id: str) -> list[tuple[str, int]]: - """(mac, rssi) for every device currently within earshot of this AP.""" + def clients(self, ap_id: str, now_s: float | None = None) -> list[tuple[str, int]]: + """(mac, rssi) for every device currently in the building and within earshot of this AP.""" ap = self.aps[ap_id] - now = time.time() + now = now_s if now_s is not None else time.time() out: list[tuple[str, int]] = [] for device in self._devices: - x, y = device.position_at(now) - distance = math.hypot(x - ap.x, y - ap.y) + if not device.present_at(now): + continue + distance = math.dist(device.position_at(now), (ap.x, ap.y, ap.z)) signal = rssi_dbm(distance, self.config.tx_power_dbm, self.config.path_loss_exponent) signal += self._rng.gauss(0, self.config.noise_stddev_db) if signal >= self.config.sensitivity_dbm: @@ -139,6 +151,6 @@ def ground_truth_zone(self, mac: str, now_s: float | None = None) -> str | None: device = next((d for d in self._devices if d.mac == mac), None) if device is None: return None - x, y = device.position_at(now) - nearest = min(self.aps.values(), key=lambda ap: math.hypot(x - ap.x, y - ap.y)) + here = device.position_at(now) + nearest = min(self.aps.values(), key=lambda ap: math.dist(here, (ap.x, ap.y, ap.z))) return nearest.zone_id From 8e68e34735ebbe3a60e29088b58bb10dec2877a9 Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Sat, 26 Sep 2026 11:59:23 +0200 Subject: [PATCH 03/10] fix(telemetry): keep coordinates out of simulation-start and ap-simulator --- backend/telemetry/CLAUDE.md | 4 +- .../src/adapters/driven/simulators.rs | 85 +------- backend/telemetry/src/kernel/fakes.rs | 3 +- backend/telemetry/src/kernel/ports.rs | 5 +- backend/telemetry/src/kernel/simulation.rs | 4 +- backend/telemetry/src/types/simulation.rs | 27 --- documentation/config/sensor-simulator.qd | 26 ++- documentation/packages/telemetry-schema.qd | 6 +- documentation/services/telemetry.qd | 4 +- schemas/CLAUDE.md | 3 +- schemas/fixtures/simulation-start.json | 103 +-------- schemas/json/simulation-start.schema.json | 44 ---- simulators/ap-simulator/api.py | 15 +- simulators/ap-simulator/building.py | 204 +++++++++++------- simulators/ap-simulator/docker-compose.yml | 2 +- simulators/ap-simulator/schemas.py | 37 ---- .../ap-simulator/tests/test_building.py | 151 ++++++------- simulators/ap-simulator/tests/test_control.py | 18 +- simulators/ap-simulator/world.py | 58 ++--- simulators/aq-simulator/schemas.py | 33 --- .../aq-simulator/tests/test_start_contract.py | 3 +- .../sensor-simulator/src/models/signal.ts | 84 +------- 22 files changed, 303 insertions(+), 616 deletions(-) diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 3e0e16ff..037ea064 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -88,8 +88,8 @@ claims (body: `schemas/fixtures/simulation-start.json`). Every simulator is told empty list — empty means stop, so a removed sensor stops being simulated. Kinds no simulator claims, and outdoor sensors, are never sent. Calls run concurrently with a 5 s timeout, all are tried, then any failure is `502`. `Simulation::new` refuses a kind that is not a device, or -one claimed twice (doubled readings). No simulator configured → `404`. Geometry (sensor -`position`, `rooms`) is optional in the body and omitted when absent — coordinates are twin's. +one claimed twice (doubled readings). No simulator configured → `404`. The body never +carries coordinates: positions are digital-twin's, not telemetry's. **Registration**: telemetry consumes `building-registration-requested` and answers `building-registration-completed` (both from `twin_schema`). `maxTemperature` is read here diff --git a/backend/telemetry/src/adapters/driven/simulators.rs b/backend/telemetry/src/adapters/driven/simulators.rs index d0d765a7..20038ff3 100644 --- a/backend/telemetry/src/adapters/driven/simulators.rs +++ b/backend/telemetry/src/adapters/driven/simulators.rs @@ -1,8 +1,6 @@ use crate::adapters::driven::dispatch::is_http; use crate::kernel::ports::{DispatchError, SimulatorControl}; -use crate::types::simulation::{ - Coordinates, Dimensions, SimulatedRoom, SimulatedSensor, SimulatorSpec, -}; +use crate::types::simulation::{SimulatedSensor, SimulatorSpec}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -23,8 +21,6 @@ struct Configured { struct StartBody<'a> { building_id: &'a str, sensors: Vec>, - #[serde(skip_serializing_if = "Vec::is_empty")] - rooms: Vec>, } #[derive(Serialize)] @@ -33,16 +29,6 @@ struct SensorBody<'a> { sensor_id: &'a str, sensor_type: &'a str, room_id: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - position: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RoomBody<'a> { - room_id: &'a str, - position: Coordinates, - dimensions: Dimensions, } #[derive(Serialize)] @@ -122,7 +108,6 @@ impl SimulatorControl for HttpSimulators { simulator: &str, building_id: &str, sensors: &[SimulatedSensor], - rooms: &[SimulatedRoom], ) -> Result<(), DispatchError> { let body = StartBody { building_id, @@ -132,15 +117,6 @@ impl SimulatorControl for HttpSimulators { sensor_id: &sensor.sensor_id, sensor_type: &sensor.sensor_type, room_id: &sensor.room_id, - position: sensor.position, - }) - .collect(), - rooms: rooms - .iter() - .map(|room| RoomBody { - room_id: &room.room_id, - position: room.position, - dimensions: room.dimensions, }) .collect(), }; @@ -173,7 +149,7 @@ impl SimulatorControl for HttpSimulators { mod tests { use super::*; use crate::kernel::ports::{DispatchError, SimulatorControl}; - use crate::types::simulation::{Coordinates, Dimensions, SimulatedRoom, SimulatedSensor}; + use crate::types::simulation::SimulatedSensor; use serde_json::{Value, json}; use std::collections::HashMap; use std::time::Duration; @@ -197,16 +173,10 @@ mod tests { .clone() } - fn coordinates(value: &Value) -> Coordinates { - Coordinates { - x: value["x"].as_f64().unwrap(), - y: value["y"].as_f64().unwrap(), - z: value["z"].as_f64().unwrap(), - } - } - - fn sensors_of(body: &Value) -> Vec { - body["sensors"] + #[tokio::test] + async fn start_posts_the_body_the_fixture_pins() { + let body = fixture_case("sensor-simulator"); + let sensors: Vec = body["sensors"] .as_array() .unwrap() .iter() @@ -214,30 +184,8 @@ mod tests { sensor_id: s["sensorId"].as_str().unwrap().to_owned(), sensor_type: s["sensorType"].as_str().unwrap().to_owned(), room_id: s["roomId"].as_str().unwrap().to_owned(), - position: s.get("position").map(coordinates), }) - .collect() - } - - fn rooms_of(body: &Value) -> Vec { - body.get("rooms") - .and_then(Value::as_array) - .into_iter() - .flatten() - .map(|r| SimulatedRoom { - room_id: r["roomId"].as_str().unwrap().to_owned(), - position: coordinates(&r["position"]), - dimensions: Dimensions { - width: r["dimensions"]["width"].as_f64().unwrap(), - height: r["dimensions"]["height"].as_f64().unwrap(), - depth: r["dimensions"]["depth"].as_f64().unwrap(), - }, - }) - .collect() - } - - async fn assert_start_posts(consumer: &str) { - let body = fixture_case(consumer); + .collect(); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/control/start")) @@ -248,26 +196,11 @@ mod tests { .await; simulators(&server, Duration::from_secs(5)) - .start( - "sim", - body["buildingId"].as_str().unwrap(), - &sensors_of(&body), - &rooms_of(&body), - ) + .start("sim", body["buildingId"].as_str().unwrap(), &sensors) .await .unwrap(); } - #[tokio::test] - async fn start_posts_the_body_the_fixture_pins() { - assert_start_posts("sensor-simulator").await; - } - - #[tokio::test] - async fn start_posts_positions_and_rooms_as_the_fixture_pins() { - assert_start_posts("ap-simulator").await; - } - #[tokio::test] async fn an_empty_start_still_sends_the_list() { let body = fixture_case("any"); @@ -281,7 +214,7 @@ mod tests { .await; simulators(&server, Duration::from_secs(5)) - .start("sim", body["buildingId"].as_str().unwrap(), &[], &[]) + .start("sim", body["buildingId"].as_str().unwrap(), &[]) .await .unwrap(); } diff --git a/backend/telemetry/src/kernel/fakes.rs b/backend/telemetry/src/kernel/fakes.rs index 8397bf4c..21917e3a 100644 --- a/backend/telemetry/src/kernel/fakes.rs +++ b/backend/telemetry/src/kernel/fakes.rs @@ -10,7 +10,7 @@ use crate::types::plugin::{ use crate::types::query::Bucket; use crate::types::reading::Reading; use crate::types::sensor::{Command, Sensor, SensorChanges}; -use crate::types::simulation::{SimulatedRoom, SimulatedSensor}; +use crate::types::simulation::SimulatedSensor; use crate::types::threshold::{Bounds, RoomTemperatureLimit, TemperatureLimits}; use async_trait::async_trait; use serde_json::{Map, Value}; @@ -603,7 +603,6 @@ impl SimulatorControl for FakeSimulators { simulator: &str, building_id: &str, sensors: &[SimulatedSensor], - _rooms: &[SimulatedRoom], ) -> Result<(), DispatchError> { self.answer(simulator)?; self.started.lock().unwrap().push(( diff --git a/backend/telemetry/src/kernel/ports.rs b/backend/telemetry/src/kernel/ports.rs index 17233de6..572969c9 100644 --- a/backend/telemetry/src/kernel/ports.rs +++ b/backend/telemetry/src/kernel/ports.rs @@ -3,7 +3,7 @@ use crate::types::event::{AlertPayload, TelemetryEvent}; use crate::types::query::Bucket; use crate::types::reading::Reading; use crate::types::sensor::{Command, Sensor, SensorChanges}; -use crate::types::simulation::{SimulatedRoom, SimulatedSensor}; +use crate::types::simulation::SimulatedSensor; use crate::types::threshold::{Bounds, TemperatureLimits}; use async_trait::async_trait; @@ -86,13 +86,12 @@ pub trait ActionDispatch: Send + Sync { /// The simulators standing in for a building's devices, addressed by their configured name. #[async_trait] pub trait SimulatorControl: Send + Sync { - /// Replaces everything the simulator held for the building; no sensors stops it, no rooms sends no geometry. + /// Replaces everything the simulator held for the building; an empty list stops it. async fn start( &self, simulator: &str, building_id: &str, sensors: &[SimulatedSensor], - rooms: &[SimulatedRoom], ) -> Result<(), DispatchError>; async fn stop(&self, simulator: &str, building_id: &str) -> Result<(), DispatchError>; async fn is_running(&self, simulator: &str, building_id: &str) -> Result; diff --git a/backend/telemetry/src/kernel/simulation.rs b/backend/telemetry/src/kernel/simulation.rs index 8a1acc2b..53626aaf 100644 --- a/backend/telemetry/src/kernel/simulation.rs +++ b/backend/telemetry/src/kernel/simulation.rs @@ -74,7 +74,6 @@ impl Simulation { sensor_id: sensor.sensor_id.clone(), sensor_type: sensor.sensor_type.clone(), room_id: room_id.clone(), - position: None, }), _ => plan.unsimulated.push(sensor.sensor_id.clone()), } @@ -94,7 +93,7 @@ impl Simulation { let results = join_all(plan.by_simulator.iter().map(|(name, sensors)| async move { ( name.as_str(), - self.control.start(name, building_id, sensors, &[]).await, + self.control.start(name, building_id, sensors).await, ) })) .await; @@ -241,7 +240,6 @@ mod tests { sensor_id: id.to_owned(), sensor_type: kind.to_owned(), room_id: room.to_owned(), - position: None, } } diff --git a/backend/telemetry/src/types/simulation.rs b/backend/telemetry/src/types/simulation.rs index 00792198..ef633dfe 100644 --- a/backend/telemetry/src/types/simulation.rs +++ b/backend/telemetry/src/types/simulation.rs @@ -1,38 +1,11 @@ -use serde::Serialize; use std::collections::BTreeMap; -/// A point in the twin's frame, y up. -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] -pub struct Coordinates { - pub x: f64, - pub y: f64, - pub z: f64, -} - -/// A room's extent: width along x, height up y, depth along z. -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] -pub struct Dimensions { - pub width: f64, - pub height: f64, - pub depth: f64, -} - /// A sensor as a simulator sees it: always in a room, because readings are room-keyed. #[derive(Debug, Clone, PartialEq)] pub struct SimulatedSensor { pub sensor_id: String, pub sensor_type: String, pub room_id: String, - /// `None` until the sensor is placed in the twin. - pub position: Option, -} - -/// A room as a box; `position` is its footprint centre at floor level. -#[derive(Debug, Clone, PartialEq)] -pub struct SimulatedRoom { - pub room_id: String, - pub position: Coordinates, - pub dimensions: Dimensions, } /// A simulator and the device kinds it produces readings for. diff --git a/documentation/config/sensor-simulator.qd b/documentation/config/sensor-simulator.qd index e8cfcfc7..d1a4ea49 100644 --- a/documentation/config/sensor-simulator.qd +++ b/documentation/config/sensor-simulator.qd @@ -63,7 +63,7 @@ CrowdVision ships three simulators that exist **outside the main application clu and T->>A: POST /control/start {b1, [air-quality stations]} and - T->>P: POST /control/start {b1, [routers], rooms?} + T->>P: POST /control/start {b1, [routers]} end T-->>UI: 204 @@ -84,7 +84,7 @@ Called by telemetry only. The start body is pinned by `schemas/fixtures/simulati | Method | Endpoint | Body | Effect | |---|---|---|---| -| `POST` | `/control/start` | `{ buildingId, sensors: [{ sensorId, sensorType, roomId, position? }], rooms? }` | Replaces what the building simulates. Empty `sensors` stops it. Geometry is optional; a simulator that does not need it ignores it. Any other key (e.g. the old `targetUrl`) is refused. | +| `POST` | `/control/start` | `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }` | Replaces what the building simulates. Empty `sensors` stops it. Any other key (e.g. the old `targetUrl`, or a coordinate) is refused. | | `POST` | `/control/stop` | `{ buildingId }` | Stops the building. Stopping one it never simulated succeeds. | | `GET` | `/control/status?buildingId=X` | — | `{ isRunning }` | @@ -95,10 +95,24 @@ Called by telemetry only. The start body is pinned by `schemas/fixtures/simulati Fakes the routers, not their readings: the collector stays the only thing that turns stations into counts, so its zone logic runs on simulated data too. - **One AP per router**, served at `//ubus` (credentials `collector`/`collector`). A restart drops the building's sessions; the collector logs in again. -- **Placement**: a placed router sits at its twin `position`; an unplaced one at its room's centre, 0.3 m under the ceiling. -- **No `rooms`** in the body: each router room becomes an 8 × 8 m box in a row, and twin positions are ignored — they have nothing to be measured against. -- **People**: `AP_SIM_DEVICES_PER_ROOM` (4) phones per room, over every room, including rooms without a router. Each walks 2–4 random stops, holds 2–15 min, and is in the building about 08:30–17:30 local time. Seeded by `buildingId`: a restart walks the same people. -- **Signal**: log-distance path loss over 3D distance (floors attenuate by height), 2 dB noise, −85 dBm cutoff. Every AP in range hears a phone, each at its own strength. +- **No coordinates**: a router reports `(mac, rssi)` and knows nothing of where it is. The simulator works from router ids and rooms only; position guessing, if any, happens downstream from the strengths. +- **Neighbours are invented**: the router rooms form a ring, shuffled and seeded by `buildingId`. +- **People**: `AP_SIM_PHONES_PER_ROOM` (4) phones per router room. Each visits 3–5 stops, 70 % of the time a neighbouring room, holds 2–15 min, and is in the building about 08:30–17:30 local time. Seeded by `buildingId`: a restart walks the same people. +- **Signal** for a phone in room R, standing somewhere between R's middle and the door to one neighbour: + +.tablebyrows {.headers} + - - Router in + - RSSI + - - R + - −45 → −57 dBm, weaker toward the door + - - the neighbour it stands near + - −80 → −65 dBm, stronger toward the door + - - another neighbour + - −82 dBm, at the edge of hearing + - - any other room + - not heard + +- Each router adds a fixed ±3 dB of its own, so two routers in one room still hear a phone differently. 2 dB noise per poll, −85 dBm cutoff. - **ap-collector must list the same APs** to produce readings. Today its list is static (`collector.dev.json`), so the dashboard's start alone yields no router readings yet. - The preset topology (`AP_SIM_SCENARIO`) keeps serving the static collector config. `/debug/status`, `/debug/scenario`, `/debug/kill/{ap}`, `/debug/revive/{ap}`, `/debug/ground-truth` inspect and drive it. diff --git a/documentation/packages/telemetry-schema.qd b/documentation/packages/telemetry-schema.qd index 3490a3a9..97b22a8e 100644 --- a/documentation/packages/telemetry-schema.qd +++ b/documentation/packages/telemetry-schema.qd @@ -92,13 +92,11 @@ Two telemetry shapes have no type here: one side walks raw JSON or is the only R | `ingest-batch.json` | device → telemetry `/ingest` | ap-collector, aq-simulator, sensor-simulator | telemetry | | `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator, ap-simulator | -`simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId, position? }], rooms? }`. +`simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }`. - `sensors` replaces what the simulator held for the building; empty = stop simulating it. - `roomId` is always a room: readings are room-keyed, so outdoor sensors are never sent. -- `position` (`{ x, y, z }`, the twin's frame, y up): where the sensor is placed; absent when unplaced. -- `rooms` (`[{ roomId, position, dimensions: { width, height, depth } }]`): every room of the building, sensors or not. Room `position` = footprint centre at floor level. Absent = no geometry; never empty; every side > 0. -- Every parser accepts geometry, so one body fits every simulator. ap-simulator needs it to derive RSSI from distance. +- No coordinates: positions are digital-twin's. ap-simulator models signal overlap from rooms alone. - No `targetUrl`: a simulator posts to its own `TELEMETRY_INGEST_URL`, so no caller can redirect its signed readings. The schema refuses extra keys, so the old body fails. --- diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index cc85ae3d..c1d86d8f 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -179,8 +179,8 @@ Positions are **not** stored here: they are physical layout and belong to digita - Readings are room-keyed, so an outdoor reading has nowhere to go. Compose gives routers to ap-simulator. - - Body pinned by `schemas/fixtures/simulation-start.json`; no `targetUrl` - Three simulators in two languages parse it; a simulator posts to its own `TELEMETRY_INGEST_URL`. - - - Sensor `position` and building `rooms` are optional; omitted when telemetry has none - - Coordinates are the twin's, not telemetry's. An empty `rooms` is never sent: it would claim a building with no rooms. + - - No coordinates in the body + - Positions are digital-twin's bounded context; telemetry never holds or forwards them. - - Calls run concurrently, 5 s timeout each; all are tried, then any failure is `502` - One dead simulator must not leave the others stale. A retry is safe: start replaces. - - Status is `running` when any simulator runs the building; an unreachable one counts as not running diff --git a/schemas/CLAUDE.md b/schemas/CLAUDE.md index d226cc8e..2989d359 100644 --- a/schemas/CLAUDE.md +++ b/schemas/CLAUDE.md @@ -19,8 +19,7 @@ nothing failing to compile. built by telemetry in Rust and parsed by sensor-simulator, aq-simulator and ap-simulator. Telemetry is its only Rust party, so its serialiser stays in telemetry and the crate holds no type. Each case names its `consumer` (`sensor-simulator`, `aq-simulator`, `ap-simulator`, `any`). -Geometry (`position`, `rooms`) is optional and every parser accepts it, so telemetry can send one -shape to all of them. +**No coordinates, ever**: they are digital-twin's. A simulator gets sensor ids and rooms only. **The Cedar bundle is the exception — it stays in `backend/libs/auth-policy`.** Its fixture holds golden *decisions*, not a wire shape, is meaningless without `policy.cedar` and diff --git a/schemas/fixtures/simulation-start.json b/schemas/fixtures/simulation-start.json index b79d8725..ce5f6d27 100644 --- a/schemas/fixtures/simulation-start.json +++ b/schemas/fixtures/simulation-start.json @@ -4,9 +4,7 @@ "sensors": "Replaces everything the simulator held for the building. May be empty: the simulator then stops simulating the building, so a sensor removed in the twin stops producing readings.", "roomId": "Always a room of the building. Readings are room-keyed, so telemetry never sends an outdoor sensor.", "sensorType": "A device kind from telemetry's catalog (`GET /telemetry/devices`), never a metric. A simulator ignores a kind it does not simulate.", - "targetUrl": "Absent on purpose. A simulator reads where to post readings from its own TELEMETRY_INGEST_URL, so no caller can redirect its signed batches.", - "position": "Optional. Where the twin places the sensor, in the twin's frame (y up). Absent when the sensor is not placed yet.", - "rooms": "Optional. Every room of the building, including those with no sensor: simulated people walk through rooms nobody measures. Room `position` is the footprint centre at floor level; `dimensions` extend width along x, height up y, depth along z. Absent means the caller has no geometry; never empty." + "targetUrl": "Absent on purpose. A simulator reads where to post readings from its own TELEMETRY_INGEST_URL, so no caller can redirect its signed batches." }, "cases": [ { @@ -48,7 +46,7 @@ } }, { - "name": "two placed routers in a building of three rooms, one room without a router", + "name": "two routers in two rooms", "consumer": "ap-simulator", "body": { "buildingId": "bldg-3f2b4c5d", @@ -56,52 +54,12 @@ { "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", "sensorType": "router", - "roomId": "room-lab-2", - "position": { "x": 3.5, "y": 2.6, "z": -1.0 } + "roomId": "room-lab-2" }, { "sensorId": "f7081920-3b4c-4d5e-9f60-718293a4b5c6", "sensorType": "router", - "roomId": "room-aula-magna", - "position": { "x": 14.0, "y": 3.5, "z": 0.5 } - } - ], - "rooms": [ - { - "roomId": "room-lab-2", - "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, - "dimensions": { "width": 8.0, "height": 3.0, "depth": 6.0 } - }, - { - "roomId": "room-aula-magna", - "position": { "x": 12.0, "y": 0.0, "z": 0.0 }, - "dimensions": { "width": 12.0, "height": 4.0, "depth": 10.0 } - }, - { - "roomId": "room-corridor", - "position": { "x": 7.0, "y": 0.0, "z": 6.0 }, - "dimensions": { "width": 22.0, "height": 3.0, "depth": 2.0 } - } - ] - } - }, - { - "name": "a router not placed yet carries no position", - "consumer": "ap-simulator", - "body": { - "buildingId": "bldg-3f2b4c5d", - "sensors": [ - { - "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", - "sensorType": "router", - "roomId": "room-lab-2" - } - ], - "rooms": [ - { - "roomId": "room-lab-2", - "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, - "dimensions": { "width": 8.0, "height": 3.0, "depth": 6.0 } + "roomId": "room-aula-magna" } ] } @@ -145,59 +103,6 @@ "sensors": [], "targetUrl": "http://localhost/telemetry/" } - }, - { - "name": "a position missing a coordinate", - "reason": "position: needs x, y and z", - "body": { - "buildingId": "bldg-3f2b4c5d", - "sensors": [ - { - "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", - "sensorType": "router", - "roomId": "room-lab-2", - "position": { "x": 3.5, "z": -1.0 } - } - ] - } - }, - { - "name": "an empty rooms list", - "reason": "rooms: absent means no geometry; an empty list would claim a building with no rooms", - "body": { - "buildingId": "bldg-3f2b4c5d", - "sensors": [], - "rooms": [] - } - }, - { - "name": "a room with no dimensions", - "reason": "rooms: a room is a box, so it needs position and dimensions", - "body": { - "buildingId": "bldg-3f2b4c5d", - "sensors": [], - "rooms": [ - { - "roomId": "room-lab-2", - "position": { "x": 2.0, "y": 0.0, "z": 0.0 } - } - ] - } - }, - { - "name": "a room with no width", - "reason": "dimensions: every side must be greater than zero", - "body": { - "buildingId": "bldg-3f2b4c5d", - "sensors": [], - "rooms": [ - { - "roomId": "room-lab-2", - "position": { "x": 2.0, "y": 0.0, "z": 0.0 }, - "dimensions": { "width": 0.0, "height": 3.0, "depth": 6.0 } - } - ] - } } ] } diff --git a/schemas/json/simulation-start.schema.json b/schemas/json/simulation-start.schema.json index 890e1d1f..2d716734 100644 --- a/schemas/json/simulation-start.schema.json +++ b/schemas/json/simulation-start.schema.json @@ -13,12 +13,6 @@ "type": "array", "description": "May be empty: the simulator then stops simulating the building.", "items": { "$ref": "#/$defs/sensor" } - }, - "rooms": { - "type": "array", - "minItems": 1, - "description": "Optional building geometry: every room, including rooms with no sensor. Absent means the caller has none; never empty.", - "items": { "$ref": "#/$defs/room" } } }, "$defs": { @@ -37,44 +31,6 @@ "type": "string", "minLength": 1, "description": "Readings are room-keyed, so an outdoor sensor is never sent." - }, - "position": { - "$ref": "#/$defs/coordinates", - "description": "Where the twin places the sensor; absent when it is not placed yet." - } - } - }, - "coordinates": { - "type": "object", - "required": ["x", "y", "z"], - "additionalProperties": false, - "description": "The twin's frame: y up.", - "properties": { - "x": { "type": "number" }, - "y": { "type": "number" }, - "z": { "type": "number" } - } - }, - "room": { - "type": "object", - "required": ["roomId", "position", "dimensions"], - "additionalProperties": false, - "properties": { - "roomId": { "type": "string", "minLength": 1 }, - "position": { - "$ref": "#/$defs/coordinates", - "description": "The footprint centre at floor level." - }, - "dimensions": { - "type": "object", - "required": ["width", "height", "depth"], - "additionalProperties": false, - "description": "Width along x, height up y, depth along z.", - "properties": { - "width": { "type": "number", "exclusiveMinimum": 0 }, - "height": { "type": "number", "exclusiveMinimum": 0 }, - "depth": { "type": "number", "exclusiveMinimum": 0 } - } } } } diff --git a/simulators/ap-simulator/api.py b/simulators/ap-simulator/api.py index 0b78f72b..36fd76d5 100644 --- a/simulators/ap-simulator/api.py +++ b/simulators/ap-simulator/api.py @@ -13,7 +13,7 @@ from fastapi.responses import JSONResponse import scenarios -from building import layout +from building import simulate from schemas import ( BuildingStart, BuildingStatus, @@ -28,7 +28,7 @@ app = FastAPI(title="AP Simulator (fake ubus)") -DEVICES_PER_ROOM = int(os.environ.get("AP_SIM_DEVICES_PER_ROOM", "4")) +PHONES_PER_ROOM = int(os.environ.get("AP_SIM_PHONES_PER_ROOM", "4")) _scenario_name = os.environ.get("AP_SIM_SCENARIO", "corridor") world = World(scenarios.PRESETS[_scenario_name]()) @@ -92,12 +92,15 @@ async def ubus_rpc(ap_id: str, request: Request) -> JSONResponse: @app.post("/control/start") def start(body: BuildingStart) -> dict: """Replaces what the building simulates; no router means stop simulating it.""" - config = layout(body, DEVICES_PER_ROOM) - if config is None: + simulated = simulate(body, PHONES_PER_ROOM) + if simulated is None: return stop(StopRequest(buildingId=body.buildingId)) - buildings[body.buildingId] = World(config) + buildings[body.buildingId] = simulated logger.info( - "building=%r aps=%d devices=%d", body.buildingId, len(config.aps), len(config.devices) + "building=%r aps=%d phones=%d", + body.buildingId, + len(simulated.aps), + len(simulated.device_macs), ) return {"message": f"Simulator started for {body.buildingId}"} diff --git a/simulators/ap-simulator/building.py b/simulators/ap-simulator/building.py index 0e89d51c..60dba2f0 100644 --- a/simulators/ap-simulator/building.py +++ b/simulators/ap-simulator/building.py @@ -1,91 +1,149 @@ -"""Turns telemetry's start body into a World: one AP per router, people walking the rooms. +"""One building's routers as APs, from the rooms telemetry names — no coordinates. -The twin is y-up; the World's floor plane is (x, y) with z as height, so twin (x, y, z) -maps to World (x, z, y). +A phone sits in one room at a time: its room's routers hear it strongly, the router of the +neighbouring room it stands near hears it weakly, other neighbours barely, nobody else. """ from __future__ import annotations import random +import time +from dataclasses import dataclass -from schemas import ( - ApConfig, - BuildingStart, - Coordinates, - DeviceRoute, - Dimensions, - Room, - ScenarioConfig, - SimulatedSensor, - Waypoint, -) +from schemas import ApConfig, BuildingStart, ScenarioConfig +from world import World ROUTER = "router" -PHONE_HEIGHT_M = 1.2 -CEILING_GAP_M = 0.3 -WALL_MARGIN_M = 0.5 -FALLBACK_ROOM = Dimensions(width=8.0, height=3.0, depth=8.0) -def layout(start: BuildingStart, devices_per_room: int) -> ScenarioConfig | None: - """The building's APs and devices; None when it has no router to simulate.""" - routers = [s for s in start.sensors if s.sensorType == ROUTER] - if not routers: - return None - # Without rooms a twin position has nothing to be measured against, so it is ignored. - geometry = start.rooms is not None - rooms = start.rooms or _fallback_rooms(routers) - by_id = {room.roomId: room for room in rooms} - aps = [ - _ap(router, by_id[router.roomId], geometry) for router in routers if router.roomId in by_id - ] - # Seeded by building so a restart walks the same people through the same rooms. - rng = random.Random(start.buildingId) - devices = [_device(rng, rooms) for _ in range(devices_per_room * len(rooms))] - return ScenarioConfig(aps=aps, devices=devices) - - -def _fallback_rooms(routers: list[SimulatedSensor]) -> list[Room]: - room_ids = sorted({router.roomId for router in routers}) - return [ - Room( - roomId=room_id, - position=Coordinates(x=index * FALLBACK_ROOM.width, y=0.0, z=0.0), - dimensions=FALLBACK_ROOM, +@dataclass(frozen=True) +class _Stop: + room: str + hold_s: float + edge: float + """0 = middle of the room, 1 = at the door to `toward`.""" + toward: str | None + + +@dataclass(frozen=True) +class _Phone: + mac: str + stops: tuple[_Stop, ...] + offset_s: float + arrive_h: float + leave_h: float + + def present_at(self, now_s: float) -> bool: + local = time.localtime(now_s) + return self.arrive_h <= local.tm_hour + local.tm_min / 60 < self.leave_h + + def stop_at(self, now_s: float) -> _Stop: + t = (now_s - self.offset_s) % sum(stop.hold_s for stop in self.stops) + for stop in self.stops: + if t < stop.hold_s: + return stop + t -= stop.hold_s + return self.stops[-1] + + +class BuildingWorld(World): + """A World whose phones move between rooms instead of along coordinates.""" + + def __init__(self, start: BuildingStart, phones_per_room: int, seed: int | None) -> None: + routers = [s for s in start.sensors if s.sensorType == ROUTER] + super().__init__( + ScenarioConfig( + aps=[ApConfig(id=r.sensorId, zone_id=r.roomId) for r in routers], devices=[] + ), + seed, ) - for index, room_id in enumerate(room_ids) - ] - - -def _ap(router: SimulatedSensor, room: Room, geometry: bool) -> ApConfig: - at = router.position if geometry and router.position is not None else None - if at is None: - ceiling = room.position.y + room.dimensions.height - CEILING_GAP_M - at = Coordinates(x=room.position.x, y=ceiling, z=room.position.z) - return ApConfig(id=router.sensorId, zone_id=router.roomId, x=at.x, y=at.z, z=at.y) + # Seeded by building so a restart gets the same neighbours and walks the same people. + rng = random.Random(start.buildingId) + rooms = sorted({r.roomId for r in routers}) + self.neighbours = _neighbours(rng, rooms) + self._gain = {r.sensorId: rng.uniform(-3.0, 3.0) for r in routers} + self._phones = [ + _phone(rng, rooms, self.neighbours) for _ in range(phones_per_room * len(rooms)) + ] + + @property + def device_macs(self) -> list[str]: + return [phone.mac for phone in self._phones] + + def clients(self, ap_id: str, now_s: float | None = None) -> list[tuple[str, int]]: + """(mac, rssi) for every phone in the building this AP hears.""" + room = self.aps[ap_id].zone_id + now = now_s if now_s is not None else time.time() + out: list[tuple[str, int]] = [] + for phone in self._phones: + if not phone.present_at(now): + continue + level = self._level(phone.stop_at(now), room) + if level is None: + continue + signal = level + self._gain[ap_id] + self._rng.gauss(0, self.config.noise_stddev_db) + if signal >= self.config.sensitivity_dbm: + out.append((phone.mac, round(signal))) + return out + + def ground_truth_zone(self, mac: str, now_s: float | None = None) -> str | None: + """The room the phone is in, or None while it is out of the building.""" + now = now_s if now_s is not None else time.time() + phone = next((p for p in self._phones if p.mac == mac), None) + if phone is None or not phone.present_at(now): + return None + return phone.stop_at(now).room + + def _level(self, stop: _Stop, room: str) -> float | None: + if room == stop.room: + return -45.0 - 12.0 * stop.edge + if room == stop.toward: + return -80.0 + 15.0 * stop.edge + if room in self.neighbours[stop.room]: + return -82.0 + return None -def _device(rng: random.Random, rooms: list[Room]) -> DeviceRoute: - stops = [rng.choice(rooms) for _ in range(rng.randint(2, 4))] - return DeviceRoute( +def simulate( + start: BuildingStart, phones_per_room: int, seed: int | None = None +) -> BuildingWorld | None: + """The building's world; None when it has no router to simulate.""" + if not any(s.sensorType == ROUTER for s in start.sensors): + return None + return BuildingWorld(start, phones_per_room, seed) + + +def _neighbours(rng: random.Random, rooms: list[str]) -> dict[str, frozenset[str]]: + order = rooms[:] + rng.shuffle(order) + pairs = list(zip(order, order[1:])) + if len(order) > 2: + pairs.append((order[-1], order[0])) + return { + room: frozenset(b if a == room else a for a, b in pairs if room in (a, b)) for room in rooms + } + + +def _phone(rng: random.Random, rooms: list[str], neighbours: dict[str, frozenset[str]]) -> _Phone: + room = rng.choice(rooms) + stops: list[_Stop] = [] + for _ in range(rng.randint(3, 5)): + near = sorted(neighbours[room]) + stops.append( + _Stop( + room=room, + hold_s=rng.uniform(120.0, 900.0), + edge=rng.random(), + toward=rng.choice(near) if near else None, + ) + ) + room = rng.choice(near) if near and rng.random() < 0.7 else rng.choice(rooms) + return _Phone( mac=_mac(rng), - waypoints=[_spot(rng, room, hold_s=rng.uniform(120.0, 900.0)) for room in stops], - speed_mps=rng.uniform(0.9, 1.4), - phase_offset_s=rng.uniform(0.0, 3600.0), - present_h=(rng.gauss(8.5, 1.0), rng.gauss(17.5, 1.2)), - ) - - -def _spot(rng: random.Random, room: Room, hold_s: float) -> Waypoint: - def along(centre: float, side: float) -> float: - reach = max(side / 2 - WALL_MARGIN_M, 0.0) - return rng.uniform(centre - reach, centre + reach) - - return Waypoint( - x=along(room.position.x, room.dimensions.width), - y=along(room.position.z, room.dimensions.depth), - z=room.position.y + PHONE_HEIGHT_M, - hold_s=hold_s, + stops=tuple(stops), + offset_s=rng.uniform(0.0, 3600.0), + arrive_h=rng.gauss(8.5, 1.0), + leave_h=rng.gauss(17.5, 1.2), ) diff --git a/simulators/ap-simulator/docker-compose.yml b/simulators/ap-simulator/docker-compose.yml index 6d379672..52302be0 100644 --- a/simulators/ap-simulator/docker-compose.yml +++ b/simulators/ap-simulator/docker-compose.yml @@ -5,6 +5,6 @@ services: context: . environment: - AP_SIM_SCENARIO=${AP_SIM_SCENARIO:-corridor} - - AP_SIM_DEVICES_PER_ROOM=${AP_SIM_DEVICES_PER_ROOM:-4} + - AP_SIM_PHONES_PER_ROOM=${AP_SIM_PHONES_PER_ROOM:-4} ports: - "3003:3000" diff --git a/simulators/ap-simulator/schemas.py b/simulators/ap-simulator/schemas.py index fdcc0ff2..831e7752 100644 --- a/simulators/ap-simulator/schemas.py +++ b/simulators/ap-simulator/schemas.py @@ -13,7 +13,6 @@ class ApConfig(BaseModel): zone_id: str = "" x: float = 0.0 y: float = 0.0 - z: float = 0.0 username: str = "collector" password: str = "collector" session_ttl_s: float = 300.0 @@ -35,7 +34,6 @@ def model_post_init(self, _context: Any, /) -> None: class Waypoint(BaseModel): x: float y: float - z: float = 0.0 hold_s: float = 0.0 @@ -44,8 +42,6 @@ class DeviceRoute(BaseModel): waypoints: list[Waypoint] speed_mps: float = 1.2 phase_offset_s: float = 0.0 - # Local hours [from, until) the device is in the building; None means always. - present_h: tuple[float, float] | None = None @field_validator("waypoints") @classmethod @@ -92,36 +88,6 @@ def exactly_one(self) -> ScenarioRequest: NonEmpty = Annotated[str, Field(min_length=1)] -Finite = Annotated[float, Field(allow_inf_nan=False)] -Side = Annotated[float, Field(gt=0, allow_inf_nan=False)] - - -class Coordinates(BaseModel): - """A point in the twin's frame, y up.""" - - model_config = ConfigDict(extra="forbid") - - x: Finite - y: Finite - z: Finite - - -class Dimensions(BaseModel): - model_config = ConfigDict(extra="forbid") - - width: Side - height: Side - depth: Side - - -class Room(BaseModel): - """A room as a box; `position` is its footprint centre at floor level.""" - - model_config = ConfigDict(extra="forbid") - - roomId: NonEmpty - position: Coordinates - dimensions: Dimensions class SimulatedSensor(BaseModel): @@ -130,7 +96,6 @@ class SimulatedSensor(BaseModel): sensorId: NonEmpty sensorType: NonEmpty roomId: NonEmpty - position: Coordinates | None = None class BuildingStart(BaseModel): @@ -141,5 +106,3 @@ class BuildingStart(BaseModel): buildingId: NonEmpty sensors: list[SimulatedSensor] - # Absent means no geometry; an empty list would claim a building with no rooms. - rooms: Annotated[list[Room], Field(min_length=1)] | None = None diff --git a/simulators/ap-simulator/tests/test_building.py b/simulators/ap-simulator/tests/test_building.py index 9f31994f..171bc996 100644 --- a/simulators/ap-simulator/tests/test_building.py +++ b/simulators/ap-simulator/tests/test_building.py @@ -1,110 +1,113 @@ import json -import math import time from pathlib import Path -from building import layout +from building import simulate from schemas import BuildingStart -from world import World _FIXTURE = json.loads( ( Path(__file__).resolve().parents[3] / "schemas" / "fixtures" / "simulation-start.json" ).read_text() ) +TWO_ROOMS = BuildingStart.model_validate( + next(c for c in _FIXTURE["cases"] if c["consumer"] == "ap-simulator")["body"] +) -def _start(name_part: str) -> BuildingStart: - case = next(c for c in _FIXTURE["cases"] if name_part in c["name"]) - return BuildingStart.model_validate(case["body"]) +def _at(hour: int, minute: int = 0) -> float: + return time.mktime((2026, 9, 23, hour, minute, 0, 0, 0, -1)) -def _at(hour: int) -> float: - return time.mktime((2026, 9, 23, hour, 0, 0, 0, 0, -1)) +def _routers(*rooms: str) -> BuildingStart: + return BuildingStart.model_validate( + { + "buildingId": "b1", + "sensors": [ + {"sensorId": f"r{i}", "sensorType": "router", "roomId": room} + for i, room in enumerate(rooms) + ], + } + ) -PLACED = _start("two placed routers") -UNPLACED = _start("not placed yet") +def _quiet(start: BuildingStart, phones_per_room: int = 6): + world = simulate(start, phones_per_room=phones_per_room, seed=1) + assert world is not None + world.config.noise_stddev_db = 0.0 + return world -def test_each_router_becomes_an_ap_in_its_room(): - config = layout(PLACED, devices_per_room=4) - assert config is not None - assert {(ap.id, ap.zone_id) for ap in config.aps} == { - (s.sensorId, s.roomId) for s in PLACED.sensors - } +def _heard(world, now_s: float) -> dict[str, dict[str, int]]: + """mac -> router -> rssi, over every router.""" + out: dict[str, dict[str, int]] = {} + for ap in world.aps: + for mac, rssi in world.clients(ap, now_s=now_s): + out.setdefault(mac, {})[ap] = rssi + return out -def test_a_placed_router_sits_where_the_twin_put_it(): - config = layout(PLACED, devices_per_room=4) - assert config is not None - ap = next(ap for ap in config.aps if ap.id == PLACED.sensors[0].sensorId) - assert (ap.x, ap.y, ap.z) == (3.5, -1.0, 2.6) +def test_each_router_is_an_ap_in_its_room_and_other_kinds_are_ignored(): + body = TWO_ROOMS.model_dump() + body["sensors"].append({"sensorId": "t1", "sensorType": "temperature", "roomId": "x"}) + world = simulate(BuildingStart.model_validate(body), phones_per_room=4) + assert world is not None + assert {(ap.id, ap.zone_id) for ap in world.aps.values()} == { + (s.sensorId, s.roomId) for s in TWO_ROOMS.sensors + } -def test_an_unplaced_router_hangs_under_its_room_ceiling_at_the_centre(): - config = layout(UNPLACED, devices_per_room=4) - assert config is not None - (ap,) = config.aps - assert (ap.x, ap.y) == (2.0, 0.0) - assert 2.0 < ap.z < 3.0 +def test_no_router_means_nothing_to_simulate(): + assert simulate(_routers(), phones_per_room=4) is None -def test_other_kinds_get_no_ap_and_no_router_means_nothing_to_simulate(): - body = PLACED.model_dump(exclude_none=True) - for sensor in body["sensors"]: - sensor["sensorType"] = "temperature" - assert layout(BuildingStart.model_validate(body), devices_per_room=4) is None +def test_a_phone_between_two_routers_is_heard_by_both_louder_in_its_own_room(): + world = _quiet(TWO_ROOMS) + noon = _at(11) + both = {mac: by_ap for mac, by_ap in _heard(world, noon).items() if len(by_ap) == 2} + assert both + for mac, by_ap in both.items(): + own = world.ground_truth_zone(mac, now_s=noon) + loudest = max(by_ap, key=lambda ap: by_ap[ap]) + assert world.aps[loudest].zone_id == own -def test_devices_walk_through_every_room_even_one_without_a_router(): - config = layout(PLACED, devices_per_room=4) - assert config is not None - assert len(config.devices) == 4 * len(PLACED.rooms or []) - corridor = next(r for r in PLACED.rooms or [] if r.roomId == "room-corridor") - inside = [ - wp - for d in config.devices - for wp in d.waypoints - if abs(wp.x - corridor.position.x) <= corridor.dimensions.width / 2 - and abs(wp.y - corridor.position.z) <= corridor.dimensions.depth / 2 - ] - assert inside +def test_only_a_neighbouring_room_hears_a_phone(): + world = _quiet(_routers("a", "b", "c", "d")) + noon = _at(11) + for mac, by_ap in _heard(world, noon).items(): + own = world.ground_truth_zone(mac, now_s=noon) + assert own is not None + for ap in by_ap: + room = world.aps[ap].zone_id + assert room == own or room in world.neighbours[own] + assert any(len(near) < 3 for near in world.neighbours.values()) -def test_one_building_always_gets_the_same_layout(): - assert layout(PLACED, devices_per_room=4) == layout(PLACED, devices_per_room=4) +def test_two_routers_in_one_room_hear_the_same_phone_differently(): + world = _quiet(_routers("a", "a")) + heard = _heard(world, _at(11)) + assert any(len(set(by_ap.values())) == 2 for by_ap in heard.values() if len(by_ap) == 2) -def test_without_rooms_each_router_room_gets_its_own_box(): - body = PLACED.model_dump(exclude_none=True) - del body["rooms"] - config = layout(BuildingStart.model_validate(body), devices_per_room=4) - assert config is not None - a, b = config.aps - assert math.dist((a.x, a.y), (b.x, b.y)) > 0 - assert len(config.devices) == 8 +def test_one_building_always_gets_the_same_neighbours_and_people(): + first, again = _quiet(_routers("a", "b", "c", "d")), _quiet(_routers("a", "b", "c", "d")) + assert first.neighbours == again.neighbours + noon = _at(11) + assert {m: first.ground_truth_zone(m, now_s=noon) for m in first.device_macs} == { + m: again.ground_truth_zone(m, now_s=noon) for m in again.device_macs + } -def test_a_device_between_two_routers_is_heard_by_both_louder_by_the_nearer(): - config = layout(PLACED, devices_per_room=4) - assert config is not None - world = World(config.model_copy(update={"noise_stddev_db": 0.0}), seed=1) - lab, aula = (s.sensorId for s in PLACED.sensors) - noon = _at(11) - heard_lab = dict(world.clients(lab, now_s=noon)) - heard_aula = dict(world.clients(aula, now_s=noon)) - both = heard_lab.keys() & heard_aula.keys() - assert both - assert any(heard_lab[mac] != heard_aula[mac] for mac in both) +def test_phones_move_between_rooms_over_the_day(): + world = _quiet(_routers("a", "b", "c")) + rooms = { + mac: {world.ground_truth_zone(mac, now_s=_at(h, m)) for h in range(10, 16) for m in (0, 30)} + for mac in world.device_macs + } + assert any(len(visited - {None}) > 1 for visited in rooms.values()) def test_the_building_empties_at_night(): - config = layout(PLACED, devices_per_room=4) - assert config is not None - world = World(config, seed=1) - heard = { - hour: {mac for ap in world.aps for mac, _ in world.clients(ap, now_s=_at(hour))} - for hour in (3, 11) - } - assert len(heard[3]) < len(heard[11]) + world = _quiet(TWO_ROOMS) + assert len(_heard(world, _at(3))) < len(_heard(world, _at(11))) diff --git a/simulators/ap-simulator/tests/test_control.py b/simulators/ap-simulator/tests/test_control.py index 80e9f9a7..b8ee9cc2 100644 --- a/simulators/ap-simulator/tests/test_control.py +++ b/simulators/ap-simulator/tests/test_control.py @@ -10,9 +10,9 @@ Path(__file__).resolve().parents[3] / "schemas" / "fixtures" / "simulation-start.json" ).read_text() ) -PLACED = next(c for c in _FIXTURE["cases"] if "two placed routers" in c["name"])["body"] -BUILDING = PLACED["buildingId"] -ROUTER = PLACED["sensors"][0]["sensorId"] +ROUTERS = next(c for c in _FIXTURE["cases"] if c["consumer"] == "ap-simulator")["body"] +BUILDING = ROUTERS["buildingId"] +ROUTER = ROUTERS["sensors"][0]["sensorId"] def _client() -> TestClient: @@ -41,14 +41,14 @@ def _login(client: TestClient, ap_id: str) -> int: def test_start_runs_the_building_and_serves_one_ap_per_router(): client = _client() - assert client.post("/control/start", json=PLACED).status_code == 200 + assert client.post("/control/start", json=ROUTERS).status_code == 200 assert _running(client) assert _login(client, ROUTER) == 200 def test_an_empty_start_stops_the_building(): client = _client() - client.post("/control/start", json=PLACED) + client.post("/control/start", json=ROUTERS) client.post("/control/start", json={"buildingId": BUILDING, "sensors": []}) assert not _running(client) assert _login(client, ROUTER) == 404 @@ -56,21 +56,21 @@ def test_an_empty_start_stops_the_building(): def test_a_router_removed_before_a_restart_stops_answering(): client = _client() - client.post("/control/start", json=PLACED) - client.post("/control/start", json={**PLACED, "sensors": PLACED["sensors"][1:]}) + client.post("/control/start", json=ROUTERS) + client.post("/control/start", json={**ROUTERS, "sensors": ROUTERS["sensors"][1:]}) assert _login(client, ROUTER) == 404 def test_stop_ends_the_building_and_an_unknown_one_is_already_stopped(): client = _client() - client.post("/control/start", json=PLACED) + client.post("/control/start", json=ROUTERS) assert client.post("/control/stop", json={"buildingId": BUILDING}).status_code == 200 assert client.post("/control/stop", json={"buildingId": "ghost"}).status_code == 200 assert not _running(client) def test_the_old_body_that_chose_its_own_ingest_target_is_refused(): - body = {**PLACED, "targetUrl": "http://localhost/telemetry/"} + body = {**ROUTERS, "targetUrl": "http://localhost/telemetry/"} assert _client().post("/control/start", json=body).status_code == 422 diff --git a/simulators/ap-simulator/world.py b/simulators/ap-simulator/world.py index a0ffcc33..65c3ee04 100644 --- a/simulators/ap-simulator/world.py +++ b/simulators/ap-simulator/world.py @@ -15,13 +15,12 @@ from schemas import ApConfig, DeviceRoute, ScenarioConfig -Point = tuple[float, float, float] - - @dataclass class _Segment: - start: Point - end: Point + start_x: float + start_y: float + end_x: float + end_y: float hold_s: float travel_s: float @@ -35,44 +34,34 @@ def _route_segments(route: DeviceRoute) -> list[_Segment]: segments: list[_Segment] = [] for i, wp in enumerate(points): nxt = points[(i + 1) % len(points)] - start, end = (wp.x, wp.y, wp.z), (nxt.x, nxt.y, nxt.z) - travel_s = math.dist(start, end) / route.speed_mps if route.speed_mps > 0 else 0.0 - segments.append(_Segment(start, end, wp.hold_s, travel_s)) + dist = math.hypot(nxt.x - wp.x, nxt.y - wp.y) + travel_s = dist / route.speed_mps if route.speed_mps > 0 else 0.0 + segments.append(_Segment(wp.x, wp.y, nxt.x, nxt.y, wp.hold_s, travel_s)) return segments -def _local_hour(now_s: float) -> float: - local = time.localtime(now_s) - return local.tm_hour + local.tm_min / 60 - - @dataclass class _Device: mac: str segments: list[_Segment] cycle_s: float start_offset_s: float - present_h: tuple[float, float] | None - - def present_at(self, now_s: float) -> bool: - if self.present_h is None: - return True - arrive, leave = self.present_h - return arrive <= _local_hour(now_s) < leave - def position_at(self, now_s: float) -> Point: + def position_at(self, now_s: float) -> tuple[float, float]: t = (now_s - self.start_offset_s) % self.cycle_s for seg in self.segments: if t < seg.hold_s: - return seg.start + return seg.start_x, seg.start_y t -= seg.hold_s if t < seg.travel_s: frac = t / seg.travel_s if seg.travel_s > 0 else 1.0 - x0, y0, z0 = seg.start - x1, y1, z1 = seg.end - return x0 + (x1 - x0) * frac, y0 + (y1 - y0) * frac, z0 + (z1 - z0) * frac + return ( + seg.start_x + (seg.end_x - seg.start_x) * frac, + seg.start_y + (seg.end_y - seg.start_y) * frac, + ) t -= seg.travel_s - return self.segments[-1].end + last = self.segments[-1] + return last.end_x, last.end_y @dataclass @@ -94,7 +83,7 @@ def __init__(self, config: ScenarioConfig, seed: int | None = None) -> None: def _build_device(route: DeviceRoute) -> _Device: segments = _route_segments(route) cycle_s = sum(seg.duration_s for seg in segments) or 1.0 - return _Device(route.mac, segments, cycle_s, route.phase_offset_s, route.present_h) + return _Device(route.mac, segments, cycle_s, route.phase_offset_s) @property def device_macs(self) -> list[str]: @@ -129,15 +118,14 @@ def check_session(self, ap_id: str, token: str) -> bool: and time.monotonic() < session.expires_at ) - def clients(self, ap_id: str, now_s: float | None = None) -> list[tuple[str, int]]: - """(mac, rssi) for every device currently in the building and within earshot of this AP.""" + def clients(self, ap_id: str) -> list[tuple[str, int]]: + """(mac, rssi) for every device currently within earshot of this AP.""" ap = self.aps[ap_id] - now = now_s if now_s is not None else time.time() + now = time.time() out: list[tuple[str, int]] = [] for device in self._devices: - if not device.present_at(now): - continue - distance = math.dist(device.position_at(now), (ap.x, ap.y, ap.z)) + x, y = device.position_at(now) + distance = math.hypot(x - ap.x, y - ap.y) signal = rssi_dbm(distance, self.config.tx_power_dbm, self.config.path_loss_exponent) signal += self._rng.gauss(0, self.config.noise_stddev_db) if signal >= self.config.sensitivity_dbm: @@ -151,6 +139,6 @@ def ground_truth_zone(self, mac: str, now_s: float | None = None) -> str | None: device = next((d for d in self._devices if d.mac == mac), None) if device is None: return None - here = device.position_at(now) - nearest = min(self.aps.values(), key=lambda ap: math.dist(here, (ap.x, ap.y, ap.z))) + x, y = device.position_at(now) + nearest = min(self.aps.values(), key=lambda ap: math.hypot(x - ap.x, y - ap.y)) return nearest.zone_id diff --git a/simulators/aq-simulator/schemas.py b/simulators/aq-simulator/schemas.py index 40d9ca4b..f7f84939 100644 --- a/simulators/aq-simulator/schemas.py +++ b/simulators/aq-simulator/schemas.py @@ -13,36 +13,6 @@ def _strip_newlines(value: str) -> str: NonEmpty = Annotated[str, Field(min_length=1)] -Finite = Annotated[float, Field(allow_inf_nan=False)] -Side = Annotated[float, Field(gt=0, allow_inf_nan=False)] - - -class Coordinates(BaseModel): - """A point in the twin's frame, y up.""" - - model_config = ConfigDict(extra="forbid") - - x: Finite - y: Finite - z: Finite - - -class Dimensions(BaseModel): - model_config = ConfigDict(extra="forbid") - - width: Side - height: Side - depth: Side - - -class Room(BaseModel): - """A room as a box; `position` is its footprint centre at floor level.""" - - model_config = ConfigDict(extra="forbid") - - roomId: NonEmpty - position: Coordinates - dimensions: Dimensions class SimulatedSensor(BaseModel): @@ -51,7 +21,6 @@ class SimulatedSensor(BaseModel): sensorId: NonEmpty sensorType: NonEmpty roomId: NonEmpty - position: Coordinates | None = None @field_validator("sensorId", "sensorType", "roomId") @classmethod @@ -67,8 +36,6 @@ class BuildingConfig(BaseModel): buildingId: NonEmpty sensors: list[SimulatedSensor] - # Absent means no geometry; an empty list would claim a building with no rooms. - rooms: Annotated[list[Room], Field(min_length=1)] | None = None scenario: Scenario = Scenario.CLEAN_INDOOR interval_seconds: float = 10.0 diff --git a/simulators/aq-simulator/tests/test_start_contract.py b/simulators/aq-simulator/tests/test_start_contract.py index 003365c3..854881f0 100644 --- a/simulators/aq-simulator/tests/test_start_contract.py +++ b/simulators/aq-simulator/tests/test_start_contract.py @@ -29,8 +29,7 @@ def _body(consumer: str) -> dict: def test_every_start_the_fixture_accepts_validates(case): config = BuildingConfig.model_validate(case["body"]) assert config.buildingId == case["body"]["buildingId"] - assert [s.model_dump(exclude_none=True) for s in config.sensors] == case["body"]["sensors"] - assert [r.model_dump() for r in config.rooms or []] == case["body"].get("rooms", []) + assert [s.model_dump() for s in config.sensors] == case["body"]["sensors"] @pytest.mark.parametrize("case", _FIXTURE["rejected"], ids=lambda c: c["name"]) diff --git a/simulators/sensor-simulator/src/models/signal.ts b/simulators/sensor-simulator/src/models/signal.ts index e79d33c6..dbdb0eb0 100644 --- a/simulators/sensor-simulator/src/models/signal.ts +++ b/simulators/sensor-simulator/src/models/signal.ts @@ -13,29 +13,15 @@ export const mySimulationBuildings: SimulationBuildings = { }, }; -export interface ICoordinates { - x: number; - y: number; - z: number; -} - export interface ISimulatedSensor { sensorId: string; sensorType: string; roomId: string; - position?: ICoordinates; -} - -export interface IRoom { - roomId: string; - position: ICoordinates; - dimensions: { width: number; height: number; depth: number }; } export interface IBuilding { buildingId: string; sensors: ISimulatedSensor[]; - rooms?: IRoom[]; } const isRecord = (value: unknown): value is Record => @@ -47,74 +33,25 @@ const onlyKeys = (value: Record, keys: string[]) => const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.length > 0; -const numbers = ( - value: unknown, - keys: readonly K[], - what: string, -): Record => { - if ( - !isRecord(value) || - !onlyKeys(value, [...keys]) || - !keys.every((key) => Number.isFinite(value[key])) - ) { - throw new Error(`${what} must carry exactly ${keys.join(", ")} as numbers`); - } - return value as Record; -}; - -const parsePosition = (value: unknown): ICoordinates => - numbers(value, ["x", "y", "z"] as const, "a position"); - const parseSensor = (value: unknown): ISimulatedSensor => { - if ( - !isRecord(value) || - !onlyKeys(value, ["sensorId", "sensorType", "roomId", "position"]) - ) { - throw new Error( - "a sensor must carry sensorId, sensorType, roomId and an optional position", - ); + const keys = ["sensorId", "sensorType", "roomId"]; + if (!isRecord(value) || !onlyKeys(value, keys)) { + throw new Error(`a sensor must carry exactly ${keys.join(", ")}`); } - const { sensorId, sensorType, roomId, position } = value; + const { sensorId, sensorType, roomId } = value; if (!nonEmpty(sensorId) || !nonEmpty(sensorType) || !nonEmpty(roomId)) { throw new Error( "a sensor's sensorId, sensorType and roomId must be non-empty strings", ); } - return position === undefined - ? { sensorId, sensorType, roomId } - : { sensorId, sensorType, roomId, position: parsePosition(position) }; -}; - -const parseRoom = (value: unknown): IRoom => { - if ( - !isRecord(value) || - !onlyKeys(value, ["roomId", "position", "dimensions"]) || - !nonEmpty(value["roomId"]) - ) { - throw new Error("a room must carry roomId, position and dimensions"); - } - const dimensions = numbers( - value["dimensions"], - ["width", "height", "depth"] as const, - "a room's dimensions", - ); - if (Object.values(dimensions).some((side) => side <= 0)) { - throw new Error("every side of a room must be greater than zero"); - } - return { - roomId: value["roomId"], - position: parsePosition(value["position"]), - dimensions, - }; + return { sensorId, sensorType, roomId }; }; /** Reads the body telemetry sends to /control/start, throwing on any other shape. */ export const parseStart = (body: unknown): IBuilding => { // An extra key is refused so the old `targetUrl` can never choose where readings go. - if (!isRecord(body) || !onlyKeys(body, ["buildingId", "sensors", "rooms"])) { - throw new Error( - "the body must carry buildingId, sensors and optional rooms", - ); + if (!isRecord(body) || !onlyKeys(body, ["buildingId", "sensors"])) { + throw new Error("the body must carry exactly buildingId and sensors"); } if (!nonEmpty(body["buildingId"])) { throw new Error("buildingId must be a non-empty string"); @@ -122,15 +59,10 @@ export const parseStart = (body: unknown): IBuilding => { if (!Array.isArray(body["sensors"])) { throw new Error("sensors must be an array"); } - const start: IBuilding = { + return { buildingId: body["buildingId"], sensors: body["sensors"].map(parseSensor), }; - if (body["rooms"] === undefined) return start; - if (!Array.isArray(body["rooms"]) || body["rooms"].length === 0) { - throw new Error("rooms, when sent, must be a non-empty array"); - } - return { ...start, rooms: body["rooms"].map(parseRoom) }; }; export interface ISignalPeopleCount { From 53e718f58beb598e091c06acce9ad71a945250de Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Sat, 26 Sep 2026 12:49:44 +0200 Subject: [PATCH 04/10] feat(telemetry): let collectors read their routers from the platform --- CLAUDE.md | 4 +- Caddyfile | 6 + api/telemetry.yaml | 60 +++++++++ backend/acceptance/edge/test_edge_routing.py | 17 +++ backend/acceptance/support/telemetry.py | 11 ++ backend/telemetry/CLAUDE.md | 2 +- .../src/adapters/driven/postgres/sensors.rs | 11 ++ .../adapters/driving/http_api/controllers.rs | 28 +++- backend/telemetry/src/adapters/ingest_auth.rs | 116 +++++++++++++++++ backend/telemetry/src/kernel/fakes.rs | 14 ++ backend/telemetry/src/kernel/ports.rs | 2 + backend/telemetry/src/kernel/sensors.rs | 65 ++++++++- backend/telemetry/src/lib.rs | 8 ++ backend/telemetry/src/types/sensor.rs | 7 + backend/telemetry/tests/api.rs | 60 +++++++++ backend/telemetry/tests/support/test_app.rs | 18 ++- devices/ap-collector/app/__main__.py | 59 +++++++-- devices/ap-collector/app/collector.py | 36 ++++- devices/ap-collector/app/config.py | 64 +++++++-- devices/ap-collector/app/routers.py | 123 ++++++++++++++++++ devices/ap-collector/collector.dev.json | 31 +---- devices/ap-collector/collector.example.json | 37 ++---- devices/ap-collector/docker-compose.dev.yml | 2 + devices/ap-collector/docker-compose.yml | 2 + devices/ap-collector/moon.yml | 2 + devices/ap-collector/tests/test_collector.py | 49 +++++++ devices/ap-collector/tests/test_config.py | 91 ++++++------- devices/ap-collector/tests/test_main.py | 96 +++++++++----- devices/ap-collector/tests/test_routers.py | 114 ++++++++++++++++ .../architecture/auth-architecture.qd | 2 +- documentation/architecture/deployment.qd | 2 +- documentation/config/sensor-simulator.qd | 5 +- documentation/config/setting-up.qd | 2 +- documentation/design/wireless-flow.qd | 8 +- documentation/packages/telemetry-schema.qd | 1 + documentation/services/telemetry.qd | 8 ++ k8s/istio-request-authentication.yml | 9 +- schemas/CLAUDE.md | 7 +- schemas/fixtures/collector-request.json | 24 ++++ schemas/fixtures/collector-routers.json | 105 +++++++++++++++ schemas/json/collector-routers.schema.json | 69 ++++++++++ .../tests/collector_routers_schema.rs | 46 +++++++ 42 files changed, 1250 insertions(+), 173 deletions(-) create mode 100644 devices/ap-collector/app/routers.py create mode 100644 devices/ap-collector/tests/test_routers.py create mode 100644 schemas/fixtures/collector-request.json create mode 100644 schemas/fixtures/collector-routers.json create mode 100644 schemas/json/collector-routers.schema.json create mode 100644 schemas/telemetry-schema/tests/collector_routers_schema.rs diff --git a/CLAUDE.md b/CLAUDE.md index 13d80cea..d977ede0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,8 +156,8 @@ real port/adapter split only on the outbound side. **Routing** (`Caddyfile`, mirrored by `k8s/istio-*.yml`): `/gateway`, `/tenancy`, `/twin`, `/telemetry`, `/notification`, `/chat` (SSE, needs `flush_interval -1`), `/dashboard`, -`/socket.io`, `/agent` (ungated), `/` → frontend. `/telemetry/ingest` is ungated at the edge -and HMAC-verified in-service — it is one exact path, so a sub-path would 401. +`/socket.io`, `/agent` (ungated), `/` → frontend. `/telemetry/ingest` and `/telemetry/collector` +are ungated at the edge and HMAC-verified in-service — exact paths, so a sub-path would 401. `registry` and `provisioner` have no external route. ## Golden rules diff --git a/Caddyfile b/Caddyfile index bd583784..584c2457 100644 --- a/Caddyfile +++ b/Caddyfile @@ -97,6 +97,12 @@ uri strip_prefix /telemetry reverse_proxy telemetry:3000 } + # A collector reads its routers here with the same key, signing a timestamp instead of + # a body. Exact path, and listed in the same Istio no-principal rule, for the same reason. + handle /telemetry/collector { + uri strip_prefix /telemetry + reverse_proxy telemetry:3000 + } handle_path /telemetry/* { import require_gateway_auth reverse_proxy telemetry:3000 diff --git a/api/telemetry.yaml b/api/telemetry.yaml index af13a825..6dd5dbf8 100644 --- a/api/telemetry.yaml +++ b/api/telemetry.yaml @@ -32,6 +32,20 @@ components: TELEMETRY_INGEST_SECRET. A sensor gateway has no user JWT, so this is how it authenticates itself. The signature covers the exact bytes on the wire — sign the string you send, never a re-serialised copy. + collectorSignature: + type: apiKey + in: header + name: x-signature + description: > + Lowercase-hex HMAC-SHA256, keyed by TELEMETRY_INGEST_SECRET, over + "GET /collector\n" followed by the x-timestamp value. A GET has no body, + so the signed timestamp is what proves the caller holds the key; a + timestamp more than 300 s from telemetry's clock is refused. + collectorTimestamp: + type: apiKey + in: header + name: x-timestamp + description: Unix seconds, decimal — the value the x-signature covers. parameters: SensorType: @@ -270,6 +284,52 @@ security: - gatewayClaims: [] paths: + /collector: + get: + summary: Routers an on-site collector polls + description: > + Every router sensor placed in a room, grouped by building: what an + ap-collector polls and which room each one counts into. Outdoor routers + and buildings with no router are left out. + + Addresses only, never logins: `endpoint` is the router's ubus URL on the + site network, and the credentials to use it stay in the collector's own + configuration. Ungated at the edge like `/ingest`, and signed by the + caller instead. + tags: [Ingestion] + security: + - collectorSignature: [] + collectorTimestamp: [] + responses: + "200": + description: The routers, by building + content: + application/json: + schema: + type: object + required: [buildings] + properties: + buildings: + type: array + items: + type: object + required: [buildingId, routers] + properties: + buildingId: { type: string, example: "bldg-3f2b4c5d" } + routers: + type: array + minItems: 1 + items: + type: object + required: [sensorId, roomId] + properties: + sensorId: { type: string, format: uuid } + roomId: { type: string, example: "room-lab-2" } + driver: { type: string, example: "openwrt-hostapd" } + endpoint: { type: string, example: "http://10.0.4.12/ubus" } + "401": + description: Unsigned, mis-signed, or stamped outside the 300 s window + /ingest: post: summary: Ingest one building tick diff --git a/backend/acceptance/edge/test_edge_routing.py b/backend/acceptance/edge/test_edge_routing.py index a74b31ce..32cab12d 100644 --- a/backend/acceptance/edge/test_edge_routing.py +++ b/backend/acceptance/edge/test_edge_routing.py @@ -94,3 +94,20 @@ def test_the_ingest_ungate_is_an_exact_path_not_a_prefix(): assert response.status_code == 401 assert EDGE_REJECTION_MARKER in response.text + + +def test_the_collector_read_crosses_the_edge_ungated(): + """A collector carries no user JWT either. /telemetry/collector is the second exact + path both edges let through; telemetry verifies its signed timestamp itself. Unsigned, + it must be refused by telemetry, not by the edge. + """ + with httpx.Client(timeout=10.0) as client: + signed = telemetry.read_collector(client, f"{TELEMETRY_VIA_EDGE}/collector") + unsigned = telemetry.read_collector( + client, f"{TELEMETRY_VIA_EDGE}/collector", signed=False + ) + + assert signed.status_code == 200 + assert "buildings" in signed.json() + assert unsigned.status_code == 401 + assert EDGE_REJECTION_MARKER not in unsigned.text diff --git a/backend/acceptance/support/telemetry.py b/backend/acceptance/support/telemetry.py index 81018040..c414fe29 100644 --- a/backend/acceptance/support/telemetry.py +++ b/backend/acceptance/support/telemetry.py @@ -104,3 +104,14 @@ def latest(client: httpx.Client, metric: str, building_id: str, room_id: str) -> def latest_temperature(client: httpx.Client, building_id: str, room_id: str) -> dict: return latest(client, "temperature", building_id, room_id) + + +def read_collector(client: httpx.Client, url: str, *, signed: bool = True) -> httpx.Response: + """GET the collector's router list, signed as ap-collector signs it.""" + if not signed: + return client.get(url) + stamp = str(int(time.time())) + signature = hmac.new( + config.TELEMETRY_INGEST_SECRET.encode(), f"GET /collector\n{stamp}".encode(), hashlib.sha256 + ).hexdigest() + return client.get(url, headers={"X-Signature": signature, "X-Timestamp": stamp}) diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 037ea064..106be37f 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -2,7 +2,7 @@ Rust / Axum / Postgres+Timescale / Kafka / Redis. Ingests sensor readings, owns thresholds, sensors and device actions, fans out to dashboard and raises alerts. Routes `/telemetry/*` -gated at the edge; `/telemetry/ingest` ungated and HMAC-verified in-service. +gated at the edge; `/telemetry/ingest` and `/telemetry/collector` ungated and HMAC-verified in-service. Docs: `documentation/architecture/telemetry-architecture.qd`, `design/telemetry-storage.qd`. diff --git a/backend/telemetry/src/adapters/driven/postgres/sensors.rs b/backend/telemetry/src/adapters/driven/postgres/sensors.rs index ce0418c6..2aba5ea6 100644 --- a/backend/telemetry/src/adapters/driven/postgres/sensors.rs +++ b/backend/telemetry/src/adapters/driven/postgres/sensors.rs @@ -95,6 +95,17 @@ impl SensorStore for PgSensors { Ok(rows.iter().map(as_sensor).collect()) } + async fn of_type(&self, sensor_type: &str) -> anyhow::Result> { + let rows = sqlx::query( + "select building_id, room_id, sensor_id, name, sensor_type, driver, endpoint from sensors + where sensor_type = $1 order by building_id, sensor_id", + ) + .bind(sensor_type) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(as_sensor).collect()) + } + async fn by_room(&self, building_id: &str, room_id: &str) -> anyhow::Result> { let rows = sqlx::query( "select building_id, room_id, sensor_id, name, sensor_type, driver, endpoint from sensors diff --git a/backend/telemetry/src/adapters/driving/http_api/controllers.rs b/backend/telemetry/src/adapters/driving/http_api/controllers.rs index a3d23e73..f8203709 100644 --- a/backend/telemetry/src/adapters/driving/http_api/controllers.rs +++ b/backend/telemetry/src/adapters/driving/http_api/controllers.rs @@ -6,7 +6,7 @@ use crate::types::error::DomainError; use crate::types::identity::GatewayClaims; use crate::types::plugin::ENVELOPE_FIELDS; use crate::types::reading::Reading; -use crate::types::sensor::Command; +use crate::types::sensor::{Command, Sensor}; use crate::types::threshold::{Bounds, TemperatureLimits}; use axum::Json; use axum::extract::{Path, Query, State}; @@ -303,6 +303,32 @@ pub async fn building_sensors( Ok(Json(json!({ "data": data }))) } +/// Every router a collector may poll, by building. Signed by a collector, never by a user. +pub async fn collector(State(state): State>) -> Result, DomainError> { + let buildings: Vec = state + .sensors + .for_collector() + .await? + .iter() + .map(|building| { + let routers: Vec = building.routers.iter().map(collector_router).collect(); + json!({ "buildingId": building.building_id, "routers": routers }) + }) + .collect(); + Ok(Json(json!({ "buildings": buildings }))) +} + +// Absent, not null, when unset: the collector fills a missing endpoint from its own template. +fn collector_router(router: &Sensor) -> Value { + let mut entry = json!({ "sensorId": router.sensor_id, "roomId": router.room_id }); + for (key, value) in [("driver", &router.driver), ("endpoint", &router.endpoint)] { + if let Some(value) = value { + entry[key] = json!(value); + } + } + entry +} + pub async fn room_sensors( State(state): State>, Path((building_id, room_id)): Path<(String, String)>, diff --git a/backend/telemetry/src/adapters/ingest_auth.rs b/backend/telemetry/src/adapters/ingest_auth.rs index 0d99b7cb..50428c86 100644 --- a/backend/telemetry/src/adapters/ingest_auth.rs +++ b/backend/telemetry/src/adapters/ingest_auth.rs @@ -11,6 +11,9 @@ use std::sync::Arc; use crate::adapters::metrics; pub const SIGNATURE_HEADER: &str = "x-signature"; +pub const TIMESTAMP_HEADER: &str = "x-timestamp"; +/// How far a collector's clock may stray; also how long a captured request stays replayable. +pub const MAX_SKEW_S: i64 = 300; const MAX_BODY_BYTES: usize = 1 << 20; const MIN_SECRET_BYTES: usize = 32; @@ -38,6 +41,53 @@ impl IngestKey { hex }) } + + /// A GET has no body, so a collector signs this canonical string instead. + pub fn sign_collector_request(&self, timestamp: &str) -> String { + self.sign(format!("GET /collector\n{timestamp}").as_bytes()) + } +} + +/// Signed by this key, and stamped within `MAX_SKEW_S` of `now_s`. +pub fn collector_request_is_valid( + key: &IngestKey, + signature: &str, + timestamp: &str, + now_s: i64, +) -> bool { + let fresh = timestamp + .parse::() + .is_ok_and(|stamped| (now_s - stamped).abs() <= MAX_SKEW_S); + fresh && constant_time_eq(&key.sign_collector_request(timestamp), signature) +} + +pub async fn verify_collector_request( + State(key): State, + request: Request, + next: Next, +) -> Result { + let valid = { + let header = |name: &str| { + request + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + }; + let now_s = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs() as i64); + collector_request_is_valid( + &key, + header(SIGNATURE_HEADER), + header(TIMESTAMP_HEADER), + now_s, + ) + }; + match valid { + true => Ok(next.run(request).await), + false => Err(StatusCode::UNAUTHORIZED), + } } fn constant_time_eq(a: &str, b: &str) -> bool { @@ -112,6 +162,72 @@ mod tests { } } + const REQUEST_FIXTURE: &str = + include_str!("../../../../schemas/fixtures/collector-request.json"); + + #[test] + fn collector_requests_sign_as_the_fixture_pins() { + let fixture: serde_json::Value = serde_json::from_str(REQUEST_FIXTURE).unwrap(); + let key = IngestKey::new(fixture["secret"].as_str().unwrap()).unwrap(); + assert_eq!(fixture["maxSkewS"], MAX_SKEW_S); + for case in fixture["cases"].as_array().unwrap() { + assert_eq!( + key.sign_collector_request(case["timestamp"].as_str().unwrap()), + case["signature"].as_str().unwrap(), + "{}", + case["name"] + ); + } + } + + fn signed_at(timestamp: &str) -> (String, String) { + ( + key().sign_collector_request(timestamp), + timestamp.to_owned(), + ) + } + + #[test] + fn a_fresh_signed_request_is_accepted() { + let (signature, timestamp) = signed_at("1000"); + assert!(collector_request_is_valid( + &key(), + &signature, + ×tamp, + 1000 + MAX_SKEW_S + )); + } + + #[test] + fn a_request_older_or_newer_than_the_skew_is_refused() { + let (signature, timestamp) = signed_at("1000"); + assert!(!collector_request_is_valid( + &key(), + &signature, + ×tamp, + 1001 + MAX_SKEW_S + )); + assert!(!collector_request_is_valid( + &key(), + &signature, + ×tamp, + 999 - MAX_SKEW_S + )); + } + + #[test] + fn a_retimed_request_or_a_bad_timestamp_is_refused() { + let (signature, _) = signed_at("1000"); + assert!(!collector_request_is_valid( + &key(), + &signature, + "1001", + 1000 + )); + let (garbled, _) = signed_at("soon"); + assert!(!collector_request_is_valid(&key(), &garbled, "soon", 1000)); + } + #[test] fn a_short_secret_is_rejected() { assert!(IngestKey::new("too-short").is_err()); diff --git a/backend/telemetry/src/kernel/fakes.rs b/backend/telemetry/src/kernel/fakes.rs index 21917e3a..29540333 100644 --- a/backend/telemetry/src/kernel/fakes.rs +++ b/backend/telemetry/src/kernel/fakes.rs @@ -395,6 +395,20 @@ impl SensorStore for FakeSensors { .collect()) } + async fn of_type(&self, sensor_type: &str) -> anyhow::Result> { + if self.refuse { + anyhow::bail!("sensors refused"); + } + Ok(self + .registered + .lock() + .unwrap() + .iter() + .filter(|s| s.sensor_type == sensor_type) + .cloned() + .collect()) + } + async fn by_room(&self, building_id: &str, room_id: &str) -> anyhow::Result> { if self.refuse { anyhow::bail!("sensors refused"); diff --git a/backend/telemetry/src/kernel/ports.rs b/backend/telemetry/src/kernel/ports.rs index 572969c9..e7d79a3a 100644 --- a/backend/telemetry/src/kernel/ports.rs +++ b/backend/telemetry/src/kernel/ports.rs @@ -76,6 +76,8 @@ pub trait SensorStore: Send + Sync { async fn apply(&self, building_id: &str, changes: &SensorChanges) -> anyhow::Result<()>; async fn by_building(&self, building_id: &str) -> anyhow::Result>; async fn by_room(&self, building_id: &str, room_id: &str) -> anyhow::Result>; + /// Every building's sensors of one device kind. + async fn of_type(&self, sensor_type: &str) -> anyhow::Result>; } #[async_trait] diff --git a/backend/telemetry/src/kernel/sensors.rs b/backend/telemetry/src/kernel/sensors.rs index cca70ef0..22c1ed39 100644 --- a/backend/telemetry/src/kernel/sensors.rs +++ b/backend/telemetry/src/kernel/sensors.rs @@ -1,12 +1,13 @@ use crate::kernel::devices::DeviceCatalog; use crate::kernel::ports::{BuildingStore, SensorStore}; use crate::types::error::{DomainError, ItemError}; -use crate::types::sensor::{Sensor, SensorChanges, SensorUpdate}; +use crate::types::sensor::{CollectorBuilding, Sensor, SensorChanges, SensorUpdate}; use serde_json::{Map, Value}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; pub const MAX_BATCH_SENSORS: usize = 200; +pub const ROUTER: &str = "router"; pub struct Sensors { pub devices: Arc, @@ -172,6 +173,29 @@ impl Sensors { ) -> Result, DomainError> { Ok(self.store.by_room(building_id, room_id).await?) } + + /// Every router in a room, grouped by building; an outdoor one has no zone to count into. + pub async fn for_collector(&self) -> Result, DomainError> { + let mut by_building: BTreeMap> = BTreeMap::new(); + for router in self.store.of_type(ROUTER).await? { + if router.room_id.is_some() { + by_building + .entry(router.building_id.clone()) + .or_default() + .push(router); + } + } + Ok(by_building + .into_iter() + .map(|(building_id, mut routers)| { + routers.sort_by(|a, b| a.sensor_id.cmp(&b.sensor_id)); + CollectorBuilding { + building_id, + routers, + } + }) + .collect()) + } } /// Whether `sensor_id` is this building's and not already changed by the batch; records why not. @@ -292,6 +316,43 @@ mod tests { harness(FakeSensors::default()) } + fn device(building: &str, id: &str, kind: &str, room: Option<&str>) -> Sensor { + Sensor { + building_id: building.to_owned(), + room_id: room.map(str::to_owned), + sensor_id: id.to_owned(), + name: id.to_owned(), + sensor_type: kind.to_owned(), + driver: None, + endpoint: None, + } + } + + #[tokio::test] + async fn a_collector_sees_every_indoor_router_grouped_by_building() { + let h = harness(FakeSensors { + registered: std::sync::Mutex::new(vec![ + device("b2", "r9", "router", Some("hall")), + device("b1", "r2", "router", Some("lab")), + device("b1", "r1", "router", Some("lobby")), + device("b1", "roof", "router", None), + device("b1", "t1", "temperature", Some("lab")), + ]), + ..Default::default() + }); + + let found = h.sensors.for_collector().await.unwrap(); + + let ids: Vec<(&str, Vec<&str>)> = found + .iter() + .map(|b| { + let routers = b.routers.iter().map(|r| r.sensor_id.as_str()).collect(); + (b.building_id.as_str(), routers) + }) + .collect(); + assert_eq!(ids, vec![("b1", vec!["r1", "r2"]), ("b2", vec!["r9"])]); + } + fn stored(h: &Harness) -> Vec { h.store.registered.lock().unwrap().clone() } diff --git a/backend/telemetry/src/lib.rs b/backend/telemetry/src/lib.rs index 41ed07cc..98b20721 100644 --- a/backend/telemetry/src/lib.rs +++ b/backend/telemetry/src/lib.rs @@ -29,6 +29,13 @@ pub fn router(state: Arc) -> Router { ), ); + let collector = Router::new().route("/collector", get(c::collector)).layer( + axum::middleware::from_fn_with_state( + state.ingest_key.clone(), + ingest_auth::verify_collector_request, + ), + ); + let protected = Router::new() .route( "/thresholds/buildings/{buildingId}", @@ -71,6 +78,7 @@ pub fn router(state: Arc) -> Router { public .merge(ingest) + .merge(collector) .merge(protected) .with_state(state) .layer(axum::middleware::from_fn(metrics::track_metrics)) diff --git a/backend/telemetry/src/types/sensor.rs b/backend/telemetry/src/types/sensor.rs index acf5f55b..9e3680cc 100644 --- a/backend/telemetry/src/types/sensor.rs +++ b/backend/telemetry/src/types/sensor.rs @@ -36,3 +36,10 @@ pub struct Command { pub action: String, pub arguments: Map, } + +/// A building's routers in rooms: what a collector may poll there. +#[derive(Debug, Clone, PartialEq)] +pub struct CollectorBuilding { + pub building_id: String, + pub routers: Vec, +} diff --git a/backend/telemetry/tests/api.rs b/backend/telemetry/tests/api.rs index ccb7801a..437ed114 100644 --- a/backend/telemetry/tests/api.rs +++ b/backend/telemetry/tests/api.rs @@ -481,6 +481,66 @@ async fn sensor_app(label: &str) -> support::test_app::TestApp { test_app(pool, vec!["eng"]).await } +fn now_s() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +#[tokio::test] +async fn a_collector_reads_every_indoor_router_with_its_address_and_no_login() { + let app = sensor_app("collector").await; + let (_, created) = app + .send_json( + "POST", + SENSORS_B1, + Some(&staff()), + json!({ "create": [ + { "ref": "hall", "name": "Hall AP", "sensorType": "router", "roomId": "r1", + "driver": "openwrt-hostapd", "endpoint": "http://10.0.4.12/ubus" }, + { "ref": "lab", "name": "Lab AP", "sensorType": "router", "roomId": "r2" }, + { "ref": "yard", "name": "Yard AP", "sensorType": "router", "roomId": null }, + { "ref": "t", "name": "Thermostat", "sensorType": "temperature", "roomId": "r1" }, + ]}), + ) + .await; + let id = |reference: &str| { + created["created"] + .as_array() + .unwrap() + .iter() + .find(|c| c["ref"] == reference) + .unwrap()["sensorId"] + .as_str() + .unwrap() + .to_owned() + }; + let mut routers = vec![ + json!({ "sensorId": id("hall"), "roomId": "r1", + "driver": "openwrt-hostapd", "endpoint": "http://10.0.4.12/ubus" }), + json!({ "sensorId": id("lab"), "roomId": "r2" }), + ]; + routers.sort_by_key(|router| router["sensorId"].as_str().unwrap().to_owned()); + + let (status, body) = app.collector_at(now_s()).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + json!({ "buildings": [{ "buildingId": "b1", "routers": routers }] }) + ); +} + +#[tokio::test] +async fn a_collector_request_unsigned_or_stale_is_refused() { + let app = sensor_app("collectorauth").await; + let (unsigned, _) = app.get("/collector", None).await; + let (stale, _) = app.collector_at(now_s() - 3600).await; + assert_eq!(unsigned, StatusCode::UNAUTHORIZED); + assert_eq!(stale, StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn a_sensor_batch_returns_server_ids_and_the_list_shows_them() { let app = sensor_app("sensorsave").await; diff --git a/backend/telemetry/tests/support/test_app.rs b/backend/telemetry/tests/support/test_app.rs index 79adea9e..ac86741e 100644 --- a/backend/telemetry/tests/support/test_app.rs +++ b/backend/telemetry/tests/support/test_app.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use telemetry::adapters::driven::dispatch::HttpDispatch; use telemetry::adapters::driven::postgres::{PgBuildings, PgReadings, PgSensors, PgThresholds}; use telemetry::adapters::driven::simulators::HttpSimulators; -use telemetry::adapters::ingest_auth::{IngestKey, SIGNATURE_HEADER}; +use telemetry::adapters::ingest_auth::{IngestKey, SIGNATURE_HEADER, TIMESTAMP_HEADER}; use telemetry::kernel::actions::Actions; use telemetry::kernel::devices::DeviceCatalog; use telemetry::kernel::ingest::Ingest; @@ -203,6 +203,22 @@ impl TestApp { read_json(self.send(request).await).await } + /// `GET /collector`, signed as a collector would at `timestamp` (unix seconds). + pub async fn collector_at(&self, timestamp: i64) -> (StatusCode, Value) { + let stamp = timestamp.to_string(); + let request = Request::builder() + .method("GET") + .uri("/collector") + .header( + SIGNATURE_HEADER, + self.ingest_key.sign_collector_request(&stamp), + ) + .header(TIMESTAMP_HEADER, stamp) + .body(Body::empty()) + .unwrap(); + read_json(self.send(request).await).await + } + pub async fn ingest_unsigned(&self, body: Value) -> (StatusCode, Value) { self.send_json("POST", "/ingest", None, body).await } diff --git a/devices/ap-collector/app/__main__.py b/devices/ap-collector/app/__main__.py index 424f29f4..20590f69 100644 --- a/devices/ap-collector/app/__main__.py +++ b/devices/ap-collector/app/__main__.py @@ -6,10 +6,11 @@ import time from typing import TYPE_CHECKING -from app.collector import build_sessions, build_trackers, readings_for_building, run +from app.collector import build_sessions, build_trackers, readings_for_building, run, sync_state from app.config import Config from app.ingest import IngestError, post_batch -from app.zones import DEFAULT_FROZEN_POLLS +from app.routers import SyncError, buildings_from, fetch_routers +from app.zones import DEFAULT_FROZEN_POLLS, ZoneTracker if TYPE_CHECKING: from collections import Counter @@ -47,9 +48,9 @@ def _make_print_tick( ) -> Callable[[dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]], None]: """Dry-run `on_tick`: shares `readings_for_building` with the real post path so the preview always matches what would actually be sent, `devicesPerPerson` included.""" - buildings_by_name = {building.name: building for building in config.buildings} def on_tick(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) -> None: + buildings_by_name = {building.name: building for building in config.buildings} now_ms = int(time.time() * 1000) for building_name, (assignment, moves) in results.items(): building = buildings_by_name[building_name] @@ -79,11 +80,11 @@ def _make_post_tick( A failed POST is dropped, not raised: an escaping IngestError would end the process, and a tick is a snapshot the next supersedes. Caught per building, so one rejected batch does not skip the buildings after it.""" - buildings_by_name = {building.name: building for building in config.buildings} ingest_url = config.telemetry_service.rstrip("/") + "/ingest" secret = config.telemetry_secret.encode("utf-8") def on_tick(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) -> None: + buildings_by_name = {building.name: building for building in config.buildings} now_ms = int(time.time() * 1000) for building_name, (assignment, _moves) in results.items(): building = buildings_by_name[building_name] @@ -100,6 +101,24 @@ def on_tick(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) return on_tick +def _refreshing( + on_tick: Callable[[dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]], None], + refresh: Callable[[], None], + every: int, +) -> Callable[[dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]], None]: + """`on_tick`, then a router-list refresh after every `every` ticks.""" + ticks = 0 + + def wrapped(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) -> None: + nonlocal ticks + on_tick(results) + ticks += 1 + if ticks % every == 0: + refresh() + + return wrapped + + def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) @@ -115,12 +134,14 @@ def main(argv: list[str] | None = None) -> int: config = Config([]) config.load_from_config_file(args.config) + # Even a dry run reads its routers from telemetry: there is no other list. + config.load_env() + on_tick = _make_print_tick(config) if args.dry_run else _make_post_tick(config) - if args.dry_run: - on_tick = _make_print_tick(config) - else: - config.load_env() - on_tick = _make_post_tick(config) + def new_tracker() -> ZoneTracker: + return ZoneTracker( + args.hysteresis_polls, args.hysteresis_margin_db, args.absent_polls, args.frozen_polls + ) sessions_by_building = build_sessions(config, timeout=config.default_timeout) trackers_by_building = build_trackers( @@ -131,12 +152,30 @@ def main(argv: list[str] | None = None) -> int: frozen_polls=args.frozen_polls, ) + def refresh() -> None: + secret = config.telemetry_secret.encode("utf-8") + try: + answer = fetch_routers(config.telemetry_service, secret, config.default_timeout) + except SyncError as error: + print(f"keeping the previous router list: {error}", file=sys.stderr) + return + config.buildings = buildings_from(answer, config.site) + sync_state( + config, + sessions_by_building, + trackers_by_building, + config.default_timeout, + new_tracker, + ) + + refresh() + every = max(1, round(config.sync_interval / config.poll_interval)) run( config, sessions_by_building, trackers_by_building, interval_s=config.poll_interval, - on_tick=on_tick, + on_tick=_refreshing(on_tick, refresh, every), max_ticks=1 if args.once else None, ) return 0 diff --git a/devices/ap-collector/app/collector.py b/devices/ap-collector/app/collector.py index 5b6a6154..b91831e5 100644 --- a/devices/ap-collector/app/collector.py +++ b/devices/ap-collector/app/collector.py @@ -19,7 +19,7 @@ from collections import Counter from collections.abc import Callable, Mapping, Sequence - from app.config import Building, Config + from app.config import AccessPoint, Building, Config from app.ubus import StationsSource from app.zones import Reading @@ -119,6 +119,40 @@ def build_sessions(config: Config, timeout: int) -> dict[str, dict[str, ApSessio } +def _same_ap(a: AccessPoint, b: AccessPoint) -> bool: + fields = ("url", "username", "password", "ifaces", "reader") + return all(getattr(a, f) == getattr(b, f) for f in fields) + + +def sync_state( + config: Config, + sessions_by_building: dict[str, dict[str, ApSession]], + trackers_by_building: dict[str, ZoneTracker], + timeout: int, + new_tracker: Callable[[], ZoneTracker], +) -> None: + """Reconcile the long-lived state with `config.buildings` after a router-list refresh. + + Kept in place, not rebuilt: a router that stayed keeps its login token, and a building + that stayed keeps its tracker -- rebuilding would reset every device's hysteresis. + """ + wanted = {building.name: building for building in config.buildings} + for name in set(sessions_by_building) - set(wanted): + del sessions_by_building[name] + for name in set(trackers_by_building) - set(wanted): + del trackers_by_building[name] + for name, building in wanted.items(): + current = sessions_by_building.setdefault(name, {}) + aps = {ap.name: ap for ap in building.ap} + for ap_name in set(current) - set(aps): + del current[ap_name] + for ap_name, ap in aps.items(): + held = current.get(ap_name) + if held is None or not _same_ap(held.ap, ap): + current[ap_name] = ApSession(ap, timeout) + trackers_by_building.setdefault(name, new_tracker()) + + def build_trackers( config: Config, polls: int, margin_db: float, absent_polls: int, frozen_polls: int ) -> dict[str, ZoneTracker]: diff --git a/devices/ap-collector/app/config.py b/devices/ap-collector/app/config.py index fc87a4df..14f61203 100644 --- a/devices/ap-collector/app/config.py +++ b/devices/ap-collector/app/config.py @@ -1,7 +1,9 @@ from __future__ import annotations +import dataclasses import json import os +from dataclasses import dataclass, replace from pathlib import Path READERS = ("hostapd", "iwinfo") @@ -10,6 +12,50 @@ """Must stay <= DEFAULT_POLL_INTERVAL -- see Config._validate.""" DEFAULT_DEVICES_PER_PERSON = 2.5 """Used only when useDevicesPerPerson is true and devicesPerPerson is omitted.""" +DEFAULT_SYNC_INTERVAL = 300 +"""How often the router list is re-read from telemetry, in seconds.""" +DEFAULT_IFACES = ("wlan0",) + + +@dataclass(frozen=True) +class RouterLogin: + """How the collector logs in to a router. Stays on the site: telemetry never holds it.""" + + username: str + password: str + ifaces: tuple[str, ...] + + +@dataclass(frozen=True) +class Site: + """Everything about this site's routers that is not the platform's to know.""" + + login: RouterLogin + overrides: dict[str, dict] = dataclasses.field(default_factory=dict) + endpoint_template: str | None = None + + def login_for(self, sensor_id: str) -> RouterLogin: + override = self.overrides.get(sensor_id, {}) + return replace( + self.login, + username=override.get("username", self.login.username), + password=override.get("password", self.login.password), + ifaces=tuple(override.get("ifaces", self.login.ifaces)), + ) + + @classmethod + def from_json(cls, data: dict) -> Site: + ubus = data.get("ubus") + if not isinstance(ubus, dict) or not ubus.get("username"): + raise ValueError("config: ubus.username and ubus.password must be set") + ifaces = tuple(data.get("ifaces", DEFAULT_IFACES)) + if not ifaces or not all(isinstance(i, str) and i for i in ifaces): + raise ValueError("config: ifaces must be a non-empty list of interface names") + return cls( + login=RouterLogin(ubus["username"], ubus.get("password", ""), ifaces), + overrides=dict(data.get("overrides", {})), + endpoint_template=data.get("endpointTemplate"), + ) class AccessPoint: @@ -88,11 +134,16 @@ def __init__( self.poll_interval: int = poll_interval self.default_timeout: int = default_timeout self.devices_per_person: float | None = devices_per_person + self.sync_interval: float = DEFAULT_SYNC_INTERVAL + self.site: Site = Site(RouterLogin("", "", DEFAULT_IFACES)) def load_from_config_file(self, config_file_path: str) -> None: with Path.open(Path(config_file_path)) as f: data = json.load(f) - buildings = [Building.from_json(building) for building in data.get("buildings", [])] + if "buildings" in data: + raise ValueError("config: buildings are read from telemetry; remove them from the file") + site = Site.from_json(data) + sync_interval = data.get("syncIntervalS", DEFAULT_SYNC_INTERVAL) poll_interval = data.get("pollIntervalS", DEFAULT_POLL_INTERVAL) default_timeout = data.get("requestTimeoutS", DEFAULT_REQUEST_TIMEOUT) devices_per_person = ( @@ -100,8 +151,9 @@ def load_from_config_file(self, config_file_path: str) -> None: if data.get("useDevicesPerPerson", False) else None ) - self._validate(buildings, poll_interval, default_timeout, devices_per_person) - self.buildings = buildings + self._validate(poll_interval, default_timeout, devices_per_person) + self.site = site + self.sync_interval = sync_interval self.poll_interval = poll_interval self.default_timeout = default_timeout self.devices_per_person = devices_per_person @@ -120,16 +172,10 @@ def load_env(self) -> None: def _validate( self, - buildings: list[Building], poll_interval: float, default_timeout: float, devices_per_person: float | None, ) -> None: - if not buildings: - raise ValueError("config: buildings must not be empty") - names = [b.name for b in buildings] - if len(set(names)) != len(names): - raise ValueError("config: building names must be unique") if default_timeout > poll_interval: # Otherwise a single unreachable AP holds a tick open past when the next one # should start, and the real poll rate drifts away from what phase 3's hysteresis diff --git a/devices/ap-collector/app/routers.py b/devices/ap-collector/app/routers.py new file mode 100644 index 00000000..70d73ce5 --- /dev/null +++ b/devices/ap-collector/app/routers.py @@ -0,0 +1,123 @@ +"""Which routers to poll: read from telemetry, joined with this site's own logins. + +Telemetry knows where each router is (building, room, ubus address). The site knows how to +log in to it. Neither half ever travels to the other side. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import sys +import time +import urllib.error +import urllib.request +from typing import TYPE_CHECKING, Any + +from app.config import AccessPoint, Building + +if TYPE_CHECKING: + from collections.abc import Callable + + from app.config import Site + +COLLECTOR_PATH = "/collector" +IWINFO_DRIVER = "openwrt-iwinfo" +_ROUTER_KEYS = {"sensorId", "roomId", "driver", "endpoint"} + + +class SyncError(RuntimeError): + """The router list could not be read -- telemetry unreachable, refusing, or malformed.""" + + +def sign_request(secret: bytes, timestamp: str) -> str: + """A GET has no body, so it signs this canonical string; the timestamp bounds replay.""" + canonical = f"GET {COLLECTOR_PATH}\n{timestamp}".encode() + return hmac.new(secret, canonical, hashlib.sha256).hexdigest() + + +def fetch_routers( + telemetry_url: str, + secret: bytes, + timeout: float, + now: Callable[[], float] = time.time, +) -> dict[str, Any]: + """Every router this collector may poll, as telemetry answers it.""" + stamp = str(int(now())) + request = urllib.request.Request( # noqa: S310 + telemetry_url.rstrip("/") + COLLECTOR_PATH, + headers={"x-signature": sign_request(secret, stamp), "x-timestamp": stamp}, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + answer = json.loads(response.read()) + except urllib.error.HTTPError as error: + raise SyncError(f"{request.full_url} returned {error.code}: {error.reason}") from error + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + raise SyncError(f"{request.full_url} unreadable: {error}") from error + try: + return parse_answer(answer) + except ValueError as error: + raise SyncError(f"{request.full_url} answered a malformed router list: {error}") from error + + +def parse_answer(answer: Any) -> dict[str, Any]: + """The answer, checked against the contract; raises ValueError on any other shape.""" + if not isinstance(answer, dict) or set(answer) != {"buildings"}: + raise ValueError("the answer must carry exactly buildings") + if not isinstance(answer["buildings"], list): + raise ValueError("buildings must be a list") + for building in answer["buildings"]: + if not isinstance(building, dict) or set(building) != {"buildingId", "routers"}: + raise ValueError("a building must carry exactly buildingId and routers") + if not _text(building["buildingId"]): + raise ValueError("buildingId must be a non-empty string") + routers = building["routers"] + if not isinstance(routers, list) or not routers: + raise ValueError(f"{building['buildingId']}: routers must be a non-empty list") + for router in routers: + if not isinstance(router, dict) or not set(router) <= _ROUTER_KEYS: + raise ValueError(f"a router may carry only {sorted(_ROUTER_KEYS)}") + if not all(_text(router.get(key)) for key in ("sensorId", "roomId")): + raise ValueError("a router's sensorId and roomId must be non-empty strings") + if not all(_text(router[key]) for key in ("driver", "endpoint") if key in router): + raise ValueError("a router's driver and endpoint, when sent, must be non-empty") + return answer + + +def buildings_from(answer: dict[str, Any], site: Site) -> list[Building]: + """One Building per answered building that still has a router the site can reach.""" + buildings: list[Building] = [] + for entry in answer["buildings"]: + aps = [ap for ap in (_access_point(r, site) for r in entry["routers"]) if ap] + if aps: + buildings.append(Building(name=entry["buildingId"], ap=aps)) + return buildings + + +def _access_point(router: dict[str, str], site: Site) -> AccessPoint | None: + sensor_id = router["sensorId"] + url = router.get("endpoint") + if url is None and site.endpoint_template: + url = site.endpoint_template.format(sensorId=sensor_id) + if url is None: + print(f"router {sensor_id}: no endpoint and no endpointTemplate, skipped", file=sys.stderr) + return None + login = site.login_for(sensor_id) + return AccessPoint.from_json( + { + "name": sensor_id, + "zone": router["roomId"], + "url": url, + "username": login.username, + "password": login.password, + "ifaces": login.ifaces, + "reader": "iwinfo" if router.get("driver") == IWINFO_DRIVER else "hostapd", + } + ) + + +def _text(value: object) -> bool: + return isinstance(value, str) and bool(value) diff --git a/devices/ap-collector/collector.dev.json b/devices/ap-collector/collector.dev.json index 2de1111c..8f219251 100644 --- a/devices/ap-collector/collector.dev.json +++ b/devices/ap-collector/collector.dev.json @@ -1,29 +1,10 @@ { "pollIntervalS": 5.0, "requestTimeoutS": 3.0, - "buildings": [ - { - "name": "b1", - "ap": [ - { - "name": "ap-a", - "zone": "zone-a", - "url": "http://ap-simulator:3000/ap-a/ubus", - "username": "collector", - "password": "collector", - "ifaces": ["wlan0"], - "reader": "hostapd" - }, - { - "name": "ap-b", - "zone": "zone-b", - "url": "http://ap-simulator:3000/ap-b/ubus", - "username": "collector", - "password": "collector", - "ifaces": ["wlan0"], - "reader": "iwinfo" - } - ] - } - ] + "syncIntervalS": 15, + "useDevicesPerPerson": true, + "devicesPerPerson": 1.4, + "ubus": { "username": "collector", "password": "collector" }, + "ifaces": ["wlan0"], + "endpointTemplate": "http://ap-simulator:3000/{sensorId}/ubus" } diff --git a/devices/ap-collector/collector.example.json b/devices/ap-collector/collector.example.json index a828b951..d108a000 100644 --- a/devices/ap-collector/collector.example.json +++ b/devices/ap-collector/collector.example.json @@ -1,31 +1,12 @@ { - "pollIntervalS": 5.0, - "requestTimeoutS": 3.0, - "useDevicesPerPerson": false, + "pollIntervalS": 60, + "requestTimeoutS": 10, + "syncIntervalS": 300, + "useDevicesPerPerson": true, "devicesPerPerson": 1.4, - "buildings": [ - { - "name": "b1", - "ap": [ - { - "name": "ap-a", - "zone": "zone-a", - "url": "http://localhost:3003/ap-a/ubus", - "username": "collector", - "password": "collector", - "ifaces": ["wlan0"], - "reader": "hostapd" - }, - { - "name": "ap-b", - "zone": "zone-b", - "url": "http://localhost:3003/ap-b/ubus", - "username": "collector", - "password": "collector", - "ifaces": ["wlan0"], - "reader": "iwinfo" - } - ] - } - ] + "ubus": { "username": "collector", "password": "change-me" }, + "ifaces": ["phy0-ap0", "phy1-ap0"], + "overrides": { + "e6f70819-2a3b-4c4d-8e5f-60718293a4b5": { "password": "this-router-differs", "ifaces": ["wlan0"] } + } } diff --git a/devices/ap-collector/docker-compose.dev.yml b/devices/ap-collector/docker-compose.dev.yml index fb836d22..14134b89 100644 --- a/devices/ap-collector/docker-compose.dev.yml +++ b/devices/ap-collector/docker-compose.dev.yml @@ -4,3 +4,5 @@ services: watch: - action: rebuild path: ./app + - action: rebuild + path: ./collector.dev.json diff --git a/devices/ap-collector/docker-compose.yml b/devices/ap-collector/docker-compose.yml index a160638f..3ac1404b 100644 --- a/devices/ap-collector/docker-compose.yml +++ b/devices/ap-collector/docker-compose.yml @@ -6,3 +6,5 @@ services: environment: - TELEMETRY_SERVICE_URL=${TELEMETRY_SERVICE_URL:-http://gateway/telemetry} - TELEMETRY_SERVICE_SECRET=${TELEMETRY_INGEST_SECRET} + # The image defaults to --dry-run; the stack posts, so simulated routers reach the dashboard. + command: ["--config", "collector.json"] diff --git a/devices/ap-collector/moon.yml b/devices/ap-collector/moon.yml index 75d4d913..a9fe5ca7 100644 --- a/devices/ap-collector/moon.yml +++ b/devices/ap-collector/moon.yml @@ -11,6 +11,8 @@ tasks: - 'app/**/*' - 'tests/**/*' - 'pyproject.toml' + # Contract tests read the shared fixtures; without these a fixture edit replays a cached pass. + - '/schemas/fixtures/**/*' lint: script: 'uv run ruff check . && uv run ruff format --check . && uv run pyright' diff --git a/devices/ap-collector/tests/test_collector.py b/devices/ap-collector/tests/test_collector.py index bc02165c..0c366e79 100644 --- a/devices/ap-collector/tests/test_collector.py +++ b/devices/ap-collector/tests/test_collector.py @@ -14,6 +14,7 @@ poll_one, readings_for_building, run, + sync_state, tick, tick_building, ) @@ -502,3 +503,51 @@ def test_the_readings_match_the_shape_telemetry_pins(): readings = readings_for_building(building, assignment, now_ms=timestamp, devices_per_person=2.0) assert readings == expected + + +def _router(name: str, zone: str, url: str | None = None) -> AccessPoint: + return AccessPoint.from_json( + { + "name": name, + "zone": zone, + "url": url or f"http://{name}.example/ubus", + "username": "collector", + "password": "collector", + "ifaces": ["wlan0"], + } + ) + + +def _new_tracker() -> ZoneTracker: + return ZoneTracker(polls=3, margin_db=6.0, absent_polls=3, frozen_polls=30) + + +def test_a_new_router_list_keeps_what_stayed_and_adds_and_drops_the_rest(): + config = Config( + [ + Building("b1", [_router("r1", "lobby"), _router("r2", "lab")]), + Building("b2", [_router("r9", "hall")]), + ] + ) + sessions = build_sessions(config, timeout=3) + trackers = {"b1": _new_tracker(), "b2": _new_tracker()} + kept_session, kept_tracker = sessions["b1"]["r1"], trackers["b1"] + + config.buildings = [ + Building( + "b1", + [ + _router("r1", "lobby"), + _router("r2", "lab", url="http://moved.example/ubus"), + _router("r3", "hall"), + ], + ), + Building("b3", [_router("r7", "atrium")]), + ] + sync_state(config, sessions, trackers, timeout=3, new_tracker=_new_tracker) + + assert sessions["b1"]["r1"] is kept_session + assert sessions["b1"]["r2"].ap.url == "http://moved.example/ubus" + assert set(sessions["b1"]) == {"r1", "r2", "r3"} + assert trackers["b1"] is kept_tracker + assert set(sessions) == set(trackers) == {"b1", "b3"} diff --git a/devices/ap-collector/tests/test_config.py b/devices/ap-collector/tests/test_config.py index a07fad8e..32401788 100644 --- a/devices/ap-collector/tests/test_config.py +++ b/devices/ap-collector/tests/test_config.py @@ -6,12 +6,16 @@ DEFAULT_DEVICES_PER_PERSON, DEFAULT_POLL_INTERVAL, DEFAULT_REQUEST_TIMEOUT, + DEFAULT_SYNC_INTERVAL, READERS, AccessPoint, Building, Config, + RouterLogin, ) +SITE = {"ubus": {"username": "collector", "password": "collector"}} + GOOD_AP = { "name": "ap-a", "zone": "zone-a", @@ -105,66 +109,49 @@ def test_get_ap_finds_by_name_and_returns_none_for_unknown(): assert building.get_ap("nope") is None -def test_load_from_config_file_parses_multiple_buildings(tmp_path): - data = { - "buildings": [ - {"name": "b1", "ap": [GOOD_AP]}, - { - "name": "b2", - "ap": [ - dict(GOOD_AP, name="ap-c", zone="zone-c", url="http://localhost:3003/ap-c/ubus") - ], - }, - ] - } - path = _write(tmp_path, data) - +def test_the_site_login_ifaces_overrides_and_template_are_read(tmp_path): + data = dict( + SITE, + ifaces=["wlan0", "wlan1"], + overrides={"r1": {"password": "other", "ifaces": ["phy0-ap0"]}}, + endpointTemplate="http://ap-simulator:3000/{sensorId}/ubus", + syncIntervalS=120, + ) config = Config([]) - config.load_from_config_file(path) - - assert [b.name for b in config.buildings] == ["b1", "b2"] - assert config.buildings[0].ap[0].name == "ap-a" - assert config.buildings[1].ap[0].name == "ap-c" - - -def test_load_from_config_file_empty_buildings_raises(tmp_path): - path = _write(tmp_path, {"buildings": []}) - - with pytest.raises(ValueError, match=r"(?i)buildings"): - Config([]).load_from_config_file(path) - - -def test_load_from_config_file_missing_buildings_key_raises(tmp_path): - path = _write(tmp_path, {}) + config.load_from_config_file(_write(tmp_path, data)) - with pytest.raises(ValueError, match=r"(?i)buildings"): - Config([]).load_from_config_file(path) + assert config.site.login == RouterLogin("collector", "collector", ("wlan0", "wlan1")) + assert config.site.login_for("r1") == RouterLogin("collector", "other", ("phy0-ap0",)) + assert config.site.login_for("r2") == config.site.login + assert config.site.endpoint_template == "http://ap-simulator:3000/{sensorId}/ubus" + assert config.sync_interval == 120 -def test_load_from_config_file_duplicate_building_names_raise(tmp_path): - data = {"buildings": [{"name": "b1", "ap": [GOOD_AP]}, {"name": "b1", "ap": [GOOD_AP]}]} - path = _write(tmp_path, data) +def test_the_site_defaults_to_wlan0_no_template_and_a_five_minute_sync(tmp_path): + config = Config([]) + config.load_from_config_file(_write(tmp_path, SITE)) - with pytest.raises(ValueError, match=r"(?i)building names"): - Config([]).load_from_config_file(path) + assert config.site.login.ifaces == ("wlan0",) + assert config.site.endpoint_template is None + assert config.sync_interval == DEFAULT_SYNC_INTERVAL -def test_a_failed_reload_does_not_clobber_the_previous_buildings(tmp_path): - original = [Building.from_json({"name": "b1", "ap": [GOOD_AP]})] - config = Config(original) - bad_path = _write(tmp_path, {"buildings": []}) +def test_a_missing_router_login_raises(tmp_path): + with pytest.raises(ValueError, match=r"(?i)ubus"): + Config([]).load_from_config_file(_write(tmp_path, {})) - with pytest.raises(ValueError): - config.load_from_config_file(bad_path) - assert config.buildings is original +def test_a_file_that_still_lists_buildings_is_refused(tmp_path): + """Routers come from telemetry now; a stale hand-written list must not be silently ignored.""" + with pytest.raises(ValueError, match=r"(?i)buildings"): + Config([]).load_from_config_file(_write(tmp_path, dict(SITE, buildings=[]))) def test_poll_interval_and_timeout_are_read_from_json(tmp_path): data = { "pollIntervalS": 10, "requestTimeoutS": 4, - "buildings": [{"name": "b1", "ap": [GOOD_AP]}], + **SITE, } path = _write(tmp_path, data) @@ -178,7 +165,7 @@ def test_poll_interval_and_timeout_are_read_from_json(tmp_path): def test_poll_interval_and_timeout_default_when_omitted_from_json(tmp_path): """Missing from the file means the module DEFAULT_*, not whatever the instance already happened to hold -- a reload must not leak a previous load's values in.""" - data = {"buildings": [{"name": "b1", "ap": [GOOD_AP]}]} + data = {**SITE} path = _write(tmp_path, data) config = Config([], poll_interval=999, default_timeout=999) @@ -192,7 +179,7 @@ def test_request_timeout_greater_than_poll_interval_raises(tmp_path): data = { "pollIntervalS": 2, "requestTimeoutS": 5, - "buildings": [{"name": "b1", "ap": [GOOD_AP]}], + **SITE, } path = _write(tmp_path, data) @@ -205,7 +192,7 @@ def test_a_failed_timeout_validation_does_not_clobber_previous_settings(tmp_path bad_data = { "pollIntervalS": 2, "requestTimeoutS": 5, - "buildings": [{"name": "b2", "ap": [GOOD_AP]}], + **SITE, } bad_path = _write(tmp_path, bad_data) @@ -246,7 +233,7 @@ def test_load_env_raises_when_telemetry_service_secret_is_unset(monkeypatch): def test_devices_per_person_is_none_when_flag_is_false_or_omitted(tmp_path): - data = {"devicesPerPerson": 1.4, "buildings": [{"name": "b1", "ap": [GOOD_AP]}]} + data = {"devicesPerPerson": 1.4, **SITE} path = _write(tmp_path, data) config = Config([]) @@ -259,7 +246,7 @@ def test_devices_per_person_is_used_when_flag_is_true(tmp_path): data = { "useDevicesPerPerson": True, "devicesPerPerson": 1.4, - "buildings": [{"name": "b1", "ap": [GOOD_AP]}], + **SITE, } path = _write(tmp_path, data) @@ -270,7 +257,7 @@ def test_devices_per_person_is_used_when_flag_is_true(tmp_path): def test_devices_per_person_defaults_when_flag_true_but_ratio_omitted(tmp_path): - data = {"useDevicesPerPerson": True, "buildings": [{"name": "b1", "ap": [GOOD_AP]}]} + data = {"useDevicesPerPerson": True, **SITE} path = _write(tmp_path, data) config = Config([]) @@ -283,7 +270,7 @@ def test_devices_per_person_zero_or_negative_raises_when_enabled(tmp_path): data = { "useDevicesPerPerson": True, "devicesPerPerson": 0, - "buildings": [{"name": "b1", "ap": [GOOD_AP]}], + **SITE, } path = _write(tmp_path, data) diff --git a/devices/ap-collector/tests/test_main.py b/devices/ap-collector/tests/test_main.py index 70a9aed6..ba880bd5 100644 --- a/devices/ap-collector/tests/test_main.py +++ b/devices/ap-collector/tests/test_main.py @@ -2,7 +2,7 @@ import json import urllib.error -from app.__main__ import main +from app.__main__ import _refreshing, main class _FakeResponse: @@ -33,11 +33,31 @@ def _fake_ubus_urlopen(request, timeout=None): return _FakeResponse(json.dumps(payload).encode()) -def _make_fake_urlopen(ingest_calls): - """Routes to the ubus fake for ubus JSON-RPC calls, records everything else as an - ingest POST -- lets one test drive the whole pipeline (poll -> tick -> post) for real.""" +def _collector_answer(building_names): + return { + "buildings": [ + { + "buildingId": name, + "routers": [ + { + "sensorId": "ap-a", + "roomId": "lobby", + "endpoint": f"http://{name}-ap-a.example/ubus", + } + ], + } + for name in building_names + ] + } + + +def _make_fake_urlopen(ingest_calls, building_names=("b1",)): + """Answers the router list, the ubus fake, and records every ingest POST -- lets one test + drive the whole pipeline (sync -> poll -> tick -> post) for real.""" def fake(request, timeout=None): + if request.get_method() == "GET": + return _FakeResponse(json.dumps(_collector_answer(building_names)).encode()) body = json.loads(request.data) if isinstance(body, dict) and body.get("method") == "call": return _fake_ubus_urlopen(request, timeout) @@ -47,37 +67,27 @@ def fake(request, timeout=None): return fake -def _write_config(tmp_path, building_names=("b1",), devices_per_person=None): +def _write_config(tmp_path, devices_per_person=None): data = { "pollIntervalS": 5, "requestTimeoutS": 3, "useDevicesPerPerson": devices_per_person is not None, "devicesPerPerson": devices_per_person, - "buildings": [ - { - "name": name, - "ap": [ - { - "name": "ap-a", - "zone": "lobby", - "url": f"http://{name}-ap-a.example/ubus", - "username": "collector", - "password": "collector", - "ifaces": ["wlan0"], - "reader": "hostapd", - } - ], - } - for name in building_names - ], + "ubus": {"username": "collector", "password": "collector"}, } path = tmp_path / "collector.json" path.write_text(json.dumps(data), encoding="utf-8") return str(path) +def _telemetry(monkeypatch): + monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry.example/telemetry") + monkeypatch.setenv("TELEMETRY_SERVICE_SECRET", "x" * 32) + + def test_main_dry_run_once_prints_one_tick_batch(tmp_path, monkeypatch, capsys): - monkeypatch.setattr("urllib.request.urlopen", _fake_ubus_urlopen) + monkeypatch.setattr("urllib.request.urlopen", _make_fake_urlopen([])) + _telemetry(monkeypatch) config_path = _write_config(tmp_path) exit_code = main(["--config", config_path, "--once", "--dry-run"]) @@ -92,8 +102,7 @@ def test_main_dry_run_once_prints_one_tick_batch(tmp_path, monkeypatch, capsys): def test_main_without_dry_run_posts_a_signed_occupancy_batch(tmp_path, monkeypatch): ingest_calls = [] monkeypatch.setattr("urllib.request.urlopen", _make_fake_urlopen(ingest_calls)) - monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry.example/telemetry") - monkeypatch.setenv("TELEMETRY_SERVICE_SECRET", "x" * 32) + _telemetry(monkeypatch) config_path = _write_config(tmp_path) exit_code = main(["--config", config_path, "--once"]) @@ -117,8 +126,7 @@ def test_main_without_dry_run_posts_a_signed_occupancy_batch(tmp_path, monkeypat def test_main_posts_both_metrics_when_a_conversion_factor_is_configured(tmp_path, monkeypatch): ingest_calls = [] monkeypatch.setattr("urllib.request.urlopen", _make_fake_urlopen(ingest_calls)) - monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry.example/telemetry") - monkeypatch.setenv("TELEMETRY_SERVICE_SECRET", "x" * 32) + _telemetry(monkeypatch) config_path = _write_config(tmp_path, devices_per_person=2.5) exit_code = main(["--config", config_path, "--once"]) @@ -139,6 +147,8 @@ def test_main_survives_a_rejected_batch_and_still_posts_every_other_building( posted = [] def fake(request, timeout=None): + if request.get_method() == "GET": + return _FakeResponse(json.dumps(_collector_answer(("b1", "b2"))).encode()) body = json.loads(request.data) if isinstance(body, dict) and body.get("method") == "call": return _fake_ubus_urlopen(request, timeout) @@ -150,9 +160,8 @@ def fake(request, timeout=None): return _FakeResponse(b"") monkeypatch.setattr("urllib.request.urlopen", fake) - monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry.example/telemetry") - monkeypatch.setenv("TELEMETRY_SERVICE_SECRET", "x" * 32) - config_path = _write_config(tmp_path, ("b1", "b2")) + _telemetry(monkeypatch) + config_path = _write_config(tmp_path) exit_code = main(["--config", config_path, "--once"]) @@ -161,6 +170,33 @@ def fake(request, timeout=None): assert "b1" in capsys.readouterr().err +def test_main_starts_with_no_building_when_telemetry_is_unreachable(tmp_path, monkeypatch, capsys): + """A collector booting before the platform must keep running and pick routers up later.""" + + def down(request, timeout=None): + raise urllib.error.URLError("refused") + + monkeypatch.setattr("urllib.request.urlopen", down) + _telemetry(monkeypatch) + + exit_code = main(["--config", _write_config(tmp_path), "--once"]) + + assert exit_code == 0 + assert "refused" in capsys.readouterr().err + + +def test_the_router_list_is_refreshed_every_sync_interval(): + calls = [] + on_tick = _refreshing( + lambda results: calls.append("tick"), lambda: calls.append("sync"), every=2 + ) + + for _ in range(4): + on_tick({}) + + assert calls == ["tick", "tick", "sync", "tick", "tick", "sync"] + + def test_main_replay_is_not_implemented_yet(tmp_path, capsys): exit_code = main(["--config", "unused.json", "--replay", "survey.csv"]) diff --git a/devices/ap-collector/tests/test_routers.py b/devices/ap-collector/tests/test_routers.py new file mode 100644 index 00000000..c28f5da9 --- /dev/null +++ b/devices/ap-collector/tests/test_routers.py @@ -0,0 +1,114 @@ +import json +import urllib.error +from pathlib import Path + +import pytest + +from app.config import RouterLogin, Site +from app.routers import SyncError, buildings_from, fetch_routers, parse_answer, sign_request + +_FIXTURES = Path(__file__).resolve().parents[3] / "schemas" / "fixtures" +REQUEST = json.loads((_FIXTURES / "collector-request.json").read_text()) +ROUTERS = json.loads((_FIXTURES / "collector-routers.json").read_text()) +SITE = Site(RouterLogin("collector", "secret", ("wlan0",))) + + +def _answer(name: str) -> dict: + return next(c for c in ROUTERS["cases"] if c["name"] == name)["body"] + + +def test_requests_sign_as_the_fixture_pins(): + secret = REQUEST["secret"].encode() + for case in REQUEST["cases"]: + assert sign_request(secret, case["timestamp"]) == case["signature"], case["name"] + + +@pytest.mark.parametrize("case", ROUTERS["cases"], ids=lambda c: c["name"]) +def test_every_answer_the_fixture_accepts_parses(case): + parse_answer(case["body"]) + + +@pytest.mark.parametrize("case", ROUTERS["rejected"], ids=lambda c: c["name"]) +def test_every_answer_the_fixture_rejects_is_refused(case): + with pytest.raises(ValueError): + parse_answer(case["body"]) + + +def test_each_router_becomes_an_ap_in_its_room_read_as_its_driver_says(): + (aula, iwinfo) = buildings_from( + _answer("two buildings, one router without driver or endpoint"), SITE + ) + ap = aula.ap[0] + assert (aula.name, ap.name, ap.zone, ap.url, ap.reader) == ( + "bldg-3f2b4c5d", + "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "room-aula-magna", + "http://10.0.4.12/ubus", + "hostapd", + ) + assert (ap.username, ap.password, ap.ifaces) == ("collector", "secret", ("wlan0",)) + assert iwinfo.ap[0].reader == "iwinfo" + + +def test_a_router_without_an_endpoint_is_skipped_unless_the_site_has_a_template(): + answer = _answer("two buildings, one router without driver or endpoint") + assert [len(b.ap) for b in buildings_from(answer, SITE)] == [1, 1] + + templated = Site(SITE.login, endpoint_template="http://ap-simulator:3000/{sensorId}/ubus") + aula = buildings_from(answer, templated)[0] + assert aula.ap[1].url == "http://ap-simulator:3000/f7081920-3b4c-4d5e-9f60-718293a4b5c6/ubus" + + +def test_a_per_router_override_replaces_the_site_login(): + site = Site(SITE.login, overrides={"e6f70819-2a3b-4c4d-8e5f-60718293a4b5": {"password": "x"}}) + aula = buildings_from(_answer("two buildings, one router without driver or endpoint"), site)[0] + assert aula.ap[0].password == "x" + + +def test_a_building_left_with_no_reachable_router_is_dropped(): + answer = {"buildings": [{"buildingId": "b1", "routers": [{"sensorId": "r", "roomId": "a"}]}]} + assert buildings_from(answer, SITE) == [] + + +class _Response: + def __init__(self, body: bytes): + self._body = body + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_fetch_signs_a_timestamped_get_and_parses_the_answer(monkeypatch): + seen = [] + + def fake(request, timeout=None): + seen.append((request, timeout)) + return _Response(json.dumps(_answer("no building has a router")).encode()) + + monkeypatch.setattr("urllib.request.urlopen", fake) + answer = fetch_routers("http://t.example/telemetry", b"k" * 32, timeout=3, now=lambda: 1000.9) + + (request, timeout) = seen[0] + assert answer == {"buildings": []} + assert (request.get_method(), request.full_url, timeout) == ( + "GET", + "http://t.example/telemetry/collector", + 3, + ) + assert request.get_header("X-timestamp") == "1000" + assert request.get_header("X-signature") == sign_request(b"k" * 32, "1000") + + +def test_fetch_reports_an_unreachable_or_refusing_telemetry_as_a_sync_error(monkeypatch): + def refuse(request, timeout=None): + raise urllib.error.URLError("refused") + + monkeypatch.setattr("urllib.request.urlopen", refuse) + with pytest.raises(SyncError): + fetch_routers("http://t.example/telemetry", b"k" * 32, timeout=3) diff --git a/documentation/architecture/auth-architecture.qd b/documentation/architecture/auth-architecture.qd index 7b5af97f..bba1750b 100644 --- a/documentation/architecture/auth-architecture.qd +++ b/documentation/architecture/auth-architecture.qd @@ -199,7 +199,7 @@ Downstream of either path, the receiving service does the same thing: decode `x- `/agent/*` is not gated by `require_gateway_auth` in the Caddyfile, and has no equivalent `AuthorizationPolicy` requirement at the Istio edge. `evals/run_evals.py`'s local-dev bypass token is HS256-signed with a secret claims-gateway never sees — it was never going to pass gateway verification, by design. `agent` checks for the mesh claims header first and falls back to its own eval-token verification (`app/auth.py`). Since the edge can't *inject* a verified `x-gateway-claims` on an ungated route, both edges **strip** any client-supplied one on `/agent` (agent decodes that header without a signature check, so an ungated caller could otherwise forge it); real user traffic reaches agent through `chat`, which forwards mesh-verified claims internally. .box {telemetry ingest is the other ungated route — and it is an exact path} type:{warning} - Devices and simulators carry no user JWT, so `/telemetry/ingest` is ungated at both edges: `handle` (not `handle_path …/*`) in the `Caddyfile`, and a no-principal `paths:` entry in `k8s/istio-request-authentication.yml`\. Both are **exact paths, kept in sync by hand** — an ingest sub-path such as `/telemetry/ingest/batch` falls through to the gated rule and returns `401` to every gateway. The request is HMAC-verified inside telemetry instead (`adapters/ingest_auth.rs`). `backend/acceptance/edge/` asserts the Caddy half; nothing exercises Istio locally, so the Kubernetes half stays manual. + Devices and simulators carry no user JWT, so `/telemetry/ingest` — and `/telemetry/collector`, where a collector reads its routers — is ungated at both edges: `handle` (not `handle_path …/*`) in the `Caddyfile`, and a no-principal `paths:` entry in `k8s/istio-request-authentication.yml`\. Both are **exact paths, kept in sync by hand** — an ingest sub-path such as `/telemetry/ingest/batch` falls through to the gated rule and returns `401` to every gateway. The request is HMAC-verified inside telemetry instead (`adapters/ingest_auth.rs`). `backend/acceptance/edge/` asserts the Caddy half; nothing exercises Istio locally, so the Kubernetes half stays manual. The internal HMAC-signed contract used for `claims-gateway` → `tenancy` and `provisioner` → `registry`/`tenancy` calls is the same convention across the whole Go control plane — see each service's `internal/api/internalauth.go` for the `X-Signature` implementation. diff --git a/documentation/architecture/deployment.qd b/documentation/architecture/deployment.qd index ba8d80bc..c2fff58c 100644 --- a/documentation/architecture/deployment.qd +++ b/documentation/architecture/deployment.qd @@ -103,7 +103,7 @@ Kubernetes ingress is Istio's Gateway API. `k8s/istio-gateway.yml` defines one ` **Per-service routes.** Each API route gets a `URLRewrite`/`ReplacePrefixMatch` filter that strips the service prefix before forwarding — so `/twin/buildings` reaches `digital-twin` as `/buildings`\. `socket`'s route and the client's `/` catch-all forward the path unrewritten; `/grafana` is forwarded intact to Grafana, which owns that subpath. Gateway API matches by path specificity, so the client catch-all is evaluated last regardless of file order. The routes cover every service including `claims-gateway` (`/gateway`) and `tenancy` (`/tenancy`). -**Edge security.** The Gateway runs with Istio ambient enrollment underneath it — `ztunnel` L4 mTLS, `PeerAuthentication: STRICT`, and edge `RequestAuthentication` verifying the gateway JWT once and injecting the validated claims as `x-gateway-claims`\. An `AuthorizationPolicy` requires a verified JWT principal on the protected service paths (`/twin`, `/telemetry`, `/notification`, `/chat`, `/socket.io`, `/tenancy`, `/contracts`); `/gateway`, `/agent`, `/telemetry/ingest`, `/grafana`, and the client SPA are deliberately open. `docker-compose` + Caddy's `forward_auth` mirrors this exactly for the dev loop. See [Service Mesh Architecture](service-mesh.qd) for the full migration and [Kubernetes Configuration](../config/kubernetes.qd#istio-ambient-mesh) for the setup commands and the real gotchas hit standing it up (CNI bin dir, flannel IP exhaustion, Git Bash path mangling). +**Edge security.** The Gateway runs with Istio ambient enrollment underneath it — `ztunnel` L4 mTLS, `PeerAuthentication: STRICT`, and edge `RequestAuthentication` verifying the gateway JWT once and injecting the validated claims as `x-gateway-claims`\. An `AuthorizationPolicy` requires a verified JWT principal on the protected service paths (`/twin`, `/telemetry`, `/notification`, `/chat`, `/socket.io`, `/tenancy`, `/contracts`); `/gateway`, `/agent`, `/telemetry/ingest`, `/telemetry/collector`, `/grafana`, and the client SPA are deliberately open. `docker-compose` + Caddy's `forward_auth` mirrors this exactly for the dev loop. See [Service Mesh Architecture](service-mesh.qd) for the full migration and [Kubernetes Configuration](../config/kubernetes.qd#istio-ambient-mesh) for the setup commands and the real gotchas hit standing it up (CNI bin dir, flannel IP exhaustion, Git Bash path mangling). --- diff --git a/documentation/config/sensor-simulator.qd b/documentation/config/sensor-simulator.qd index d1a4ea49..563303ae 100644 --- a/documentation/config/sensor-simulator.qd +++ b/documentation/config/sensor-simulator.qd @@ -70,6 +70,7 @@ CrowdVision ships three simulators that exist **outside the main application clu loop Every 10 seconds S->>T: POST TELEMETRY_INGEST_URL (signed batch) A->>T: POST TELEMETRY_INGEST_URL (signed batch) + C->>T: GET /telemetry/collector (routers, every syncIntervalS) C->>P: POST //ubus (station list per AP) C->>T: POST TELEMETRY_INGEST_URL (signed batch) end @@ -113,8 +114,8 @@ Fakes the routers, not their readings: the collector stays the only thing that t - not heard - Each router adds a fixed ±3 dB of its own, so two routers in one room still hear a phone differently. 2 dB noise per poll, −85 dBm cutoff. -- **ap-collector must list the same APs** to produce readings. Today its list is static (`collector.dev.json`), so the dashboard's start alone yields no router readings yet. -- The preset topology (`AP_SIM_SCENARIO`) keeps serving the static collector config. `/debug/status`, `/debug/scenario`, `/debug/kill/{ap}`, `/debug/revive/{ap}`, `/debug/ground-truth` inspect and drive it. +- **ap-collector finds them through the platform**, like at a real site: it reads the router sensors from `GET /telemetry/collector`. Simulated routers have no `endpoint`, so the dev config (`collector.dev.json`) fills it with `endpointTemplate: http://ap-simulator:3000/{sensorId}/ubus`. +- The preset topology (`AP_SIM_SCENARIO`) is no longer polled by any collector; `/debug/*` still inspects and drives it for hand tests. --- diff --git a/documentation/config/setting-up.qd b/documentation/config/setting-up.qd index 43ac385f..a276c4e8 100644 --- a/documentation/config/setting-up.qd +++ b/documentation/config/setting-up.qd @@ -102,7 +102,7 @@ This chains ten small, idempotent POSIX-sh scripts (`scripts/env/*.sh`), each re - - `gateway-key.sh` - A stable RSA signing key for `claims-gateway`, written to `secrets/gateway-dev-key.pem` — **not** \.`env` (see below). - - `ingest-key.sh` - - `TELEMETRY_INGEST_SECRET` — the HMAC key a gateway signs `/telemetry/ingest` bodies with. Deliberately distinct from `INTERNAL_SIGNING_SECRET`: it leaves the mesh and lives on customer premises. + - `TELEMETRY_INGEST_SECRET` — the HMAC key a gateway signs `/telemetry/ingest` bodies and `/telemetry/collector` reads with. Deliberately distinct from `INTERNAL_SIGNING_SECRET`: it leaves the mesh and lives on customer premises. - - `telemetry-db.sh` - `TELEMETRY_DB_PASSWORD` — backs `telemetry-db`. Read only by the Kubernetes path (`just k8s secrets`); Compose hardcodes `telemetry` in `backend/telemetry/docker-compose.yml`. diff --git a/documentation/design/wireless-flow.qd b/documentation/design/wireless-flow.qd index 19b8542b..1c734d24 100644 --- a/documentation/design/wireless-flow.qd +++ b/documentation/design/wireless-flow.qd @@ -144,6 +144,7 @@ calibration — and zone granularity is exactly what the twin renders. AP1 -->|RSSI per station| C AP2 --> C AP3 --> C + T2[GET /telemetry/collector] -->|routers: sensor id, room, ubus address| C C -->|aggregate counts, HMAC-signed| T[POST /telemetry/ingest] T --> RD{{Redis}} T --> PG[(Timescale)] @@ -153,6 +154,10 @@ calibration — and zone granularity is exactly what the twin renders. A small daemon on the building network. Polls each AP every few seconds, holds one in-memory table, emits one signed batch per tick. +- **Which APs**: the router sensors placed in the twin, read from `GET /telemetry/collector` at start and every `syncIntervalS` (300 s). A router that stays keeps its session and every device's hysteresis; telemetry unreachable keeps the previous list. +- **How to reach them**: the sensor's `endpoint` is the ubus URL (`endpointTemplate` fills a missing one); `driver` `openwrt-iwinfo` selects the iwinfo reader, anything else hostapd. +- **How to log in**: the collector's own file — one site-wide `ubus` login and `ifaces`, per-router `overrides`. Never sent to the platform. + On OpenWrt the readings come from hostapd over `ubus` — `hostapd. get_clients` returns every associated station with its `signal` in dBm. `iw dev station dump` is the equivalent without ubus. @@ -374,7 +379,8 @@ recommendations. | **One SSID across all APs** | Per-SSID randomization means a second SSID is a second identity for the same phone. | | **One SSID across 2.4 and 5 GHz** | Split-band SSIDs are two networks; use band steering. Standard practice regardless. | | **WPA2-AES or WPA3** | On open networks Apple rotates the private address every 2 weeks. Encryption pins it. | -| **Known AP → zone mapping** | The collector needs it. A flat config file; no coordinate survey. | +| **Every router placed in the twin** | A router sensor in its room *is* the AP → zone mapping; the collector reads it from `GET /telemetry/collector`. No coordinate survey. | +| **One read-only ubus login on the routers** | The collector's own file holds it (per-router `overrides` where one differs). Logins never reach the platform. | --- diff --git a/documentation/packages/telemetry-schema.qd b/documentation/packages/telemetry-schema.qd index 97b22a8e..37a2bbd6 100644 --- a/documentation/packages/telemetry-schema.qd +++ b/documentation/packages/telemetry-schema.qd @@ -91,6 +91,7 @@ Two telemetry shapes have no type here: one side walks raw JSON or is the only R |---|---|---|---| | `ingest-batch.json` | device → telemetry `/ingest` | ap-collector, aq-simulator, sensor-simulator | telemetry | | `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator, ap-simulator | +| `collector-routers.json` | telemetry → collector, `GET /collector` | telemetry | ap-collector | `simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }`. diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index c1d86d8f..5e2a9fd9 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -129,7 +129,15 @@ The signature covers the exact bytes on the wire, so a re-serialised body fails: There is deliberately no `/ingest/batch` alongside a single-reading `/ingest`. Two routes meant two kernel entry points, two store methods and two fan-out methods to keep in step — and a sub-path the edge does not ungate, since `Caddyfile` and the Istio policy exempt the exact path `/telemetry/ingest`. One route with N ≥ 1 removes both problems. +**Collector read** — `GET /collector`, the router list an on-site collector polls. Device-facing like ingest: exempt from the edge gate, signed by the caller. + +- `X-Timestamp` (unix seconds) and `X-Signature` = `HMAC-SHA256(TELEMETRY_INGEST_SECRET, "GET /collector\n" + timestamp)`. A GET has no body, so the timestamp is what is signed; more than 300 s off telemetry's clock is `401`, which bounds replay. Pinned by `schemas/fixtures/collector-request.json`. +- Answers `{ buildings: [{ buildingId, routers: [{ sensorId, roomId, driver?, endpoint? }] }] }`: every router sensor in a room, grouped by building. Outdoor routers and buildings without a router are left out. Pinned by `schemas/fixtures/collector-routers.json`. +- **Addresses only, never logins.** `endpoint` is the router's ubus URL on the site network; its credentials live in the collector's own file. + .box {One shared secret, not one per building} type:{note} + A leaked key also reads every building's router list, not only posts for any building. Per-building device keys close both, with no wire change. + Every gateway currently signs with the same key, so a legitimate gateway could still post for a building that is not its own. That is bounded by there being no third-party gateways yet. Per-building keys are a store lookup behind the same header, with no wire change. Replay is likewise undefended — a captured body stays valid — which matches the mesh's documented "hard perimeter, guarded interior" posture, where an in-mesh attacker could forge `x-gateway-claims` regardless. A threshold-evaluation failure is logged and does **not** fail the ingest; a persistence failure does, and is counted by `telemetry_ingest_persist_failures_total`. diff --git a/k8s/istio-request-authentication.yml b/k8s/istio-request-authentication.yml index d7e0d153..7e57f263 100644 --- a/k8s/istio-request-authentication.yml +++ b/k8s/istio-request-authentication.yml @@ -52,16 +52,17 @@ spec: - /tenancy/* - /dashboard - /dashboard/* - # Device/simulator telemetry carries no user JWT — it is HMAC-signed and - # verified inside telemetry, so allow this exact path with no principal. - # Exact, not a prefix, and mirrored by hand in the Caddyfile's - # `handle /telemetry/ingest` — a path added to one list and not the other is + # Devices (ingest) and collectors (their router list) carry no user JWT — both + # are HMAC-signed and verified inside telemetry, so allow these exact paths with + # no principal. Exact, not prefixes, and mirrored by hand in the Caddyfile's + # `handle /telemetry/ingest` and `handle /telemetry/collector` — a path added to one list and not the other is # ungated in exactly one environment. backend/acceptance/edge/ asserts the # Caddy side; nothing exercises Istio locally, so this half stays manual. - to: - operation: paths: - /telemetry/ingest + - /telemetry/collector - from: - source: requestPrincipals: ["cv-gateway/*"] diff --git a/schemas/CLAUDE.md b/schemas/CLAUDE.md index 2989d359..4578ef7a 100644 --- a/schemas/CLAUDE.md +++ b/schemas/CLAUDE.md @@ -21,6 +21,11 @@ Telemetry is its only Rust party, so its serialiser stays in telemetry and the c type. Each case names its `consumer` (`sensor-simulator`, `aq-simulator`, `ap-simulator`, `any`). **No coordinates, ever**: they are digital-twin's. A simulator gets sensor ids and rooms only. +**`fixtures/collector-routers.json` and `fixtures/collector-request.json`**: what telemetry answers +on `GET /telemetry/collector`, and how a collector signs that GET (a timestamp, not a body). +Telemetry builds and verifies them in Rust, ap-collector parses and signs them in Python. Neither +carries a router login: those stay on the site. + **The Cedar bundle is the exception — it stays in `backend/libs/auth-policy`.** Its fixture holds golden *decisions*, not a wire shape, is meaningless without `policy.cedar` and `schema.cedarschema` beside it, and Go imports the package as a real module. Don't move it here. @@ -34,7 +39,7 @@ them has a wire quirk a generator would flatten. |---|---|---| | Rust path deps | Rust↔Rust drift, at compile time | `Cargo.toml` `path = "../../schemas/…"` | | `fixtures/*.json` | one language's parser disagreeing with the others | Go `conformance_test.go`, Rust `tests/*conformance*.rs`, Python `tests/unit/test_*_conformance.py`, TS `frontend/src/utils/**/*.spec.ts`; `simulation-start` by sensor-simulator's `__tests__/startContract.test.ts` and aq-simulator's and ap-simulator's `tests/test_start_contract.py` (every case parses, every rejection is refused) | -| `json/*.schema.json` | a fixture drifting from the written contract | `twin-schema/tests/building_schema.rs`, `claims-schema/tests/{tenancy_domains,chat_conversation}_schema.rs`, `telemetry-schema/tests/{metric_contract,ingest_batch,simulation_start,telemetry_envelope}_schema.rs`, `notification-schema/tests/notification{,_preferences}_schema.rs`, agent's `test_schema_conformance.py` (claims, building, agent-stream) | +| `json/*.schema.json` | a fixture drifting from the written contract | `twin-schema/tests/building_schema.rs`, `claims-schema/tests/{tenancy_domains,chat_conversation}_schema.rs`, `telemetry-schema/tests/{metric_contract,ingest_batch,simulation_start,collector_routers,telemetry_envelope}_schema.rs`, `notification-schema/tests/notification{,_preferences}_schema.rs`, agent's `test_schema_conformance.py` (claims, building, agent-stream) | | the served bytes | a producer drifting from the fixture both sides agreed on | telemetry `tests/api.rs` compares `/contracts` against `fixtures/metric-contract.json`, and posts every `fixtures/ingest-batch.json` case; notification's `controllers.rs` posts every `fixtures/notification-preferences.json` request and rejection | | the producer's own output | a hand-built payload drifting from the fixture | agent's `test_stream_conformance.py` runs `stream_answer` and compares the frames; chat's `agent.rs` tests replay them through the real `SseReader`; chat's `conversation.rs` round-trips every `fixtures/chat-conversation.json` shape through its own types; telemetry's `redis_fanout.rs` publishes every `fixtures/telemetry-envelope.json` tick byte for byte; notification's `alerts.rs` publishes a breach and compares it to `fixtures/notification.json`; socket's `relay.rs` routes every case and skips every rejection; telemetry's `simulators.rs` posts the `fixtures/simulation-start.json` bodies and wiremock matches them exactly | diff --git a/schemas/fixtures/collector-request.json b/schemas/fixtures/collector-request.json new file mode 100644 index 00000000..25043b5a --- /dev/null +++ b/schemas/fixtures/collector-request.json @@ -0,0 +1,24 @@ +{ + "description": "How a collector signs GET /telemetry/collector. A GET has no body, so it signs a canonical string instead; the timestamp bounds replay. Built by ap-collector (Python), verified by telemetry (Rust).", + "headers": { + "signature": "X-Signature", + "timestamp": "X-Timestamp" + }, + "canonical": "GET /collector\\n{timestamp}", + "algorithm": "hex(HMAC-SHA256(secret, canonical))", + "timestamp": "Unix seconds, decimal. Refused when more than maxSkewS away from telemetry's clock.", + "maxSkewS": 300, + "secret": "an-internal-secret-that-is-at-least-32-bytes", + "cases": [ + { + "name": "a timestamp", + "timestamp": "1757251200", + "signature": "35ed92d508ad8741240dd6ce49c5d35f6840f73524912ab973396fcf926ea0ba" + }, + { + "name": "one second later signs differently", + "timestamp": "1757251201", + "signature": "846d2dbea0ba74e72ecd86c7fd1b7d19a5c183fb80e265e9a803c2bec63827f7" + } + ] +} diff --git a/schemas/fixtures/collector-routers.json b/schemas/fixtures/collector-routers.json new file mode 100644 index 00000000..7db1aedf --- /dev/null +++ b/schemas/fixtures/collector-routers.json @@ -0,0 +1,105 @@ +{ + "description": "What GET /telemetry/collector answers: every router a collector may poll, grouped by building. Built by telemetry (Rust) from its sensors; parsed by ap-collector (Python).", + "rules": { + "buildings": "Only buildings with at least one router in a room. Empty when there are none.", + "roomId": "Always a room: a reading is room-keyed, so an outdoor router is never sent.", + "driver": "Optional. The device model; the collector picks its ubus reader from it (openwrt-iwinfo, else hostapd).", + "endpoint": "Optional. The router's ubus URL on the site network. Absent until someone sets it; a collector may fill it from its own endpointTemplate.", + "credentials": "Never. Router logins stay in the collector's local config; the platform stores addresses only." + }, + "cases": [ + { + "name": "two buildings, one router without driver or endpoint", + "body": { + "buildings": [ + { + "buildingId": "bldg-3f2b4c5d", + "routers": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "roomId": "room-aula-magna", + "driver": "openwrt-hostapd", + "endpoint": "http://10.0.4.12/ubus" + }, + { + "sensorId": "f7081920-3b4c-4d5e-9f60-718293a4b5c6", + "roomId": "room-lab-2" + } + ] + }, + { + "buildingId": "bldg-9a8b7c6d", + "routers": [ + { + "sensorId": "08192a3b-4c5d-4e6f-8071-8293a4b5c6d7", + "roomId": "ground-floor", + "driver": "openwrt-iwinfo", + "endpoint": "http://10.1.0.3/ubus" + } + ] + } + ] + } + }, + { + "name": "no building has a router", + "body": { + "buildings": [] + } + } + ], + "rejected": [ + { + "name": "an outdoor router", + "reason": "roomId: must be a room; readings are room-keyed", + "body": { + "buildings": [ + { + "buildingId": "bldg-3f2b4c5d", + "routers": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "roomId": null + } + ] + } + ] + } + }, + { + "name": "a building with no router", + "reason": "routers: a building without one is left out, not listed empty", + "body": { + "buildings": [ + { + "buildingId": "bldg-3f2b4c5d", + "routers": [] + } + ] + } + }, + { + "name": "a router login", + "reason": "credentials never leave the site", + "body": { + "buildings": [ + { + "buildingId": "bldg-3f2b4c5d", + "routers": [ + { + "sensorId": "e6f70819-2a3b-4c4d-8e5f-60718293a4b5", + "roomId": "room-lab-2", + "password": "collector" + } + ] + } + ] + } + }, + { + "name": "no buildings list", + "reason": "buildings: required", + "body": {} + } + ] +} diff --git a/schemas/json/collector-routers.schema.json b/schemas/json/collector-routers.schema.json new file mode 100644 index 00000000..2f312fae --- /dev/null +++ b/schemas/json/collector-routers.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crowdvision.dev/schemas/collector-routers.schema.json", + "title": "CollectorRouters", + "description": "What GET /telemetry/collector answers: the routers a collector may poll, grouped by building. Built by telemetry in Rust, parsed by ap-collector in Python.", + "type": "object", + "required": [ + "buildings" + ], + "additionalProperties": false, + "properties": { + "buildings": { + "type": "array", + "items": { + "$ref": "#/$defs/building" + } + } + }, + "$defs": { + "building": { + "type": "object", + "required": [ + "buildingId", + "routers" + ], + "additionalProperties": false, + "properties": { + "buildingId": { + "type": "string", + "minLength": 1 + }, + "routers": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/router" + } + } + } + }, + "router": { + "type": "object", + "required": [ + "sensorId", + "roomId" + ], + "additionalProperties": false, + "$comment": "No credentials: router logins stay in the collector's local config.", + "properties": { + "sensorId": { + "type": "string", + "minLength": 1 + }, + "roomId": { + "type": "string", + "minLength": 1 + }, + "driver": { + "type": "string", + "minLength": 1 + }, + "endpoint": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/schemas/telemetry-schema/tests/collector_routers_schema.rs b/schemas/telemetry-schema/tests/collector_routers_schema.rs new file mode 100644 index 00000000..137d4473 --- /dev/null +++ b/schemas/telemetry-schema/tests/collector_routers_schema.rs @@ -0,0 +1,46 @@ +const FIXTURE: &str = include_str!("../../fixtures/collector-routers.json"); +const SCHEMA: &str = include_str!("../../json/collector-routers.schema.json"); + +// Telemetry builds this answer and ap-collector parses it in Python, so no compiler sees both. +// This crate hosts the check because it already carries `jsonschema`. +#[test] +fn every_answer_the_fixture_accepts_matches_the_schema() { + let validator = validator(); + + for case in cases("cases") { + let errors: Vec = validator + .iter_errors(&case["body"]) + .map(|error| error.to_string()) + .collect(); + assert!(errors.is_empty(), "{}: {}", case["name"], errors.join("; ")); + } +} + +// Each rejection is a rule the collector enforces in Python; one the schema still accepts is a +// rule written in only one place. +#[test] +fn every_answer_the_fixture_rejects_fails_the_schema() { + let validator = validator(); + + for case in cases("rejected") { + assert!( + !validator.is_valid(&case["body"]), + "{} validated, but the collector refuses it: {}", + case["name"], + case["reason"] + ); + } +} + +fn cases(group: &str) -> Vec { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture parses"); + fixture[group] + .as_array() + .unwrap_or_else(|| panic!("the fixture has a {group} array")) + .clone() +} + +fn validator() -> jsonschema::Validator { + let schema: serde_json::Value = serde_json::from_str(SCHEMA).expect("schema parses"); + jsonschema::validator_for(&schema).expect("schema compiles") +} From 238fad610ef54c80d7b8d16de5629b55b9a9441c Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Sat, 26 Sep 2026 16:13:12 +0200 Subject: [PATCH 05/10] feat(telemetry): per-building device keys for collectors --- api/telemetry.yaml | 52 +++- .../acceptance/docker-compose.integration.yml | 1 + backend/acceptance/run-integration-tests.sh | 8 +- backend/acceptance/support/telemetry.py | 2 +- backend/telemetry/CLAUDE.md | 5 + backend/telemetry/docker-compose.yml | 1 + .../migrations/0006_device_key_epoch.down.sql | 1 + .../migrations/0006_device_key_epoch.up.sql | 2 + .../src/adapters/driven/postgres/buildings.rs | 24 +- .../adapters/driving/http_api/controllers.rs | 26 +- backend/telemetry/src/adapters/ingest_auth.rs | 271 +++++++++++++----- backend/telemetry/src/kernel/fakes.rs | 38 ++- backend/telemetry/src/kernel/ports.rs | 9 + backend/telemetry/src/lib.rs | 8 +- backend/telemetry/src/main.rs | 20 +- backend/telemetry/src/state.rs | 4 +- backend/telemetry/tests/api.rs | 96 +++++++ backend/telemetry/tests/support/test_app.rs | 45 ++- devices/ap-collector/app/__main__.py | 26 +- devices/ap-collector/app/config.py | 23 +- devices/ap-collector/app/routers.py | 14 +- devices/ap-collector/tests/test_config.py | 16 +- devices/ap-collector/tests/test_main.py | 23 ++ devices/ap-collector/tests/test_routers.py | 12 +- documentation/architecture/data-flow.qd | 2 +- documentation/config/sensor-simulator.qd | 2 +- documentation/config/setting-up.qd | 3 +- documentation/design/wireless-flow.qd | 1 + documentation/services/telemetry.qd | 17 +- schemas/CLAUDE.md | 3 +- schemas/fixtures/collector-request.json | 19 +- scripts/env/ingest-key.sh | 24 +- scripts/k8s/k8s-secrets.sh | 7 +- 33 files changed, 658 insertions(+), 147 deletions(-) create mode 100644 backend/telemetry/migrations/0006_device_key_epoch.down.sql create mode 100644 backend/telemetry/migrations/0006_device_key_epoch.up.sql diff --git a/api/telemetry.yaml b/api/telemetry.yaml index 6dd5dbf8..26b53fa7 100644 --- a/api/telemetry.yaml +++ b/api/telemetry.yaml @@ -28,8 +28,9 @@ components: in: header name: x-signature description: > - Lowercase-hex HMAC-SHA256 over the raw request body, keyed by - TELEMETRY_INGEST_SECRET. A sensor gateway has no user JWT, so this is + Lowercase-hex HMAC-SHA256 over the raw request body, keyed by the + device key of the body's buildingId (or, in dev only, the shared + TELEMETRY_INGEST_SECRET). A sensor gateway has no user JWT, so this is how it authenticates itself. The signature covers the exact bytes on the wire — sign the string you send, never a re-serialised copy. collectorSignature: @@ -37,10 +38,11 @@ components: in: header name: x-signature description: > - Lowercase-hex HMAC-SHA256, keyed by TELEMETRY_INGEST_SECRET, over - "GET /collector\n" followed by the x-timestamp value. A GET has no body, - so the signed timestamp is what proves the caller holds the key; a - timestamp more than 300 s from telemetry's clock is refused. + Lowercase-hex HMAC-SHA256, keyed by the buildingId's device key, over + "GET /collector\n{buildingId}\n{x-timestamp}". A GET has no body, so + the signed building and timestamp are what prove the caller holds the + key; a timestamp more than 300 s from telemetry's clock is refused. + Without buildingId only the dev shared key signs. collectorTimestamp: type: apiKey in: header @@ -284,6 +286,38 @@ security: - gatewayClaims: [] paths: + /device-keys/buildings/{buildingId}: + post: + summary: Issue a building's device key + description: > + Revokes the building's current device key and returns its new one. This + is the only time the key is shown: put it in the collector's `keys` and + store it nowhere else. Keys are derived from the service's master key, + never stored; other buildings' keys are untouched. + tags: [Ingestion] + security: + - gatewayClaims: [] + parameters: + - name: buildingId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The new key; the previous one no longer signs + content: + application/json: + schema: + type: object + required: [buildingId, key] + properties: + buildingId: { type: string, example: "bldg-3f2b4c5d" } + key: { type: string, description: 64 lowercase hex characters } + "403": + description: The caller may not edit this building + "404": + description: The building is not registered + /collector: get: summary: Routers an on-site collector polls @@ -300,6 +334,12 @@ paths: security: - collectorSignature: [] collectorTimestamp: [] + parameters: + - name: buildingId + in: query + required: false + description: The building whose routers to read; the signing key must be its device key. + schema: { type: string, example: "bldg-3f2b4c5d" } responses: "200": description: The routers, by building diff --git a/backend/acceptance/docker-compose.integration.yml b/backend/acceptance/docker-compose.integration.yml index dbf4f6f8..376031ca 100644 --- a/backend/acceptance/docker-compose.integration.yml +++ b/backend/acceptance/docker-compose.integration.yml @@ -60,6 +60,7 @@ services: - DASHBOARD_URL=http://dashboard:3000 - EDGE_URL=http://gateway - TELEMETRY_INGEST_SECRET=${TELEMETRY_INGEST_SECRET} + - TELEMETRY_DEVICE_MASTER_KEY=${TELEMETRY_DEVICE_MASTER_KEY} volumes: # :z relabels the bind mount for SELinux-enforcing hosts (same reason # the root compose file's gateway service does it for its own mounts) diff --git a/backend/acceptance/run-integration-tests.sh b/backend/acceptance/run-integration-tests.sh index fdef2778..d532b420 100755 --- a/backend/acceptance/run-integration-tests.sh +++ b/backend/acceptance/run-integration-tests.sh @@ -13,11 +13,11 @@ export COMPOSE_BAKE=true cd "$(dirname "${BASH_SOURCE[0]}")/../.." # repo root -# telemetry refuses to boot without an ingest signing key, and both it -# and the test client read the same one out of the repo-root .env that compose -# auto-loads. Generating it here keeps the suite runnable without `just stack env`. +# telemetry refuses to boot without its device master key, and both it and the +# test client read the shared ingest key out of the repo-root .env that compose +# auto-loads. Generating them here keeps the suite runnable without `just stack env`. mise exec -- sh scripts/env/ingest-key.sh || { - echo "::error::could not generate TELEMETRY_INGEST_SECRET" >&2 + echo "::error::could not generate TELEMETRY_INGEST_SECRET / TELEMETRY_DEVICE_MASTER_KEY" >&2 exit 1 } diff --git a/backend/acceptance/support/telemetry.py b/backend/acceptance/support/telemetry.py index c414fe29..a7e1a63a 100644 --- a/backend/acceptance/support/telemetry.py +++ b/backend/acceptance/support/telemetry.py @@ -112,6 +112,6 @@ def read_collector(client: httpx.Client, url: str, *, signed: bool = True) -> ht return client.get(url) stamp = str(int(time.time())) signature = hmac.new( - config.TELEMETRY_INGEST_SECRET.encode(), f"GET /collector\n{stamp}".encode(), hashlib.sha256 + config.TELEMETRY_INGEST_SECRET.encode(), f"GET /collector\n\n{stamp}".encode(), hashlib.sha256 ).hexdigest() return client.get(url, headers={"X-Signature": signature, "X-Timestamp": stamp}) diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 106be37f..74d434ca 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -105,3 +105,8 @@ just test telemetry-integration # tests/*.rs against a throwaway TimescaleDB, `tests/` covers `api`, `persistence`, `fanout`, `alerts`, `registration`, `architecture`. Migrations live in `migrations/`. + +**Device keys are derived, never stored.** A building's key is `HMAC(TELEMETRY_DEVICE_MASTER_KEY, +"{buildingId}:{epoch}")`; `buildings.device_key_epoch` is the only state, bumped by +`POST /device-keys/buildings/{id}` to revoke. Read uncached, so a rotation holds on every replica. +`TELEMETRY_INGEST_SECRET` is the optional shared key that signs for any building — dev only. diff --git a/backend/telemetry/docker-compose.yml b/backend/telemetry/docker-compose.yml index 6f2e8da5..05918af8 100644 --- a/backend/telemetry/docker-compose.yml +++ b/backend/telemetry/docker-compose.yml @@ -16,6 +16,7 @@ services: # Which simulator simulates which device kind; a kind no simulator claims is never sent. - 'SIMULATORS={"sensor-simulator":{"url":"http://sensor-simulator:3000","kinds":["temperature","peopleCount"]},"aq-simulator":{"url":"http://aq-simulator:3000","kinds":["airQuality"]},"ap-simulator":{"url":"http://ap-simulator:3000","kinds":["router"]}}' - TELEMETRY_INGEST_SECRET=${TELEMETRY_INGEST_SECRET} + - TELEMETRY_DEVICE_MASTER_KEY=${TELEMETRY_DEVICE_MASTER_KEY} - PORT=3000 - RUST_LOG=info healthcheck: diff --git a/backend/telemetry/migrations/0006_device_key_epoch.down.sql b/backend/telemetry/migrations/0006_device_key_epoch.down.sql new file mode 100644 index 00000000..d203b628 --- /dev/null +++ b/backend/telemetry/migrations/0006_device_key_epoch.down.sql @@ -0,0 +1 @@ +alter table buildings drop column device_key_epoch; diff --git a/backend/telemetry/migrations/0006_device_key_epoch.up.sql b/backend/telemetry/migrations/0006_device_key_epoch.up.sql new file mode 100644 index 00000000..08f0bb57 --- /dev/null +++ b/backend/telemetry/migrations/0006_device_key_epoch.up.sql @@ -0,0 +1,2 @@ +-- A building's device key is derived from the master key and this epoch; bumping it revokes the key. +alter table buildings add column device_key_epoch integer not null default 0; diff --git a/backend/telemetry/src/adapters/driven/postgres/buildings.rs b/backend/telemetry/src/adapters/driven/postgres/buildings.rs index c6eb86c3..e92f6364 100644 --- a/backend/telemetry/src/adapters/driven/postgres/buildings.rs +++ b/backend/telemetry/src/adapters/driven/postgres/buildings.rs @@ -1,4 +1,4 @@ -use crate::kernel::ports::BuildingStore; +use crate::kernel::ports::{BuildingStore, DeviceKeyStore}; use crate::types::building::{BuildingNames, RegisteredBuilding}; use async_trait::async_trait; use sqlx::PgPool; @@ -70,3 +70,25 @@ impl BuildingStore for PgBuildings { })) } } + +#[async_trait] +impl DeviceKeyStore for PgBuildings { + async fn epoch(&self, building_id: &str) -> anyhow::Result> { + Ok( + sqlx::query_scalar("select device_key_epoch from buildings where id = $1") + .bind(building_id) + .fetch_optional(&self.pool) + .await?, + ) + } + + async fn rotate(&self, building_id: &str) -> anyhow::Result> { + Ok(sqlx::query_scalar( + "update buildings set device_key_epoch = device_key_epoch + 1 where id = $1 + returning device_key_epoch", + ) + .bind(building_id) + .fetch_optional(&self.pool) + .await?) + } +} diff --git a/backend/telemetry/src/adapters/driving/http_api/controllers.rs b/backend/telemetry/src/adapters/driving/http_api/controllers.rs index f8203709..2e0390c0 100644 --- a/backend/telemetry/src/adapters/driving/http_api/controllers.rs +++ b/backend/telemetry/src/adapters/driving/http_api/controllers.rs @@ -1,3 +1,4 @@ +use crate::adapters::ingest_auth::CollectorQuery; use crate::adapters::metrics; use crate::kernel::authz; use crate::kernel::readings::DashboardQuery; @@ -304,12 +305,18 @@ pub async fn building_sensors( } /// Every router a collector may poll, by building. Signed by a collector, never by a user. -pub async fn collector(State(state): State>) -> Result, DomainError> { +pub async fn collector( + State(state): State>, + Query(query): Query, +) -> Result, DomainError> { + // The verifier already bound the signature to this buildingId, or to the shared key. + let wanted = |building: &str| query.building_id.as_deref().is_none_or(|id| id == building); let buildings: Vec = state .sensors .for_collector() .await? .iter() + .filter(|building| wanted(&building.building_id)) .map(|building| { let routers: Vec = building.routers.iter().map(collector_router).collect(); json!({ "buildingId": building.building_id, "routers": routers }) @@ -329,6 +336,23 @@ fn collector_router(router: &Sensor) -> Value { entry } +/// Rotates the building's device key and returns the new one; the only time it is shown. +pub async fn issue_device_key( + State(state): State>, + Path(building_id): Path, + claims: GatewayClaims, +) -> Result, DomainError> { + edit(&state, &claims, &building_id).await?; + let key = state + .device_keys + .issue(&building_id) + .await? + .ok_or_else(|| { + DomainError::NotFound(format!("building {building_id} is not registered.")) + })?; + Ok(Json(json!({ "buildingId": building_id, "key": key }))) +} + pub async fn room_sensors( State(state): State>, Path((building_id, room_id)): Path<(String, String)>, diff --git a/backend/telemetry/src/adapters/ingest_auth.rs b/backend/telemetry/src/adapters/ingest_auth.rs index 50428c86..cc1958e9 100644 --- a/backend/telemetry/src/adapters/ingest_auth.rs +++ b/backend/telemetry/src/adapters/ingest_auth.rs @@ -1,5 +1,5 @@ use axum::body::Body; -use axum::extract::{Request, State}; +use axum::extract::{Query, Request, State}; use axum::http::StatusCode; use axum::middleware::Next; use axum::response::Response; @@ -9,6 +9,8 @@ use std::fmt::Write; use std::sync::Arc; use crate::adapters::metrics; +use crate::kernel::ports::DeviceKeyStore; +use serde::Deserialize; pub const SIGNATURE_HEADER: &str = "x-signature"; pub const TIMESTAMP_HEADER: &str = "x-timestamp"; @@ -41,50 +43,119 @@ impl IngestKey { hex }) } +} - /// A GET has no body, so a collector signs this canonical string instead. - pub fn sign_collector_request(&self, timestamp: &str) -> String { - self.sign(format!("GET /collector\n{timestamp}").as_bytes()) - } +/// What a collector signs for `GET /collector`: a GET has no body, so a canonical string. +pub fn collector_message(building_id: Option<&str>, timestamp: &str) -> String { + format!( + "GET /collector\n{}\n{timestamp}", + building_id.unwrap_or_default() + ) } -/// Signed by this key, and stamped within `MAX_SKEW_S` of `now_s`. -pub fn collector_request_is_valid( - key: &IngestKey, - signature: &str, - timestamp: &str, - now_s: i64, -) -> bool { - let fresh = timestamp +/// Stamped within `MAX_SKEW_S` of `now_s`. +pub fn is_fresh(timestamp: &str, now_s: i64) -> bool { + timestamp .parse::() - .is_ok_and(|stamped| (now_s - stamped).abs() <= MAX_SKEW_S); - fresh && constant_time_eq(&key.sign_collector_request(timestamp), signature) + .is_ok_and(|stamped| (now_s - stamped).abs() <= MAX_SKEW_S) +} + +/// Per-building device keys, derived from one master key, plus the shared key where configured. +#[derive(Clone)] +pub struct DeviceKeys { + master: IngestKey, + shared: Option, + epochs: Arc, +} + +impl DeviceKeys { + pub fn new( + master: &str, + shared: Option<&str>, + epochs: Arc, + ) -> anyhow::Result { + Ok(Self { + master: IngestKey::new(master)?, + shared: shared.map(IngestKey::new).transpose()?, + epochs, + }) + } + + /// The building's key at `epoch`: hex HMAC(master, "{building}:{epoch}"). + pub fn derive(&self, building_id: &str, epoch: i32) -> String { + self.master + .sign(format!("{building_id}:{epoch}").as_bytes()) + } + + /// Revokes the building's key and returns its new one; `None` when it is not registered. + pub async fn issue(&self, building_id: &str) -> anyhow::Result> { + let epoch = self.epochs.rotate(building_id).await?; + Ok(epoch.map(|epoch| self.derive(building_id, epoch))) + } + + /// Signed by the shared key, or by `building_id`'s current key. + pub async fn accepts( + &self, + building_id: Option<&str>, + message: &[u8], + signature: &str, + ) -> bool { + let signed_by = |key: &IngestKey| constant_time_eq(&key.sign(message), signature); + if self.shared.as_ref().is_some_and(signed_by) { + return true; + } + let Some(building_id) = building_id else { + return false; + }; + match self.epochs.epoch(building_id).await { + Ok(Some(epoch)) => { + IngestKey::new(&self.derive(building_id, epoch)).is_ok_and(|key| signed_by(&key)) + } + Ok(None) => false, + Err(error) => { + log::warn!("device key epoch unreadable for {building_id}: {error:?}"); + false + } + } + } +} + +#[derive(Deserialize)] +pub struct CollectorQuery { + #[serde(rename = "buildingId")] + pub building_id: Option, } pub async fn verify_collector_request( - State(key): State, + State(keys): State, request: Request, next: Next, ) -> Result { - let valid = { + let (building, message, signature, fresh) = { let header = |name: &str| { request .headers() .get(name) .and_then(|value| value.to_str().ok()) .unwrap_or_default() + .to_owned() }; let now_s = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |elapsed| elapsed.as_secs() as i64); - collector_request_is_valid( - &key, - header(SIGNATURE_HEADER), - header(TIMESTAMP_HEADER), - now_s, - ) + let building = Query::::try_from_uri(request.uri()) + .map_err(|_| StatusCode::BAD_REQUEST)? + .0 + .building_id; + let timestamp = header(TIMESTAMP_HEADER); + let message = collector_message(building.as_deref(), ×tamp); + let fresh = is_fresh(×tamp, now_s); + (building, message, header(SIGNATURE_HEADER), fresh) }; - match valid { + let signed = keys + .accepts(building.as_deref(), message.as_bytes(), &signature) + .await; + match fresh && signed { true => Ok(next.run(request).await), false => Err(StatusCode::UNAUTHORIZED), } @@ -98,8 +169,14 @@ fn constant_time_eq(a: &str, b: &str) -> bool { == 0 } +#[derive(Deserialize)] +struct Addressed { + #[serde(rename = "buildingId")] + building_id: String, +} + pub async fn verify_signature( - State(key): State, + State(keys): State, request: Request, next: Next, ) -> Result { @@ -118,7 +195,10 @@ pub async fn verify_signature( .await .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?; - if !constant_time_eq(&key.sign(&bytes), &signature) { + // Read only to pick the key; the handler still validates the whole body. + let building = serde_json::from_slice::(&bytes).ok(); + let building_id = building.as_ref().map(|b| b.building_id.as_str()); + if !keys.accepts(building_id, &bytes, &signature).await { metrics::record_ingest("unknown", "bad_signature"); return Err(StatusCode::UNAUTHORIZED); } @@ -131,6 +211,7 @@ pub async fn verify_signature( #[cfg(test)] mod tests { use super::*; + use crate::kernel::fakes::FakeDeviceKeys; const SECRET: &str = "0123456789abcdef0123456789abcdef"; @@ -165,14 +246,16 @@ mod tests { const REQUEST_FIXTURE: &str = include_str!("../../../../schemas/fixtures/collector-request.json"); - #[test] - fn collector_requests_sign_as_the_fixture_pins() { + #[tokio::test] + async fn collector_requests_sign_as_the_fixture_pins() { let fixture: serde_json::Value = serde_json::from_str(REQUEST_FIXTURE).unwrap(); - let key = IngestKey::new(fixture["secret"].as_str().unwrap()).unwrap(); + let shared = IngestKey::new(fixture["secret"].as_str().unwrap()).unwrap(); assert_eq!(fixture["maxSkewS"], MAX_SKEW_S); for case in fixture["cases"].as_array().unwrap() { + let building = case["buildingId"].as_str().filter(|b| !b.is_empty()); + let message = collector_message(building, case["timestamp"].as_str().unwrap()); assert_eq!( - key.sign_collector_request(case["timestamp"].as_str().unwrap()), + shared.sign(message.as_bytes()), case["signature"].as_str().unwrap(), "{}", case["name"] @@ -180,52 +263,104 @@ mod tests { } } - fn signed_at(timestamp: &str) -> (String, String) { - ( - key().sign_collector_request(timestamp), - timestamp.to_owned(), - ) + #[test] + fn a_timestamp_is_fresh_only_within_the_skew() { + assert!(is_fresh("1000", 1000 + MAX_SKEW_S)); + assert!(is_fresh("1000", 1000 - MAX_SKEW_S)); + assert!(!is_fresh("1000", 1001 + MAX_SKEW_S)); + assert!(!is_fresh("1000", 999 - MAX_SKEW_S)); + assert!(!is_fresh("soon", 1000)); } - #[test] - fn a_fresh_signed_request_is_accepted() { - let (signature, timestamp) = signed_at("1000"); - assert!(collector_request_is_valid( - &key(), - &signature, - ×tamp, - 1000 + MAX_SKEW_S - )); + const MASTER: &str = "a-device-master-key-that-is-at-least-32-bytes"; + + fn keys(shared: Option<&str>) -> DeviceKeys { + let epochs = Arc::new(FakeDeviceKeys::registered(&[ + "bldg-3f2b4c5d", + "bldg-9a8b7c6d", + ])); + DeviceKeys::new(MASTER, shared, epochs).unwrap() } #[test] - fn a_request_older_or_newer_than_the_skew_is_refused() { - let (signature, timestamp) = signed_at("1000"); - assert!(!collector_request_is_valid( - &key(), - &signature, - ×tamp, - 1001 + MAX_SKEW_S - )); - assert!(!collector_request_is_valid( - &key(), - &signature, - ×tamp, - 999 - MAX_SKEW_S - )); + fn a_building_key_is_hmac_of_its_id_and_epoch_under_the_master_key() { + let keys = keys(None); + assert_eq!( + keys.derive("bldg-3f2b4c5d", 0), + "b42c3c8cd51584926a35e21ad0e106796e41b6b4bd19333c8b02a203ed34aa3a" + ); + assert_eq!( + keys.derive("bldg-3f2b4c5d", 1), + "f7d42c6926e516f7aaded3441173fe0a59c7b9b860838549cb5b07d4b04def6b" + ); + assert_eq!( + keys.derive("bldg-9a8b7c6d", 0), + "c33a7441dbbd659135d8672c14052891ea2451382a0c16532508b9a9d179fb8f" + ); + } + + fn signed_by(key: &str, message: &[u8]) -> String { + IngestKey::new(key).unwrap().sign(message) + } + + #[tokio::test] + async fn a_building_key_signs_for_its_own_building_only() { + let keys = keys(None); + let signature = signed_by(&keys.derive("bldg-3f2b4c5d", 0), b"tick"); + assert!( + keys.accepts(Some("bldg-3f2b4c5d"), b"tick", &signature) + .await + ); + assert!( + !keys + .accepts(Some("bldg-9a8b7c6d"), b"tick", &signature) + .await + ); + assert!(!keys.accepts(None, b"tick", &signature).await); + } + + #[tokio::test] + async fn rotating_a_building_revokes_its_previous_key() { + let epochs = Arc::new(FakeDeviceKeys::registered(&["bldg-3f2b4c5d"])); + let keys = DeviceKeys::new(MASTER, None, epochs.clone()).unwrap(); + let old = signed_by(&keys.derive("bldg-3f2b4c5d", 0), b"tick"); + + let fresh = keys.issue("bldg-3f2b4c5d").await.unwrap().unwrap(); + + assert!(!keys.accepts(Some("bldg-3f2b4c5d"), b"tick", &old).await); + let new = signed_by(&fresh, b"tick"); + assert!(keys.accepts(Some("bldg-3f2b4c5d"), b"tick", &new).await); + } + + #[tokio::test] + async fn an_unregistered_building_has_no_key_to_issue_or_accept() { + let keys = keys(None); + assert_eq!(keys.issue("ghost").await.unwrap(), None); + let forged = signed_by(&keys.derive("ghost", 0), b"tick"); + assert!(!keys.accepts(Some("ghost"), b"tick", &forged).await); + } + + #[tokio::test] + async fn the_shared_key_signs_for_any_building_only_when_configured() { + let with_shared = keys(Some(SECRET)); + let signature = key().sign(b"tick"); + assert!( + with_shared + .accepts(Some("bldg-9a8b7c6d"), b"tick", &signature) + .await + ); + assert!(with_shared.accepts(None, b"tick", &signature).await); + assert!( + !keys(None) + .accepts(Some("bldg-9a8b7c6d"), b"tick", &signature) + .await + ); } #[test] - fn a_retimed_request_or_a_bad_timestamp_is_refused() { - let (signature, _) = signed_at("1000"); - assert!(!collector_request_is_valid( - &key(), - &signature, - "1001", - 1000 - )); - let (garbled, _) = signed_at("soon"); - assert!(!collector_request_is_valid(&key(), &garbled, "soon", 1000)); + fn a_short_master_key_is_rejected() { + let epochs = Arc::new(FakeDeviceKeys::default()); + assert!(DeviceKeys::new("too-short", None, epochs).is_err()); } #[test] diff --git a/backend/telemetry/src/kernel/fakes.rs b/backend/telemetry/src/kernel/fakes.rs index 29540333..a2f83a7a 100644 --- a/backend/telemetry/src/kernel/fakes.rs +++ b/backend/telemetry/src/kernel/fakes.rs @@ -1,6 +1,6 @@ use crate::kernel::ports::{ - ActionDispatch, Alerts, BuildingDirectory, BuildingStore, Clock, DispatchError, Fanout, - ReadingStore, RegistrationEvents, SensorStore, SimulatorControl, ThresholdStore, + ActionDispatch, Alerts, BuildingDirectory, BuildingStore, Clock, DeviceKeyStore, DispatchError, + Fanout, ReadingStore, RegistrationEvents, SensorStore, SimulatorControl, ThresholdStore, }; use crate::types::building::RegisteredBuilding; use crate::types::event::{AlertPayload, TelemetryEvent}; @@ -14,6 +14,7 @@ use crate::types::simulation::SimulatedSensor; use crate::types::threshold::{Bounds, RoomTemperatureLimit, TemperatureLimits}; use async_trait::async_trait; use serde_json::{Map, Value}; +use std::collections::HashMap; use std::sync::Mutex; static FAKE_DESCRIPTOR: MetricDescriptor = MetricDescriptor { @@ -641,3 +642,36 @@ impl SimulatorControl for FakeSimulators { Ok(self.running.iter().any(|name| name == simulator)) } } + +/// Epochs per registered building; an unknown building has none. +#[derive(Default)] +pub struct FakeDeviceKeys { + pub epochs: Mutex>, +} + +impl FakeDeviceKeys { + pub fn registered(buildings: &[&str]) -> Self { + Self { + epochs: Mutex::new(buildings.iter().map(|b| ((*b).to_owned(), 0)).collect()), + } + } +} + +#[async_trait] +impl DeviceKeyStore for FakeDeviceKeys { + async fn epoch(&self, building_id: &str) -> anyhow::Result> { + Ok(self.epochs.lock().unwrap().get(building_id).copied()) + } + + async fn rotate(&self, building_id: &str) -> anyhow::Result> { + Ok(self + .epochs + .lock() + .unwrap() + .get_mut(building_id) + .map(|epoch| { + *epoch += 1; + *epoch + })) + } +} diff --git a/backend/telemetry/src/kernel/ports.rs b/backend/telemetry/src/kernel/ports.rs index e7d79a3a..7afb16d4 100644 --- a/backend/telemetry/src/kernel/ports.rs +++ b/backend/telemetry/src/kernel/ports.rs @@ -112,6 +112,15 @@ pub trait BuildingStore: Send + Sync { async fn names_of(&self, building_id: &str) -> anyhow::Result>; } +/// The per-building counter a device key is derived with; bumping it revokes the old key. +#[async_trait] +pub trait DeviceKeyStore: Send + Sync { + /// The building's current epoch, or `None` when it is not registered. + async fn epoch(&self, building_id: &str) -> anyhow::Result>; + /// Moves the building to a new epoch and returns it; `None` when it is not registered. + async fn rotate(&self, building_id: &str) -> anyhow::Result>; +} + #[async_trait] pub trait RegistrationEvents: Send + Sync { async fn publish_completed( diff --git a/backend/telemetry/src/lib.rs b/backend/telemetry/src/lib.rs index 98b20721..e01bae1d 100644 --- a/backend/telemetry/src/lib.rs +++ b/backend/telemetry/src/lib.rs @@ -24,14 +24,14 @@ pub fn router(state: Arc) -> Router { let ingest = Router::new().route("/ingest", post(c::ingest)).layer( axum::middleware::from_fn_with_state( - state.ingest_key.clone(), + state.device_keys.clone(), ingest_auth::verify_signature, ), ); let collector = Router::new().route("/collector", get(c::collector)).layer( axum::middleware::from_fn_with_state( - state.ingest_key.clone(), + state.device_keys.clone(), ingest_auth::verify_collector_request, ), ); @@ -65,6 +65,10 @@ pub fn router(state: Arc) -> Router { "/sensors/buildings/{buildingId}/rooms/{roomId}", get(c::room_sensors), ) + .route( + "/device-keys/buildings/{buildingId}", + post(c::issue_device_key), + ) .route( "/simulation/buildings/{buildingId}", get(c::simulation_status) diff --git a/backend/telemetry/src/main.rs b/backend/telemetry/src/main.rs index ad8acd0c..06d02a60 100644 --- a/backend/telemetry/src/main.rs +++ b/backend/telemetry/src/main.rs @@ -10,13 +10,13 @@ use telemetry::adapters::driven::threshold_cache::CachedThresholds; use telemetry::adapters::driven::twin_directory::TwinDirectory; use telemetry::adapters::driving::kafka_consumer; use telemetry::adapters::health::probe_exit_code; -use telemetry::adapters::ingest_auth::IngestKey; +use telemetry::adapters::ingest_auth::DeviceKeys; use telemetry::kernel::actions::Actions; use telemetry::kernel::devices::DeviceCatalog; use telemetry::kernel::ingest::Ingest; use telemetry::kernel::ports::{ - Alerts, BuildingDirectory, BuildingStore, Clock, Fanout, ReadingStore, RegistrationEvents, - SensorStore, SimulatorControl, ThresholdStore, + Alerts, BuildingDirectory, BuildingStore, Clock, DeviceKeyStore, Fanout, ReadingStore, + RegistrationEvents, SensorStore, SimulatorControl, ThresholdStore, }; use telemetry::kernel::readings::Readings; use telemetry::kernel::registration::Registration; @@ -45,7 +45,11 @@ async fn main() -> anyhow::Result<()> { } let database_url = std::env::var("DATABASE_URL")?; - let ingest_key = IngestKey::new(&std::env::var("TELEMETRY_INGEST_SECRET")?)?; + let master_key = std::env::var("TELEMETRY_DEVICE_MASTER_KEY")?; + // Dev and simulators only: signs for any building. Production leaves it unset. + let shared_key = std::env::var("TELEMETRY_INGEST_SECRET") + .ok() + .filter(|secret| !secret.is_empty()); let redis_url = env_or("REDIS_URL", "redis://redis:6379"); let brokers = env_or("KAFKA_BROKERS", "kafka:9092"); let twin_url = env_or("DIGITAL_TWIN_URL", "http://digital-twin:3000"); @@ -72,6 +76,12 @@ async fn main() -> anyhow::Result<()> { )))); let sensors_store = Arc::new(PgSensors::new(pool.clone())); // Shared for the same reason: a re-registration must reach the names cache ingest reads. + // Uncached on purpose: a rotated key must stop working on every replica at once. + let device_keys = DeviceKeys::new( + &master_key, + shared_key.as_deref(), + Arc::new(PgBuildings::new(pool.clone())) as Arc, + )?; let buildings_store = Arc::new(CachedBuildings::new(Arc::new(PgBuildings::new( pool.clone(), )))); @@ -111,7 +121,7 @@ async fn main() -> anyhow::Result<()> { directory: directory.clone() as Arc, dispatch: dispatch.clone(), pool: pool.clone(), - ingest_key, + device_keys, ingest: Ingest { registry: registry.clone(), readings: readings_store.clone() as Arc, diff --git a/backend/telemetry/src/state.rs b/backend/telemetry/src/state.rs index 06d17a05..3030ab24 100644 --- a/backend/telemetry/src/state.rs +++ b/backend/telemetry/src/state.rs @@ -1,5 +1,5 @@ use crate::adapters::driven::dispatch::HttpDispatch; -use crate::adapters::ingest_auth::IngestKey; +use crate::adapters::ingest_auth::DeviceKeys; use crate::kernel::actions::Actions; use crate::kernel::devices::DeviceCatalog; use crate::kernel::ingest::Ingest; @@ -28,7 +28,7 @@ pub struct AppState { pub pool: sqlx::PgPool, pub directory: Arc, pub dispatch: Arc, - pub ingest_key: IngestKey, + pub device_keys: DeviceKeys, pub ingest: Ingest, pub readings: Readings, pub thresholds: Thresholds, diff --git a/backend/telemetry/tests/api.rs b/backend/telemetry/tests/api.rs index 437ed114..aba2dd66 100644 --- a/backend/telemetry/tests/api.rs +++ b/backend/telemetry/tests/api.rs @@ -532,6 +532,102 @@ async fn a_collector_reads_every_indoor_router_with_its_address_and_no_login() { ); } +async fn keyed_app(label: &str) -> support::test_app::TestApp { + let pool = fresh_db(label).await; + seed_building(&pool, "b1", &["r1", "r2"]).await; + seed_building(&pool, "b2", &["r9"]).await; + test_app(pool, vec!["eng"]).await +} + +async fn issue_key(app: &support::test_app::TestApp, building: &str) -> String { + let uri = format!("/device-keys/buildings/{building}"); + let (status, body) = app + .send_json("POST", &uri, Some(&staff()), json!(null)) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["buildingId"], building); + body["key"].as_str().unwrap().to_owned() +} + +fn temperature_in(building: &str) -> serde_json::Value { + let mut body = temperature("r1", BASE_MS, 21.0); + body["buildingId"] = json!(building); + body +} + +#[tokio::test] +async fn only_an_editor_gets_a_building_key_and_it_signs_for_that_building_alone() { + let app = keyed_app("devicekey").await; + let (reader, _) = app + .send_json( + "POST", + "/device-keys/buildings/b1", + Some(&customer()), + json!(null), + ) + .await; + let (ghost, _) = app + .send_json( + "POST", + "/device-keys/buildings/ghost", + Some(&staff()), + json!(null), + ) + .await; + let key = issue_key(&app, "b1").await; + + let (own, _) = app.ingest_with(&key, temperature_in("b1")).await; + let (other, _) = app.ingest_with(&key, temperature_in("b2")).await; + + assert_eq!(reader, StatusCode::FORBIDDEN); + assert_eq!(ghost, StatusCode::NOT_FOUND); + assert_eq!(own, StatusCode::ACCEPTED); + assert_eq!(other, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn issuing_a_building_key_again_revokes_the_previous_one() { + let app = keyed_app("devicekeyrotate").await; + let old = issue_key(&app, "b1").await; + let new = issue_key(&app, "b1").await; + + let (with_old, _) = app.ingest_with(&old, temperature_in("b1")).await; + let (with_new, _) = app.ingest_with(&new, temperature_in("b1")).await; + + assert_eq!(with_old, StatusCode::UNAUTHORIZED); + assert_eq!(with_new, StatusCode::ACCEPTED); +} + +#[tokio::test] +async fn a_building_key_reads_only_its_own_routers() { + let app = keyed_app("devicekeyread").await; + for (building, room) in [("b1", "r1"), ("b2", "r9")] { + app.send_json( + "POST", + &format!("/sensors/buildings/{building}"), + Some(&staff()), + json!({ "create": [{ "ref": "ap", "name": "AP", "sensorType": "router", "roomId": room }] }), + ) + .await; + } + let key = issue_key(&app, "b1").await; + + let (status, body) = app.collector_as(&key, Some("b1"), now_s()).await; + let (other, _) = app.collector_as(&key, Some("b2"), now_s()).await; + let (every, _) = app.collector_as(&key, None, now_s()).await; + + assert_eq!(status, StatusCode::OK); + let buildings: Vec<&str> = body["buildings"] + .as_array() + .unwrap() + .iter() + .map(|b| b["buildingId"].as_str().unwrap()) + .collect(); + assert_eq!(buildings, vec!["b1"]); + assert_eq!(other, StatusCode::UNAUTHORIZED); + assert_eq!(every, StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn a_collector_request_unsigned_or_stale_is_refused() { let app = sensor_app("collectorauth").await; diff --git a/backend/telemetry/tests/support/test_app.rs b/backend/telemetry/tests/support/test_app.rs index ac86741e..f0fd1473 100644 --- a/backend/telemetry/tests/support/test_app.rs +++ b/backend/telemetry/tests/support/test_app.rs @@ -10,13 +10,15 @@ use std::sync::Arc; use telemetry::adapters::driven::dispatch::HttpDispatch; use telemetry::adapters::driven::postgres::{PgBuildings, PgReadings, PgSensors, PgThresholds}; use telemetry::adapters::driven::simulators::HttpSimulators; -use telemetry::adapters::ingest_auth::{IngestKey, SIGNATURE_HEADER, TIMESTAMP_HEADER}; +use telemetry::adapters::ingest_auth::{ + DeviceKeys, IngestKey, SIGNATURE_HEADER, TIMESTAMP_HEADER, collector_message, +}; use telemetry::kernel::actions::Actions; use telemetry::kernel::devices::DeviceCatalog; use telemetry::kernel::ingest::Ingest; use telemetry::kernel::ports::{ - Alerts, BuildingDirectory, BuildingStore, Clock, Fanout, ReadingStore, SensorStore, - SimulatorControl, ThresholdStore, + Alerts, BuildingDirectory, BuildingStore, Clock, DeviceKeyStore, Fanout, ReadingStore, + SensorStore, SimulatorControl, ThresholdStore, }; use telemetry::kernel::readings::Readings; use telemetry::kernel::registration::Registration; @@ -40,6 +42,7 @@ pub struct TestApp { } pub const INGEST_SECRET: &str = "test-ingest-secret-0123456789abcdef"; +pub const MASTER_KEY: &str = "test-device-master-key-0123456789abcdef"; pub const BINDINGS: &str = r#"{ "tp-simulator": { @@ -76,6 +79,12 @@ pub async fn test_app_with_simulators( let buildings_store = Arc::new(PgBuildings::new(pool.clone())); let dispatch = Arc::new(HttpDispatch::from_json(pool.clone(), BINDINGS).unwrap()); let ingest_key = IngestKey::new(INGEST_SECRET).unwrap(); + let device_keys = DeviceKeys::new( + MASTER_KEY, + Some(INGEST_SECRET), + buildings_store.clone() as Arc, + ) + .unwrap(); let alerts = Arc::new(StubAlerts::default()); let fanout = Arc::new(StubFanout::default()); let (simulators, simulator_specs) = HttpSimulators::from_json(simulators).unwrap(); @@ -97,7 +106,7 @@ pub async fn test_app_with_simulators( pool: pool.clone(), directory: directory.clone() as Arc, dispatch: dispatch.clone(), - ingest_key: ingest_key.clone(), + device_keys, ingest: Ingest { registry: registry.clone(), readings: readings_store.clone() as Arc, @@ -203,15 +212,30 @@ impl TestApp { read_json(self.send(request).await).await } - /// `GET /collector`, signed as a collector would at `timestamp` (unix seconds). + /// `GET /collector` for every building, signed with the shared key at `timestamp` (unix s). pub async fn collector_at(&self, timestamp: i64) -> (StatusCode, Value) { + self.collector_as(INGEST_SECRET, None, timestamp).await + } + + /// `GET /collector`, signed with `key` for `building` at `timestamp` (unix seconds). + pub async fn collector_as( + &self, + key: &str, + building: Option<&str>, + timestamp: i64, + ) -> (StatusCode, Value) { let stamp = timestamp.to_string(); + let message = collector_message(building, &stamp); + let uri = match building { + Some(building) => format!("/collector?buildingId={building}"), + None => "/collector".to_owned(), + }; let request = Request::builder() .method("GET") - .uri("/collector") + .uri(uri) .header( SIGNATURE_HEADER, - self.ingest_key.sign_collector_request(&stamp), + IngestKey::new(key).unwrap().sign(message.as_bytes()), ) .header(TIMESTAMP_HEADER, stamp) .body(Body::empty()) @@ -219,6 +243,13 @@ impl TestApp { read_json(self.send(request).await).await } + /// Posts `body` to ingest, signed with `key` instead of the shared one. + pub async fn ingest_with(&self, key: &str, body: Value) -> (StatusCode, Value) { + let raw = body.to_string(); + let signature = IngestKey::new(key).unwrap().sign(raw.as_bytes()); + self.ingest_signed(&raw, &signature).await + } + pub async fn ingest_unsigned(&self, body: Value) -> (StatusCode, Value) { self.send_json("POST", "/ingest", None, body).await } diff --git a/devices/ap-collector/app/__main__.py b/devices/ap-collector/app/__main__.py index 20590f69..78d50ae5 100644 --- a/devices/ap-collector/app/__main__.py +++ b/devices/ap-collector/app/__main__.py @@ -81,7 +81,6 @@ def _make_post_tick( a tick is a snapshot the next supersedes. Caught per building, so one rejected batch does not skip the buildings after it.""" ingest_url = config.telemetry_service.rstrip("/") + "/ingest" - secret = config.telemetry_secret.encode("utf-8") def on_tick(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) -> None: buildings_by_name = {building.name: building for building in config.buildings} @@ -91,6 +90,10 @@ def on_tick(results: dict[str, tuple[dict[str, str], Counter[tuple[str, str]]]]) readings = readings_for_building( building, assignment, now_ms, config.devices_per_person ) + secret = config.key_for(building_name) + if secret is None: + print(f"{building_name}: no device key, dropping this tick", file=sys.stderr) + continue try: post_batch( ingest_url, secret, building_name, readings, timeout=config.default_timeout @@ -153,13 +156,20 @@ def new_tracker() -> ZoneTracker: ) def refresh() -> None: - secret = config.telemetry_secret.encode("utf-8") - try: - answer = fetch_routers(config.telemetry_service, secret, config.default_timeout) - except SyncError as error: - print(f"keeping the previous router list: {error}", file=sys.stderr) - return - config.buildings = buildings_from(answer, config.site) + # Each building key reads its own building; the shared dev key reads every one. + shared = config.shared_key + reads = [(b, k) for b, k in config.keys.items()] or ([(None, shared)] if shared else []) + found: list[dict] = [] + for building_id, key in reads: + try: + answer = fetch_routers( + config.telemetry_service, key, config.default_timeout, building_id + ) + except SyncError as error: + print(f"keeping the previous router list: {error}", file=sys.stderr) + return + found += answer["buildings"] + config.buildings = buildings_from({"buildings": found}, config.site) sync_state( config, sessions_by_building, diff --git a/devices/ap-collector/app/config.py b/devices/ap-collector/app/config.py index 14f61203..2c3f634a 100644 --- a/devices/ap-collector/app/config.py +++ b/devices/ap-collector/app/config.py @@ -136,6 +136,8 @@ def __init__( self.devices_per_person: float | None = devices_per_person self.sync_interval: float = DEFAULT_SYNC_INTERVAL self.site: Site = Site(RouterLogin("", "", DEFAULT_IFACES)) + self.keys: dict[str, bytes] = {} + self.telemetry_secret: str | None = None def load_from_config_file(self, config_file_path: str) -> None: with Path.open(Path(config_file_path)) as f: @@ -143,6 +145,11 @@ def load_from_config_file(self, config_file_path: str) -> None: if "buildings" in data: raise ValueError("config: buildings are read from telemetry; remove them from the file") site = Site.from_json(data) + keys = data.get("keys", {}) + if not isinstance(keys, dict) or not all( + isinstance(key, str) and len(key) >= 32 for key in keys.values() + ): + raise ValueError("config: keys must map building ids to device keys") sync_interval = data.get("syncIntervalS", DEFAULT_SYNC_INTERVAL) poll_interval = data.get("pollIntervalS", DEFAULT_POLL_INTERVAL) default_timeout = data.get("requestTimeoutS", DEFAULT_REQUEST_TIMEOUT) @@ -153,6 +160,7 @@ def load_from_config_file(self, config_file_path: str) -> None: ) self._validate(poll_interval, default_timeout, devices_per_person) self.site = site + self.keys = {building: key.encode("utf-8") for building, key in keys.items()} self.sync_interval = sync_interval self.poll_interval = poll_interval self.default_timeout = default_timeout @@ -164,11 +172,20 @@ def load_env(self) -> None: if not telemetry_service: raise ValueError("config: TELEMETRY_SERVICE_URL must be set") - if not telemetry_secret: - raise ValueError("config: TELEMETRY_SERVICE_SECRET must be set") + if not telemetry_secret and not self.keys: + raise ValueError("config: set per-building keys, or TELEMETRY_SERVICE_SECRET in dev") self.telemetry_service = telemetry_service - self.telemetry_secret = telemetry_secret + self.telemetry_secret = telemetry_secret or None + + @property + def shared_key(self) -> bytes | None: + """The dev key that signs for any building; production sets none.""" + return self.telemetry_secret.encode("utf-8") if self.telemetry_secret else None + + def key_for(self, building_id: str) -> bytes | None: + """The building's own device key, else the shared dev key, else None.""" + return self.keys.get(building_id, self.shared_key) def _validate( self, diff --git a/devices/ap-collector/app/routers.py b/devices/ap-collector/app/routers.py index 70d73ce5..1a66300a 100644 --- a/devices/ap-collector/app/routers.py +++ b/devices/ap-collector/app/routers.py @@ -12,6 +12,7 @@ import sys import time import urllib.error +import urllib.parse import urllib.request from typing import TYPE_CHECKING, Any @@ -31,9 +32,9 @@ class SyncError(RuntimeError): """The router list could not be read -- telemetry unreachable, refusing, or malformed.""" -def sign_request(secret: bytes, timestamp: str) -> str: +def sign_request(secret: bytes, building_id: str | None, timestamp: str) -> str: """A GET has no body, so it signs this canonical string; the timestamp bounds replay.""" - canonical = f"GET {COLLECTOR_PATH}\n{timestamp}".encode() + canonical = f"GET {COLLECTOR_PATH}\n{building_id or ''}\n{timestamp}".encode() return hmac.new(secret, canonical, hashlib.sha256).hexdigest() @@ -41,13 +42,16 @@ def fetch_routers( telemetry_url: str, secret: bytes, timeout: float, + building_id: str | None = None, now: Callable[[], float] = time.time, ) -> dict[str, Any]: - """Every router this collector may poll, as telemetry answers it.""" + """The routers `secret` may read: one building's with its key, every one with the shared key.""" stamp = str(int(now())) + query = f"?{urllib.parse.urlencode({'buildingId': building_id})}" if building_id else "" + signature = sign_request(secret, building_id, stamp) request = urllib.request.Request( # noqa: S310 - telemetry_url.rstrip("/") + COLLECTOR_PATH, - headers={"x-signature": sign_request(secret, stamp), "x-timestamp": stamp}, + telemetry_url.rstrip("/") + COLLECTOR_PATH + query, + headers={"x-signature": signature, "x-timestamp": stamp}, method="GET", ) try: diff --git a/devices/ap-collector/tests/test_config.py b/devices/ap-collector/tests/test_config.py index 32401788..5c9dcfb0 100644 --- a/devices/ap-collector/tests/test_config.py +++ b/devices/ap-collector/tests/test_config.py @@ -223,15 +223,27 @@ def test_load_env_raises_when_telemetry_service_url_is_unset(monkeypatch): config.load_env() -def test_load_env_raises_when_telemetry_service_secret_is_unset(monkeypatch): +def test_load_env_raises_when_there_is_neither_a_shared_key_nor_a_building_key(monkeypatch): monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry:8080") monkeypatch.delenv("TELEMETRY_SERVICE_SECRET", raising=False) config = Config([]) - with pytest.raises(ValueError, match=r"(?i)TELEMETRY_SERVICE_SECRET must be set"): + with pytest.raises(ValueError, match=r"(?i)keys"): config.load_env() +def test_building_keys_replace_the_shared_key_for_their_building(tmp_path, monkeypatch): + monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry:8080") + monkeypatch.delenv("TELEMETRY_SERVICE_SECRET", raising=False) + config = Config([]) + config.load_from_config_file(_write(tmp_path, dict(SITE, keys={"b1": "k" * 64}))) + + config.load_env() + + assert config.key_for("b1") == b"k" * 64 + assert config.key_for("b2") is None + + def test_devices_per_person_is_none_when_flag_is_false_or_omitted(tmp_path): data = {"devicesPerPerson": 1.4, **SITE} path = _write(tmp_path, data) diff --git a/devices/ap-collector/tests/test_main.py b/devices/ap-collector/tests/test_main.py index ba880bd5..b53d154a 100644 --- a/devices/ap-collector/tests/test_main.py +++ b/devices/ap-collector/tests/test_main.py @@ -1,6 +1,9 @@ import email.message +import hashlib +import hmac import json import urllib.error +from pathlib import Path from app.__main__ import _refreshing, main @@ -170,6 +173,26 @@ def fake(request, timeout=None): assert "b1" in capsys.readouterr().err +def test_main_with_a_building_key_reads_and_posts_for_that_building_only(tmp_path, monkeypatch): + requests = [] + monkeypatch.setattr("urllib.request.urlopen", _make_fake_urlopen(requests, ("b1",))) + monkeypatch.setenv("TELEMETRY_SERVICE_URL", "http://telemetry.example/telemetry") + monkeypatch.delenv("TELEMETRY_SERVICE_SECRET", raising=False) + config = json.loads(Path(_write_config(tmp_path)).read_text()) + config["keys"] = {"b1": "k" * 64} + path = tmp_path / "keyed.json" + path.write_text(json.dumps(config)) + + exit_code = main(["--config", str(path), "--once"]) + + assert exit_code == 0 + (ingest,) = requests + assert ( + ingest.get_header("X-signature") + == hmac.new(b"k" * 64, ingest.data, hashlib.sha256).hexdigest() + ) + + def test_main_starts_with_no_building_when_telemetry_is_unreachable(tmp_path, monkeypatch, capsys): """A collector booting before the platform must keep running and pick routers up later.""" diff --git a/devices/ap-collector/tests/test_routers.py b/devices/ap-collector/tests/test_routers.py index c28f5da9..2f3fbdbe 100644 --- a/devices/ap-collector/tests/test_routers.py +++ b/devices/ap-collector/tests/test_routers.py @@ -20,7 +20,9 @@ def _answer(name: str) -> dict: def test_requests_sign_as_the_fixture_pins(): secret = REQUEST["secret"].encode() for case in REQUEST["cases"]: - assert sign_request(secret, case["timestamp"]) == case["signature"], case["name"] + building = case["buildingId"] or None + signature = sign_request(secret, building, case["timestamp"]) + assert signature == case["signature"], case["name"] @pytest.mark.parametrize("case", ROUTERS["cases"], ids=lambda c: c["name"]) @@ -92,17 +94,19 @@ def fake(request, timeout=None): return _Response(json.dumps(_answer("no building has a router")).encode()) monkeypatch.setattr("urllib.request.urlopen", fake) - answer = fetch_routers("http://t.example/telemetry", b"k" * 32, timeout=3, now=lambda: 1000.9) + answer = fetch_routers( + "http://t.example/telemetry", b"k" * 32, timeout=3, building_id="b 1", now=lambda: 1000.9 + ) (request, timeout) = seen[0] assert answer == {"buildings": []} assert (request.get_method(), request.full_url, timeout) == ( "GET", - "http://t.example/telemetry/collector", + "http://t.example/telemetry/collector?buildingId=b+1", 3, ) assert request.get_header("X-timestamp") == "1000" - assert request.get_header("X-signature") == sign_request(b"k" * 32, "1000") + assert request.get_header("X-signature") == sign_request(b"k" * 32, "b 1", "1000") def test_fetch_reports_an_unreachable_or_refusing_telemetry_as_a_sync_error(monkeypatch): diff --git a/documentation/architecture/data-flow.qd b/documentation/architecture/data-flow.qd index 547bac53..c32d143b 100644 --- a/documentation/architecture/data-flow.qd +++ b/documentation/architecture/data-flow.qd @@ -113,7 +113,7 @@ These calls are intentionally few. The design keeps the request path short and a ### Device Ingestion -`POST /ingest` on `telemetry` — no user token (a sensor gateway has none to present), so it is exempt from the gateway's auth gate and carries its own credential instead: `X-Signature`, an HMAC-SHA256 over the raw body keyed by `TELEMETRY_INGEST_SECRET` — the same `X-Signature` scheme as the control-plane hops above, but a separate key, since this one leaves the mesh and lives on customer premises. Unsigned or mis-signed requests are `401` before any work. The endpoint validates, persists and evaluates thresholds before answering `202 Accepted`, so a `202` means the reading is durable and queryable. +`POST /ingest` on `telemetry` — no user token (a sensor gateway has none to present), so it is exempt from the gateway's auth gate and carries its own credential instead: `X-Signature`, an HMAC-SHA256 over the raw body keyed by the building's device key (derived from `TELEMETRY_DEVICE_MASTER_KEY`; dev's shared `TELEMETRY_INGEST_SECRET` also signs) — the same `X-Signature` scheme as the control-plane hops above, but a separate key, since this one leaves the mesh and lives on customer premises. Unsigned or mis-signed requests are `401` before any work. The endpoint validates, persists and evaluates thresholds before answering `202 Accepted`, so a `202` means the reading is durable and queryable. --- diff --git a/documentation/config/sensor-simulator.qd b/documentation/config/sensor-simulator.qd index 563303ae..5530f130 100644 --- a/documentation/config/sensor-simulator.qd +++ b/documentation/config/sensor-simulator.qd @@ -124,7 +124,7 @@ Fakes the routers, not their readings: the collector stays the only thing that t | Variable | Default | Purpose | |---|---|---| | `TELEMETRY_INGEST_URL` | `http://gateway/telemetry/ingest` | Where readings go. Set by the simulator's own environment, never by a caller. | -| `TELEMETRY_INGEST_SECRET` | *(empty)* | HMAC key for `X-Signature`; must match telemetry's. | +| `TELEMETRY_INGEST_SECRET` | *(empty)* | Telemetry's shared key: signs for any building, which a simulator started for any building needs. Dev only. | --- diff --git a/documentation/config/setting-up.qd b/documentation/config/setting-up.qd index a276c4e8..269288ef 100644 --- a/documentation/config/setting-up.qd +++ b/documentation/config/setting-up.qd @@ -102,7 +102,8 @@ This chains ten small, idempotent POSIX-sh scripts (`scripts/env/*.sh`), each re - - `gateway-key.sh` - A stable RSA signing key for `claims-gateway`, written to `secrets/gateway-dev-key.pem` — **not** \.`env` (see below). - - `ingest-key.sh` - - `TELEMETRY_INGEST_SECRET` — the HMAC key a gateway signs `/telemetry/ingest` bodies and `/telemetry/collector` reads with. Deliberately distinct from `INTERNAL_SIGNING_SECRET`: it leaves the mesh and lives on customer premises. + - `TELEMETRY_DEVICE_MASTER_KEY` — every building's device key is derived from it; a collector signs `/telemetry/ingest` and `/telemetry/collector` with its building's key. Losing it revokes them all. + - `TELEMETRY_INGEST_SECRET` — the shared key that signs for **any** building: simulators and dev only, leave it unset in production. Deliberately distinct from `INTERNAL_SIGNING_SECRET`: it leaves the mesh and lives on customer premises. - - `telemetry-db.sh` - `TELEMETRY_DB_PASSWORD` — backs `telemetry-db`. Read only by the Kubernetes path (`just k8s secrets`); Compose hardcodes `telemetry` in `backend/telemetry/docker-compose.yml`. diff --git a/documentation/design/wireless-flow.qd b/documentation/design/wireless-flow.qd index 1c734d24..16d8c56a 100644 --- a/documentation/design/wireless-flow.qd +++ b/documentation/design/wireless-flow.qd @@ -157,6 +157,7 @@ table, emits one signed batch per tick. - **Which APs**: the router sensors placed in the twin, read from `GET /telemetry/collector` at start and every `syncIntervalS` (300 s). A router that stays keeps its session and every device's hysteresis; telemetry unreachable keeps the previous list. - **How to reach them**: the sensor's `endpoint` is the ubus URL (`endpointTemplate` fills a missing one); `driver` `openwrt-iwinfo` selects the iwinfo reader, anything else hostapd. - **How to log in**: the collector's own file — one site-wide `ubus` login and `ifaces`, per-router `overrides`. Never sent to the platform. +- **How it proves which building it is**: `keys: { "": "" }` in the same file, issued by an editor (`POST /telemetry/device-keys/buildings/{id}`). Each key reads that building's routers and signs its batches; it cannot sign for another building. On OpenWrt the readings come from hostapd over `ubus` — `hostapd. get_clients` returns every associated station with its `signal` in dBm. `iw dev station dump` is the diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index 5e2a9fd9..3e4cffba 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -112,7 +112,7 @@ A bound names the payload `field` it compares — not the reading's value, since **Ingestion** — `POST /ingest`, device-facing so no user JWT and still exempt from the edge gate, but not open: the caller signs the request itself. The route takes a *batch*, always — a lone device sends one reading in the array, so there is no second route and no second code path. -0. `X-Signature` must equal `HMAC-SHA256(TELEMETRY_INGEST_SECRET, raw_body)` in lowercase hex — missing, malformed or mismatched is `401` before any parsing, persistence or fanout. Bodies over 1 MiB are `413`. +0. `X-Signature` must equal `HMAC-SHA256(key, raw_body)` in lowercase hex, `key` being the body's `buildingId` device key (or the dev shared key — see **Device keys**) — missing, malformed or mismatched is `401` before any parsing, persistence or fanout. Bodies over 1 MiB are `413`. 1. Envelope `{buildingId, readings[]}`, capped at 500 readings. Each reading carries its own `type`, which selects the plugin and is stripped; the envelope supplies `buildingId`. A reading naming a *different* building is rejected — a batch maps to exactly one fan-out channel. 2. Every reading is validated before anything is written. All-or-nothing: one failure persists nothing, publishes nothing, and one `422` names every offending field, each reading's prefixed with its index (`readings[3]: ...`). An unknown metric is one of those errors, not a `404` — within a batch it is a bad element of a payload, not an unroutable request. 3. One bulk insert, with one threshold lookup for the whole tick running alongside it. @@ -131,14 +131,19 @@ The signature covers the exact bytes on the wire, so a re-serialised body fails: **Collector read** — `GET /collector`, the router list an on-site collector polls. Device-facing like ingest: exempt from the edge gate, signed by the caller. -- `X-Timestamp` (unix seconds) and `X-Signature` = `HMAC-SHA256(TELEMETRY_INGEST_SECRET, "GET /collector\n" + timestamp)`. A GET has no body, so the timestamp is what is signed; more than 300 s off telemetry's clock is `401`, which bounds replay. Pinned by `schemas/fixtures/collector-request.json`. -- Answers `{ buildings: [{ buildingId, routers: [{ sensorId, roomId, driver?, endpoint? }] }] }`: every router sensor in a room, grouped by building. Outdoor routers and buildings without a router are left out. Pinned by `schemas/fixtures/collector-routers.json`. +- `?buildingId=X`, `X-Timestamp` (unix seconds) and `X-Signature` = `HMAC-SHA256(X's device key, "GET /collector\n" + X + "\n" + timestamp)`. A GET has no body, so the timestamp is what is signed; more than 300 s off telemetry's clock is `401`, which bounds replay. Without `buildingId` only the shared key signs, and it reads every building. Pinned by `schemas/fixtures/collector-request.json`. +- Answers `{ buildings: [{ buildingId, routers: [{ sensorId, roomId, driver?, endpoint? }] }] }`: X's router sensors in rooms (or every building's, with the shared key). Outdoor routers and buildings without a router are left out. Pinned by `schemas/fixtures/collector-routers.json`. - **Addresses only, never logins.** `endpoint` is the router's ubus URL on the site network; its credentials live in the collector's own file. -.box {One shared secret, not one per building} type:{note} - A leaked key also reads every building's router list, not only posts for any building. Per-building device keys close both, with no wire change. +**Device keys** — one per building, so a key taken from one site's collector signs for that building alone. - Every gateway currently signs with the same key, so a legitimate gateway could still post for a building that is not its own. That is bounded by there being no third-party gateways yet. Per-building keys are a store lookup behind the same header, with no wire change. Replay is likewise undefended — a captured body stays valid — which matches the mesh's documented "hard perimeter, guarded interior" posture, where an in-mesh attacker could forge `x-gateway-claims` regardless. +- `key = hex(HMAC-SHA256(TELEMETRY_DEVICE_MASTER_KEY, "{buildingId}:{epoch}"))`. Derived, never stored: the database holds only `buildings.device_key_epoch`. +- `POST /device-keys/buildings/{buildingId}` (editing role) bumps the epoch and returns `{ buildingId, key }` — the only time the key is shown. The previous key stops working at once; other buildings are untouched. +- The epoch is read uncached on every signed request, so a rotation holds on every replica immediately. +- `TELEMETRY_INGEST_SECRET`, when set, is a **shared key** that signs for any building — dev and simulators only. Production leaves it unset. + +.box {The master key is the one secret} type:{warning} + Anyone holding `TELEMETRY_DEVICE_MASTER_KEY` can derive every building's key. Changing it revokes every device key at once, so every collector needs a new one. Replay of an ingest body is still undefended — a captured body stays valid — which matches the mesh's documented "hard perimeter, guarded interior" posture. A threshold-evaluation failure is logged and does **not** fail the ingest; a persistence failure does, and is counted by `telemetry_ingest_persist_failures_total`. diff --git a/schemas/CLAUDE.md b/schemas/CLAUDE.md index 4578ef7a..dd5b6cfa 100644 --- a/schemas/CLAUDE.md +++ b/schemas/CLAUDE.md @@ -22,7 +22,8 @@ type. Each case names its `consumer` (`sensor-simulator`, `aq-simulator`, `ap-si **No coordinates, ever**: they are digital-twin's. A simulator gets sensor ids and rooms only. **`fixtures/collector-routers.json` and `fixtures/collector-request.json`**: what telemetry answers -on `GET /telemetry/collector`, and how a collector signs that GET (a timestamp, not a body). +on `GET /telemetry/collector?buildingId=`, and how a collector signs that GET (building and +timestamp, not a body) with its building's device key. Telemetry builds and verifies them in Rust, ap-collector parses and signs them in Python. Neither carries a router login: those stay on the site. diff --git a/schemas/fixtures/collector-request.json b/schemas/fixtures/collector-request.json index 25043b5a..f355e9ca 100644 --- a/schemas/fixtures/collector-request.json +++ b/schemas/fixtures/collector-request.json @@ -4,21 +4,30 @@ "signature": "X-Signature", "timestamp": "X-Timestamp" }, - "canonical": "GET /collector\\n{timestamp}", - "algorithm": "hex(HMAC-SHA256(secret, canonical))", + "query": "buildingId: the building whose routers to read. Absent only with the shared key, which reads every building.", + "canonical": "GET /collector\\n{buildingId}\\n{timestamp}", + "algorithm": "hex(HMAC-SHA256(key, canonical)), key = the building's device key, or the shared key", "timestamp": "Unix seconds, decimal. Refused when more than maxSkewS away from telemetry's clock.", "maxSkewS": 300, "secret": "an-internal-secret-that-is-at-least-32-bytes", "cases": [ { - "name": "a timestamp", + "name": "one building", + "buildingId": "bldg-3f2b4c5d", "timestamp": "1757251200", - "signature": "35ed92d508ad8741240dd6ce49c5d35f6840f73524912ab973396fcf926ea0ba" + "signature": "444f58b66f1cbcfce502b70d0ad3a6359f97c15a5a5f00809dfde5e377d586f2" }, { "name": "one second later signs differently", + "buildingId": "bldg-3f2b4c5d", "timestamp": "1757251201", - "signature": "846d2dbea0ba74e72ecd86c7fd1b7d19a5c183fb80e265e9a803c2bec63827f7" + "signature": "253ae0d081b6947160ea46ef7b70909ea5ce1cbe4b5bba776c77ce6c00d4c2c1" + }, + { + "name": "every building, shared key only", + "buildingId": "", + "timestamp": "1757251200", + "signature": "4e12ee335e654aa78a55182e49f2915c55c0b2bf12e9b74d6fade5a701d1a1ee" } ] } diff --git a/scripts/env/ingest-key.sh b/scripts/env/ingest-key.sh index dc16a387..9eda3687 100755 --- a/scripts/env/ingest-key.sh +++ b/scripts/env/ingest-key.sh @@ -2,16 +2,22 @@ set -eu . "$(dirname -- "$0")/lib.sh" -if env_has "TELEMETRY_INGEST_SECRET="; then - exit 0 -fi - -echo "🔑 Generating telemetry ingest secret..." - -env_append < Date: Sun, 27 Sep 2026 23:42:27 +0200 Subject: [PATCH 06/10] feat(telemetry): serve a building's connected devices --- api/telemetry.yaml | 38 +++++++++++ backend/telemetry/CLAUDE.md | 4 ++ .../src/adapters/driven/postgres/readings.rs | 31 ++++++++- .../adapters/driving/http_api/controllers.rs | 23 +++++++ backend/telemetry/src/kernel/fakes.rs | 25 ++++++- backend/telemetry/src/kernel/ports.rs | 9 ++- backend/telemetry/src/kernel/readings.rs | 66 ++++++++++++++++++- backend/telemetry/src/lib.rs | 4 ++ backend/telemetry/src/plugins/device_count.rs | 7 +- backend/telemetry/src/types/reading.rs | 7 ++ backend/telemetry/tests/api.rs | 45 +++++++++++++ backend/telemetry/tests/persistence.rs | 53 ++++++++++++++- documentation/design/wireless-flow.qd | 5 ++ documentation/packages/telemetry-schema.qd | 1 + documentation/services/telemetry.qd | 8 +++ schemas/CLAUDE.md | 6 +- schemas/fixtures/connected-devices.json | 65 ++++++++++++++++++ schemas/json/connected-devices.schema.json | 28 ++++++++ .../tests/connected_devices_schema.rs | 46 +++++++++++++ 19 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 schemas/fixtures/connected-devices.json create mode 100644 schemas/json/connected-devices.schema.json create mode 100644 schemas/telemetry-schema/tests/connected_devices_schema.rs diff --git a/api/telemetry.yaml b/api/telemetry.yaml index 26b53fa7..90ce3fd8 100644 --- a/api/telemetry.yaml +++ b/api/telemetry.yaml @@ -690,6 +690,44 @@ paths: "404": description: Unknown sensor type, or the room has no readings + /connected-devices/buildings/{buildingId}: + get: + summary: Every device connected in a building + description: > + The sum of the building's rooms' totalDeviceCount at its newest report. + A router counts the devices it hears wherever they stand, so this + building figure is the one that means what it says. A room whose last + report is older than the newest is left out (its router was removed or + moved), and each room counts once, so a report delivered twice does not + double the total. + tags: [Reads] + security: + - gatewayClaims: [] + parameters: + - name: buildingId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The building's connected devices at its newest report + content: + application/json: + schema: + type: object + required: [buildingId, totalDeviceCount, timestamp] + properties: + buildingId: { type: string, example: "bldg-3f2b4c5d" } + totalDeviceCount: { type: integer, minimum: 0, example: 47 } + timestamp: + type: integer + description: Epoch milliseconds of the newest report summed + example: 1757251200000 + "403": + description: The caller may not read this building + "404": + description: The building never reported a device count + /{sensorType}/entireBuilding: get: summary: Latest reading for every room in a building diff --git a/backend/telemetry/CLAUDE.md b/backend/telemetry/CLAUDE.md index 74d434ca..9178d515 100644 --- a/backend/telemetry/CLAUDE.md +++ b/backend/telemetry/CLAUDE.md @@ -110,3 +110,7 @@ Migrations live in `migrations/`. "{buildingId}:{epoch}")`; `buildings.device_key_epoch` is the only state, bumped by `POST /device-keys/buildings/{id}` to revoke. Read uncached, so a rotation holds on every replica. `TELEMETRY_INGEST_SECRET` is the optional shared key that signs for any building — dev only. + +**A building's connected devices are summed here, never by a client.** `Readings::building_total` +sums a metric over the rooms at the building's newest report, each room once +(`GET /connected-devices/buildings/{id}`). Only a count adds up: never route an average through it. diff --git a/backend/telemetry/src/adapters/driven/postgres/readings.rs b/backend/telemetry/src/adapters/driven/postgres/readings.rs index beda8c62..fd929d73 100644 --- a/backend/telemetry/src/adapters/driven/postgres/readings.rs +++ b/backend/telemetry/src/adapters/driven/postgres/readings.rs @@ -4,7 +4,7 @@ use crate::kernel::ports::ReadingStore; use crate::kernel::registry::PluginRegistry; use crate::types::plugin::ENVELOPE_FIELDS; use crate::types::query::Bucket; -use crate::types::reading::Reading; +use crate::types::reading::{BuildingTotal, Reading}; use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde_json::{Map, Value}; @@ -140,6 +140,35 @@ impl ReadingStore for PgReadings { Ok(rows.iter().map(|row| self.construct(row)).collect()) } + async fn building_total( + &self, + building_id: &str, + metric: &str, + ) -> anyhow::Result> { + // distinct on room: a report delivered twice must not double the total. + let row = sqlx::query( + "with newest as ( + select max(ts) as ts from readings where building_id = $1 and metric = $2 + ), + rooms as ( + select distinct on (room_id) r.ts, r.value + from readings r, newest + where r.building_id = $1 and r.metric = $2 and r.ts = newest.ts + order by room_id + ) + select min(ts) as ts, sum(value) as total from rooms", + ) + .bind(building_id) + .bind(metric) + .fetch_one(&self.pool) + .await?; + let ts: Option> = row.get("ts"); + Ok(ts.map(|ts| BuildingTotal { + ts_ms: to_millis(ts), + value: row.get::, _>("total").unwrap_or_default(), + })) + } + async fn series( &self, building_id: &str, diff --git a/backend/telemetry/src/adapters/driving/http_api/controllers.rs b/backend/telemetry/src/adapters/driving/http_api/controllers.rs index 2e0390c0..5e643eb8 100644 --- a/backend/telemetry/src/adapters/driving/http_api/controllers.rs +++ b/backend/telemetry/src/adapters/driving/http_api/controllers.rs @@ -2,6 +2,7 @@ use crate::adapters::ingest_auth::CollectorQuery; use crate::adapters::metrics; use crate::kernel::authz; use crate::kernel::readings::DashboardQuery; +use crate::plugins::device_count::TOTAL_DEVICE_COUNT; use crate::state::AppState; use crate::types::error::DomainError; use crate::types::identity::GatewayClaims; @@ -336,6 +337,28 @@ fn collector_router(router: &Sensor) -> Value { entry } +/// Every device connected in the building at its newest report, summed over its rooms. +pub async fn connected_devices( + State(state): State>, + Path(building_id): Path, + claims: GatewayClaims, +) -> Result, DomainError> { + read(&state, &claims, &building_id).await?; + let started = Instant::now(); + let total = state + .readings + .building_total(TOTAL_DEVICE_COUNT, &building_id) + .await; + metrics::record_query("connected_devices", started.elapsed()); + let total = total?; + Ok(Json(json!({ + "buildingId": building_id, + // Each room's count is a validated non-negative integer, so their sum is one too. + "totalDeviceCount": total.value.round() as u64, + "timestamp": total.ts_ms, + }))) +} + /// Rotates the building's device key and returns the new one; the only time it is shown. pub async fn issue_device_key( State(state): State>, diff --git a/backend/telemetry/src/kernel/fakes.rs b/backend/telemetry/src/kernel/fakes.rs index a2f83a7a..7e5bf05e 100644 --- a/backend/telemetry/src/kernel/fakes.rs +++ b/backend/telemetry/src/kernel/fakes.rs @@ -8,7 +8,7 @@ use crate::types::plugin::{ ActionSpec, BoundDirection, BoundSpec, FieldKind, FieldSpec, MetricDescriptor, SensorPlugin, }; use crate::types::query::Bucket; -use crate::types::reading::Reading; +use crate::types::reading::{BuildingTotal, Reading}; use crate::types::sensor::{Command, Sensor, SensorChanges}; use crate::types::simulation::SimulatedSensor; use crate::types::threshold::{Bounds, RoomTemperatureLimit, TemperatureLimits}; @@ -193,6 +193,29 @@ impl ReadingStore for FakeReadings { Ok(newest) } + async fn building_total( + &self, + building_id: &str, + metric: &str, + ) -> anyhow::Result> { + if self.refuse { + anyhow::bail!("readings refused"); + } + let rows: Vec<&Reading> = self + .rows + .iter() + .filter(|r| r.building_id == building_id && r.metric == metric) + .collect(); + let Some(ts_ms) = rows.iter().map(|r| r.ts_ms).max() else { + return Ok(None); + }; + let mut rooms: Vec<&Reading> = rows.into_iter().filter(|r| r.ts_ms == ts_ms).collect(); + rooms.sort_by(|a, b| a.room_id.cmp(&b.room_id)); + rooms.dedup_by(|a, b| a.room_id == b.room_id); + let value = rooms.iter().map(|r| r.value).sum(); + Ok(Some(BuildingTotal { ts_ms, value })) + } + async fn series( &self, building_id: &str, diff --git a/backend/telemetry/src/kernel/ports.rs b/backend/telemetry/src/kernel/ports.rs index 7afb16d4..6eaa8250 100644 --- a/backend/telemetry/src/kernel/ports.rs +++ b/backend/telemetry/src/kernel/ports.rs @@ -1,7 +1,7 @@ use crate::types::building::{BuildingNames, RegisteredBuilding}; use crate::types::event::{AlertPayload, TelemetryEvent}; use crate::types::query::Bucket; -use crate::types::reading::Reading; +use crate::types::reading::{BuildingTotal, Reading}; use crate::types::sensor::{Command, Sensor, SensorChanges}; use crate::types::simulation::SimulatedSensor; use crate::types::threshold::{Bounds, TemperatureLimits}; @@ -24,6 +24,13 @@ pub trait ReadingStore: Send + Sync { metric: &str, ) -> anyhow::Result>; + /// Sum over rooms at the building's newest timestamp for `metric`, each room once. + async fn building_total( + &self, + building_id: &str, + metric: &str, + ) -> anyhow::Result>; + async fn series( &self, building_id: &str, diff --git a/backend/telemetry/src/kernel/readings.rs b/backend/telemetry/src/kernel/readings.rs index e141cf0e..4baac620 100644 --- a/backend/telemetry/src/kernel/readings.rs +++ b/backend/telemetry/src/kernel/readings.rs @@ -2,7 +2,7 @@ use crate::kernel::ports::{Clock, ReadingStore}; use crate::kernel::registry::PluginRegistry; use crate::types::error::DomainError; use crate::types::query::{AggMode, Bucket, TimeRange}; -use crate::types::reading::Reading; +use crate::types::reading::{BuildingTotal, Reading}; use std::sync::Arc; pub struct DashboardQuery<'a> { @@ -55,6 +55,21 @@ impl Readings { Ok(self.store.latest_per_room(building_id, metric).await?) } + /// `metric` summed over the building's rooms at its newest report; only a count adds up. + pub async fn building_total( + &self, + metric: &str, + building_id: &str, + ) -> Result { + self.known(metric)?; + self.store + .building_total(building_id, metric) + .await? + .ok_or_else(|| { + DomainError::NotFound(format!("no {metric} data found for {building_id}")) + }) + } + pub async fn dashboard(&self, query: DashboardQuery<'_>) -> Result, DomainError> { self.known(query.metric)?; let now_ms = self.clock.now_ms(); @@ -100,6 +115,55 @@ mod tests { Harness { store, readings } } + #[tokio::test] + async fn a_building_total_sums_every_room_at_the_newest_report_only() { + let h = harness(FakeReadings { + rows: vec![ + reading("fake", "b1", "r1", 10, 5.0), + reading("fake", "b1", "r1", 40, 3.0), + reading("fake", "b1", "r2", 40, 4.0), + reading("fake", "b1", "gone", 10, 9.0), + reading("fake", "b2", "r9", 50, 7.0), + ], + ..Default::default() + }); + + let total = h.readings.building_total("fake", "b1").await.unwrap(); + + assert_eq!( + total, + BuildingTotal { + ts_ms: 40, + value: 7.0 + } + ); + } + + #[tokio::test] + async fn a_report_delivered_twice_counts_each_room_once() { + let h = harness(FakeReadings { + rows: vec![ + reading("fake", "b1", "r1", 40, 3.0), + reading("fake", "b1", "r1", 40, 3.0), + reading("fake", "b1", "r2", 40, 4.0), + ], + ..Default::default() + }); + + let total = h.readings.building_total("fake", "b1").await.unwrap(); + + assert_eq!(total.value, 7.0); + } + + #[tokio::test] + async fn a_building_that_never_reported_has_no_total() { + let h = harness(FakeReadings::default()); + let missing = h.readings.building_total("fake", "b1").await.unwrap_err(); + let unknown = h.readings.building_total("nope", "b1").await.unwrap_err(); + assert!(matches!(missing, DomainError::NotFound(_))); + assert!(matches!(unknown, DomainError::NotFound(_))); + } + fn query(metric: &'static str) -> DashboardQuery<'static> { DashboardQuery { metric, diff --git a/backend/telemetry/src/lib.rs b/backend/telemetry/src/lib.rs index e01bae1d..4c408402 100644 --- a/backend/telemetry/src/lib.rs +++ b/backend/telemetry/src/lib.rs @@ -65,6 +65,10 @@ pub fn router(state: Arc) -> Router { "/sensors/buildings/{buildingId}/rooms/{roomId}", get(c::room_sensors), ) + .route( + "/connected-devices/buildings/{buildingId}", + get(c::connected_devices), + ) .route( "/device-keys/buildings/{buildingId}", post(c::issue_device_key), diff --git a/backend/telemetry/src/plugins/device_count.rs b/backend/telemetry/src/plugins/device_count.rs index ad1c8b5b..6d163eeb 100644 --- a/backend/telemetry/src/plugins/device_count.rs +++ b/backend/telemetry/src/plugins/device_count.rs @@ -5,9 +5,12 @@ use crate::types::plugin::{ use crate::types::reading::Reading; use serde_json::Value; +/// Devices a router hears; its rooms add up to the building's total. +pub const TOTAL_DEVICE_COUNT: &str = "totalDeviceCount"; + static TOTAL_DESCRIPTOR: MetricDescriptor = MetricDescriptor { - value_field: "totalDeviceCount", - key: "totalDeviceCount", + value_field: TOTAL_DEVICE_COUNT, + key: TOTAL_DEVICE_COUNT, label: "Total Device Count", interface_name: "ITotalDeviceCount", unit: Some("devices"), diff --git a/backend/telemetry/src/types/reading.rs b/backend/telemetry/src/types/reading.rs index 59d2e74c..0d1170e3 100644 --- a/backend/telemetry/src/types/reading.rs +++ b/backend/telemetry/src/types/reading.rs @@ -9,3 +9,10 @@ pub struct Reading { pub value: f64, pub payload: Map, } + +/// One metric summed over a building's rooms at its newest report. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BuildingTotal { + pub ts_ms: i64, + pub value: f64, +} diff --git a/backend/telemetry/tests/api.rs b/backend/telemetry/tests/api.rs index aba2dd66..fd19873b 100644 --- a/backend/telemetry/tests/api.rs +++ b/backend/telemetry/tests/api.rs @@ -532,6 +532,51 @@ async fn a_collector_reads_every_indoor_router_with_its_address_and_no_login() { ); } +fn devices_tick(ts_ms: i64, rooms: &[(&str, u32)]) -> serde_json::Value { + json!({ + "buildingId": "b1", + "readings": rooms.iter().map(|(room, count)| json!({ + "type": "totalDeviceCount", "roomId": room, + "timestamp": ts_ms, "totalDeviceCount": count + })).collect::>(), + }) +} + +const CONNECTED_B1: &str = "/connected-devices/buildings/b1"; +const CONNECTED_WIRE: &str = include_str!("../../../schemas/fixtures/connected-devices.json"); + +#[tokio::test] +async fn a_building_answers_its_connected_devices_at_the_newest_report() { + let app = sensor_app("connected").await; + app.ingest(devices_tick(BASE_MS, &[("r1", 5), ("r2", 2)])) + .await; + app.ingest(devices_tick(BASE_MS + 5000, &[("r1", 3)])).await; + + let (status, body) = app.get(CONNECTED_B1, Some(&customer())).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + json!({ "buildingId": "b1", "totalDeviceCount": 3, "timestamp": BASE_MS + 5000 }) + ); + let wire: serde_json::Value = serde_json::from_str(CONNECTED_WIRE).unwrap(); + let pinned = wire["cases"][0]["body"].as_object().unwrap(); + let served = body.as_object().unwrap(); + assert!( + served.keys().eq(pinned.keys()), + "served {served:?}, pinned {pinned:?}" + ); +} + +#[tokio::test] +async fn connected_devices_are_not_found_before_any_report_and_forbidden_to_outsiders() { + let app = sensor_app("connectedauth").await; + let (never, _) = app.get(CONNECTED_B1, Some(&customer())).await; + let (outsider, _) = app.get(CONNECTED_B1, Some(&outsider())).await; + assert_eq!(never, StatusCode::NOT_FOUND); + assert_eq!(outsider, StatusCode::FORBIDDEN); +} + async fn keyed_app(label: &str) -> support::test_app::TestApp { let pool = fresh_db(label).await; seed_building(&pool, "b1", &["r1", "r2"]).await; diff --git a/backend/telemetry/tests/persistence.rs b/backend/telemetry/tests/persistence.rs index b4618547..88dee511 100644 --- a/backend/telemetry/tests/persistence.rs +++ b/backend/telemetry/tests/persistence.rs @@ -7,9 +7,10 @@ use telemetry::adapters::driven::postgres::{PgBuildings, PgReadings, PgSensors, use telemetry::kernel::ports::{BuildingStore, ReadingStore, SensorStore, ThresholdStore}; use telemetry::kernel::registry::PluginRegistry; use telemetry::plugins::air_quality::AirQualityPlugin; +use telemetry::plugins::device_count::TotalDeviceCountPlugin; use telemetry::plugins::temperature::TemperaturePlugin; use telemetry::types::building::{RegisteredBuilding, Room}; -use telemetry::types::reading::Reading; +use telemetry::types::reading::{BuildingTotal, Reading}; use telemetry::types::sensor::{Sensor, SensorChanges, SensorUpdate}; const HOUR_MS: i64 = 3_600_000; @@ -20,6 +21,7 @@ fn registry() -> Arc { PluginRegistry::new(vec![ Box::new(TemperaturePlugin), Box::new(AirQualityPlugin), + Box::new(TotalDeviceCountPlugin), ]) .unwrap(), ) @@ -163,6 +165,55 @@ async fn latest_per_room_returns_the_newest_row_for_each_room_and_nothing_else() assert_eq!(rows[1].room_id, "r2"); } +fn devices(building: &str, room: &str, ts_ms: i64, count: u32) -> Reading { + Reading { + building_id: building.to_owned(), + ..reading( + "totalDeviceCount", + room, + ts_ms, + f64::from(count), + json!({ "buildingId": building, "roomId": room, "timestamp": ts_ms, "totalDeviceCount": count }), + ) + } +} + +#[tokio::test] +async fn a_building_total_sums_each_room_once_at_the_newest_report() { + let pool = fresh_db("buildingtotal").await; + let readings = PgReadings::new(pool, registry()); + let (before, newest) = (BASE_MS, BASE_MS + 5000); + readings + .insert(&[ + devices("b1", "r1", before, 5), + devices("b1", "gone", before, 9), + devices("b1", "r1", newest, 3), + devices("b1", "r2", newest, 4), + devices("b1", "r2", newest, 4), + devices("b2", "r9", newest + 1000, 7), + ]) + .await + .unwrap(); + + let total = readings + .building_total("b1", "totalDeviceCount") + .await + .unwrap(); + let never = readings + .building_total("b3", "totalDeviceCount") + .await + .unwrap(); + + assert_eq!( + total, + Some(BuildingTotal { + ts_ms: newest, + value: 7.0 + }) + ); + assert_eq!(never, None); +} + #[tokio::test] async fn series_buckets_hourly_for_a_one_day_range() { let pool = fresh_db("series").await; diff --git a/documentation/design/wireless-flow.qd b/documentation/design/wireless-flow.qd index 16d8c56a..dc8a628f 100644 --- a/documentation/design/wireless-flow.qd +++ b/documentation/design/wireless-flow.qd @@ -214,6 +214,11 @@ Both follow the shape of the existing `peopleCount` plugin (`backend/telemetry/src/plugins/people_count.rs`): per building and room, non-negative integer, dashboard catalog, twin overlay, agent tool. +**A room's count is its router's, not the room's.** A router hears devices wherever they +stand, across walls and floors, so the reading keyed to its room says "near this router". +The figure that means what it says is the building's: telemetry sums the rooms at the +building's newest report and serves it at `GET /telemetry/connected-devices/buildings/{id}`. + **One device, two metrics.** The access point is what gets registered as a sensor — device kind `router` in telemetry's `plugins::devices()`, which reports both. Registering it once per metric would count one device twice. diff --git a/documentation/packages/telemetry-schema.qd b/documentation/packages/telemetry-schema.qd index 37a2bbd6..3370d97e 100644 --- a/documentation/packages/telemetry-schema.qd +++ b/documentation/packages/telemetry-schema.qd @@ -92,6 +92,7 @@ Two telemetry shapes have no type here: one side walks raw JSON or is the only R | `ingest-batch.json` | device → telemetry `/ingest` | ap-collector, aq-simulator, sensor-simulator | telemetry | | `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator, ap-simulator | | `collector-routers.json` | telemetry → collector, `GET /collector` | telemetry | ap-collector | +| `connected-devices.json` | telemetry → browser, `GET /connected-devices/buildings/{id}` | telemetry | frontend (not yet) | `simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }`. diff --git a/documentation/services/telemetry.qd b/documentation/services/telemetry.qd index 3e4cffba..11e0a218 100644 --- a/documentation/services/telemetry.qd +++ b/documentation/services/telemetry.qd @@ -149,6 +149,14 @@ A threshold-evaluation failure is logged and does **not** fail the ingest; a per **Reads** — `GET /{sensorType}/latest`, `/entireBuilding`, `/dashboard`. Each resolves the building's domains through digital-twin and evaluates Cedar before touching data. `latest` on an empty room is `404`. +**Connected devices** — `GET /connected-devices/buildings/{buildingId}` → `{ buildingId, totalDeviceCount, timestamp }`, pinned by `schemas/fixtures/connected-devices.json`. + +- A router counts the devices it hears wherever they stand, so a room's `totalDeviceCount` is a router's figure; the building's sum is the one that means what it says. +- Summed over the rooms at the building's **newest** report: a room whose last report is older — its router removed or moved — is left out. +- Each room counts once at that timestamp, so a report delivered twice does not double it. +- `timestamp` is that newest report; judging it stale is the reader's call. Never reported → `404`, not `0`. Requires `Read` on the building. +- Assumes one collector per building: two would stamp different timestamps, and the newest-report rule would drop one. + A read is served in the **same flat shape as the `telemetry` socket event**: envelope plus every metric field at the top level under its own name. The trimmed `payload` column is a storage detail — it is re-inflated on the way out and never appears in a response. A client that reads a REST snapshot and then live events must not have to handle two shapes; when it did, a dashboard column stayed blank until the first socket tick overwrote the row. **Sensors** — `POST /sensors/buildings/{id}` takes one building's `create` / `update` / `delete` lists as a batch, the same all-or-nothing rule as ingest and room thresholds. The editor saves many changes at once, and half a save would leave the twin disagreeing with what was placed. diff --git a/schemas/CLAUDE.md b/schemas/CLAUDE.md index dd5b6cfa..06f9110e 100644 --- a/schemas/CLAUDE.md +++ b/schemas/CLAUDE.md @@ -27,6 +27,10 @@ timestamp, not a body) with its building's device key. Telemetry builds and verifies them in Rust, ap-collector parses and signs them in Python. Neither carries a router login: those stay on the site. +**`fixtures/connected-devices.json`**: a building's connected devices, the sum of its rooms' +`totalDeviceCount` at its newest report. Telemetry is the only Rust party, so the type stays in +telemetry; the frontend binds the fixture when it starts reading it. + **The Cedar bundle is the exception — it stays in `backend/libs/auth-policy`.** Its fixture holds golden *decisions*, not a wire shape, is meaningless without `policy.cedar` and `schema.cedarschema` beside it, and Go imports the package as a real module. Don't move it here. @@ -40,7 +44,7 @@ them has a wire quirk a generator would flatten. |---|---|---| | Rust path deps | Rust↔Rust drift, at compile time | `Cargo.toml` `path = "../../schemas/…"` | | `fixtures/*.json` | one language's parser disagreeing with the others | Go `conformance_test.go`, Rust `tests/*conformance*.rs`, Python `tests/unit/test_*_conformance.py`, TS `frontend/src/utils/**/*.spec.ts`; `simulation-start` by sensor-simulator's `__tests__/startContract.test.ts` and aq-simulator's and ap-simulator's `tests/test_start_contract.py` (every case parses, every rejection is refused) | -| `json/*.schema.json` | a fixture drifting from the written contract | `twin-schema/tests/building_schema.rs`, `claims-schema/tests/{tenancy_domains,chat_conversation}_schema.rs`, `telemetry-schema/tests/{metric_contract,ingest_batch,simulation_start,collector_routers,telemetry_envelope}_schema.rs`, `notification-schema/tests/notification{,_preferences}_schema.rs`, agent's `test_schema_conformance.py` (claims, building, agent-stream) | +| `json/*.schema.json` | a fixture drifting from the written contract | `twin-schema/tests/building_schema.rs`, `claims-schema/tests/{tenancy_domains,chat_conversation}_schema.rs`, `telemetry-schema/tests/{metric_contract,ingest_batch,simulation_start,collector_routers,connected_devices,telemetry_envelope}_schema.rs`, `notification-schema/tests/notification{,_preferences}_schema.rs`, agent's `test_schema_conformance.py` (claims, building, agent-stream) | | the served bytes | a producer drifting from the fixture both sides agreed on | telemetry `tests/api.rs` compares `/contracts` against `fixtures/metric-contract.json`, and posts every `fixtures/ingest-batch.json` case; notification's `controllers.rs` posts every `fixtures/notification-preferences.json` request and rejection | | the producer's own output | a hand-built payload drifting from the fixture | agent's `test_stream_conformance.py` runs `stream_answer` and compares the frames; chat's `agent.rs` tests replay them through the real `SseReader`; chat's `conversation.rs` round-trips every `fixtures/chat-conversation.json` shape through its own types; telemetry's `redis_fanout.rs` publishes every `fixtures/telemetry-envelope.json` tick byte for byte; notification's `alerts.rs` publishes a breach and compares it to `fixtures/notification.json`; socket's `relay.rs` routes every case and skips every rejection; telemetry's `simulators.rs` posts the `fixtures/simulation-start.json` bodies and wiremock matches them exactly | diff --git a/schemas/fixtures/connected-devices.json b/schemas/fixtures/connected-devices.json new file mode 100644 index 00000000..9af6636a --- /dev/null +++ b/schemas/fixtures/connected-devices.json @@ -0,0 +1,65 @@ +{ + "description": "What GET /telemetry/connected-devices/buildings/{buildingId} answers: every device connected in the building at its newest report. Built by telemetry (Rust); read by the frontend.", + "rules": { + "totalDeviceCount": "The sum of the building's rooms' totalDeviceCount at the newest timestamp any room reported it. A router counts the devices it hears, wherever they stand, so the building total is the figure that means what it says.", + "timestamp": "That newest report, epoch milliseconds. A room whose last report is older is left out: its router was removed or moved.", + "rooms": "Each room counts once at that timestamp, so a report delivered twice does not double the total.", + "absent": "A building that never reported a device count answers 404, not zero: nobody measured it." + }, + "cases": [ + { + "name": "a building's devices at its newest report", + "body": { + "buildingId": "bldg-3f2b4c5d", + "totalDeviceCount": 47, + "timestamp": 1757251200000 + } + }, + { + "name": "nobody connected", + "body": { + "buildingId": "bldg-3f2b4c5d", + "totalDeviceCount": 0, + "timestamp": 1757251200000 + } + } + ], + "rejected": [ + { + "name": "a negative count", + "reason": "totalDeviceCount: a count is never negative", + "body": { + "buildingId": "bldg-3f2b4c5d", + "totalDeviceCount": -1, + "timestamp": 1757251200000 + } + }, + { + "name": "a fractional count", + "reason": "totalDeviceCount: devices are whole", + "body": { + "buildingId": "bldg-3f2b4c5d", + "totalDeviceCount": 4.5, + "timestamp": 1757251200000 + } + }, + { + "name": "a room", + "reason": "roomId: not allowed -- this is the building's figure, not a room's", + "body": { + "buildingId": "bldg-3f2b4c5d", + "roomId": "room-lab-2", + "totalDeviceCount": 3, + "timestamp": 1757251200000 + } + }, + { + "name": "no timestamp", + "reason": "timestamp: required -- a total without its moment cannot be judged stale", + "body": { + "buildingId": "bldg-3f2b4c5d", + "totalDeviceCount": 3 + } + } + ] +} diff --git a/schemas/json/connected-devices.schema.json b/schemas/json/connected-devices.schema.json new file mode 100644 index 00000000..b5cf1324 --- /dev/null +++ b/schemas/json/connected-devices.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crowdvision.dev/schemas/connected-devices.schema.json", + "title": "ConnectedDevices", + "description": "A building's connected devices at its newest report: the sum of its rooms' totalDeviceCount at that timestamp. Built by telemetry in Rust, read by the frontend.", + "type": "object", + "required": [ + "buildingId", + "totalDeviceCount", + "timestamp" + ], + "additionalProperties": false, + "properties": { + "buildingId": { + "type": "string", + "minLength": 1 + }, + "totalDeviceCount": { + "type": "integer", + "minimum": 0 + }, + "timestamp": { + "type": "integer", + "minimum": 0, + "description": "Epoch milliseconds of the newest report the sum is taken at." + } + } +} diff --git a/schemas/telemetry-schema/tests/connected_devices_schema.rs b/schemas/telemetry-schema/tests/connected_devices_schema.rs new file mode 100644 index 00000000..668e784a --- /dev/null +++ b/schemas/telemetry-schema/tests/connected_devices_schema.rs @@ -0,0 +1,46 @@ +const FIXTURE: &str = include_str!("../../fixtures/connected-devices.json"); +const SCHEMA: &str = include_str!("../../json/connected-devices.schema.json"); + +// Telemetry builds this answer and the frontend reads it in TypeScript, so no compiler sees both. +// This crate hosts the check because it already carries `jsonschema`. +#[test] +fn every_answer_the_fixture_accepts_matches_the_schema() { + let validator = validator(); + + for case in cases("cases") { + let errors: Vec = validator + .iter_errors(&case["body"]) + .map(|error| error.to_string()) + .collect(); + assert!(errors.is_empty(), "{}: {}", case["name"], errors.join("; ")); + } +} + +// Each rejection is a rule the contract states for every reader; one the schema still accepts is a +// rule written in only one place. +#[test] +fn every_answer_the_fixture_rejects_fails_the_schema() { + let validator = validator(); + + for case in cases("rejected") { + assert!( + !validator.is_valid(&case["body"]), + "{} validated, but the contract refuses it: {}", + case["name"], + case["reason"] + ); + } +} + +fn cases(group: &str) -> Vec { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture parses"); + fixture[group] + .as_array() + .unwrap_or_else(|| panic!("the fixture has a {group} array")) + .clone() +} + +fn validator() -> jsonschema::Validator { + let schema: serde_json::Value = serde_json::from_str(SCHEMA).expect("schema parses"); + jsonschema::validator_for(&schema).expect("schema compiles") +} From a968f090ab5865ba53420b3de8a32af4faf86e64 Mon Sep 17 00:00:00 2001 From: Nick Ghignatti Date: Wed, 30 Sep 2026 11:24:32 +0200 Subject: [PATCH 07/10] feat(frontend): show a building's connected devices on the dashboard --- .../architecture/frontend-architecture.qd | 1 + documentation/packages/telemetry-schema.qd | 2 +- frontend/e2e/connected-devices.spec.ts | 95 +++++++++++++++++++ .../src/components/dashboard/kpi/KpiStrip.vue | 25 ++++- .../dashboard/useConnectedDevices.ts | 70 ++++++++++++++ frontend/src/i18n.ts | 6 ++ .../utils/dashboard/connectedDevices.spec.ts | 31 ++++++ .../src/utils/dashboard/connectedDevices.ts | 35 +++++++ .../src/views/dashboard/DashboardView.vue | 11 ++- schemas/CLAUDE.md | 2 +- 10 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 frontend/e2e/connected-devices.spec.ts create mode 100644 frontend/src/composables/dashboard/useConnectedDevices.ts create mode 100644 frontend/src/utils/dashboard/connectedDevices.spec.ts create mode 100644 frontend/src/utils/dashboard/connectedDevices.ts diff --git a/documentation/architecture/frontend-architecture.qd b/documentation/architecture/frontend-architecture.qd index 19f7c1d1..d287008d 100644 --- a/documentation/architecture/frontend-architecture.qd +++ b/documentation/architecture/frontend-architecture.qd @@ -131,6 +131,7 @@ Colours are **Material 3 roles**, declared once in `src/assets/main.css`: ## Dashboard - **Simulator toggle** (`SimulatorToggle`, `composables/dashboard/simulator.ts`): `PUT` / `DELETE` / `GET /telemetry/simulation/buildings/{id}`, 10 s timeout. It sends no rooms and no sensors — telemetry reads the building's own and picks the simulators — and the browser never calls a simulator. +- **Connected devices card** (`useConnectedDevices`, `utils/dashboard/connectedDevices.ts`): `GET /telemetry/connected-devices/buildings/{id}`. Telemetry sums the rooms; the browser never adds them — a device-count report on the socket only triggers a re-read. `404` shows "No reading yet", not 0; a report older than 5 min is flagged stale. - A refused start (`404` no simulator configured, `502` a simulator down) throws, and the toggle re-reads the status instead of trusting its optimistic flip. - `e2e/simulator.spec.ts` checks the start/stop calls, that nothing reaches `/simulator/`, and that a refused start leaves the toggle on Start. diff --git a/documentation/packages/telemetry-schema.qd b/documentation/packages/telemetry-schema.qd index 3370d97e..929f1df5 100644 --- a/documentation/packages/telemetry-schema.qd +++ b/documentation/packages/telemetry-schema.qd @@ -92,7 +92,7 @@ Two telemetry shapes have no type here: one side walks raw JSON or is the only R | `ingest-batch.json` | device → telemetry `/ingest` | ap-collector, aq-simulator, sensor-simulator | telemetry | | `simulation-start.json` | telemetry → simulator `/control/start` | telemetry | sensor-simulator, aq-simulator, ap-simulator | | `collector-routers.json` | telemetry → collector, `GET /collector` | telemetry | ap-collector | -| `connected-devices.json` | telemetry → browser, `GET /connected-devices/buildings/{id}` | telemetry | frontend (not yet) | +| `connected-devices.json` | telemetry → browser, `GET /connected-devices/buildings/{id}` | telemetry | frontend | `simulation-start` body: `{ buildingId, sensors: [{ sensorId, sensorType, roomId }] }`. diff --git a/frontend/e2e/connected-devices.spec.ts b/frontend/e2e/connected-devices.spec.ts new file mode 100644 index 00000000..f3c89e14 --- /dev/null +++ b/frontend/e2e/connected-devices.spec.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs' +import { expect, test } from '@playwright/test' +import type { Page, Route } from '@playwright/test' + +/** + * The dashboard's connected-devices card, with the backend stubbed: telemetry sums the + * building's devices, so the browser only shows what it answers and never adds rooms itself. + */ + +const DOMAIN = 'eng' +const BUILDING = 'bldg-e2e' + +const building = { + id: BUILDING, + name: 'Engineering Block', + domains: [DOMAIN], + rooms: [ + { + id: 'Room-101', + name: 'Room 101', + capacity: 20, + position: { x: 0, y: 2, z: 0 }, + dimensions: { width: 8, height: 4, depth: 6 }, + }, + ], +} + +const wire = JSON.parse( + readFileSync(new URL('../../schemas/fixtures/connected-devices.json', import.meta.url), 'utf8'), +) as { cases: { body: { totalDeviceCount: number; timestamp: number } }[] } + +/** Stubs the dashboard's backend; `connected` answers the building's devices, or 404 when null. */ +async function stubBackend(page: Page, connected: { status: number; body?: unknown }) { + const json = (route: Route, body: unknown) => route.fulfill({ json: body }) + + await page.route('**/gateway/me', (route) => json(route, { sub: 'u1', accountName: 'Tester' })) + await page.route('**/tenancy/me/memberships', (route) => + json(route, [{ domain: DOMAIN, role: 'business_admin' }]), + ) + await page.route(`**/twin/buildings/${DOMAIN}`, (route) => json(route, [building])) + await page.route('**/telemetry/contracts', (route) => json(route, { metrics: [] })) + await page.route('**/telemetry/*/dashboard*', (route) => json(route, { data: [] })) + await page.route('**/telemetry/*/entireBuilding*', (route) => json(route, { data: [] })) + await page.route(`**/telemetry/sensors/buildings/${BUILDING}`, (route) => + json(route, { data: [] }), + ) + await page.route(`**/telemetry/simulation/buildings/${BUILDING}`, (route) => + json(route, { running: false }), + ) + await page.route(`**/telemetry/connected-devices/buildings/${BUILDING}`, (route) => + route.fulfill({ status: connected.status, json: connected.body ?? { message: 'none' } }), + ) +} + +const card = (page: Page) => page.getByRole('article').filter({ hasText: 'Connected devices' }) + +async function openDashboard(page: Page) { + await page.goto('/dashboards') + const later = page.getByRole('dialog', { name: /alerts/i }).getByRole('button', { name: 'Later' }) + if (await later.isVisible().catch(() => false)) await later.click() +} + +test("shows the building's connected devices as telemetry sums them", async ({ page }) => { + const [answer] = wire.cases + await stubBackend(page, { + status: 200, + body: { ...answer!.body, buildingId: BUILDING, timestamp: Date.now() }, + }) + + await openDashboard(page) + + await expect(card(page)).toContainText(String(answer!.body.totalDeviceCount)) + await expect(card(page)).toContainText('Across every router') +}) + +test('says so when the building never reported, instead of showing zero', async ({ page }) => { + await stubBackend(page, { status: 404 }) + + await openDashboard(page) + + await expect(card(page)).toContainText('—') + await expect(card(page)).toContainText('No reading yet') +}) + +test('flags a figure the collector stopped refreshing', async ({ page }) => { + const [answer] = wire.cases + await stubBackend(page, { + status: 200, + body: { ...answer!.body, buildingId: BUILDING, timestamp: Date.now() - 10 * 60_000 }, + }) + + await openDashboard(page) + + await expect(card(page)).toContainText('No report for 5+ min') +}) diff --git a/frontend/src/components/dashboard/kpi/KpiStrip.vue b/frontend/src/components/dashboard/kpi/KpiStrip.vue index 97eb93a4..931e908d 100644 --- a/frontend/src/components/dashboard/kpi/KpiStrip.vue +++ b/frontend/src/components/dashboard/kpi/KpiStrip.vue @@ -2,12 +2,19 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { aqiBand, type BuildingSummary } from '@/utils/dashboard/dashboard.ts' +import type { ConnectedDevices } from '@/utils/dashboard/connectedDevices.ts' import KpiCard from '@/components/dashboard/kpi/KpiCard.vue' import ProgressBar from '@/components/commons/base/ProgressBar.vue' import BaseBadge from '@/components/commons/base/BaseBadge.vue' import type { Tone } from '@/utils/commons/tone.ts' -const props = defineProps<{ summary: BuildingSummary; loading: boolean }>() +const props = defineProps<{ + summary: BuildingSummary + loading: boolean + devices: ConnectedDevices | null + devicesStale: boolean + devicesLoading: boolean +}>() const { t, n } = useI18n() @@ -18,10 +25,14 @@ const occupancy = computed(() => props.summary.occupancy === null ? EMPTY : n(props.summary.occupancy, 'percent'), ) const band = computed(() => aqiBand(props.summary.averageAqi)) +const devicesCaption = computed(() => { + if (props.devices === null) return t('dashboard.kpi.noReading') + return props.devicesStale ? t('dashboard.kpi.devicesStale') : t('dashboard.kpi.devicesCaption') +})