/

Developer Guide

Sensor Service

Implementation reference for the sensor-service. For its architectural role, see Sensor Service Architecture; for the domain model, see Telemetry Ingestion.

A Node.js / Express service in strict TypeScript. It ingests sensor readings from constrained hardware, persists them, publishes them for live distribution, and manages per-building thresholds. Its design answers two pressures: never make a sensor wait, and never edit the core to add a sensor type.


Source Layout

PathResponsibility
src/kernel/sensorKernel.tsThe microkernel: a startup-populated registry that resolves a module by its type.
src/modules/ISensorModule.tsThe module contract and the immutable ValidationResult value object.
src/modules/BaseSensorModule.tsAbstract base implementing the sealed ingestion pipeline (Template Method).
src/modules/*Module.tsConcrete strategies: TemperatureModule, PeopleCountModule, AirQualityModule.
src/services/*ModuleService.tsPer-metric persistence, aggregation, and threshold logic.
src/services/ActionService.tsResolves a registered sensorId from its { roomId, buildingId } location.
src/controllers/ingestionController.ts, readController.ts, thresholdController.ts, writerController.ts (sensor registration), actionController.ts (command dispatch).
src/models/Signal schemas, buildingThreshold.ts, sensor.ts (the registry), actionSignal.ts, and the metric catalog.
actions.jsonData-driven routing table for the action path (see Sensor Registry & Actions).
src/config/db.ts (Mongo), redis.ts (broker).
graph TD
    subgraph Controllers
        IC["ingestionController.ts"]
        RC["readController.ts"]
        TC["thresholdController.ts"]
        WC["writerController.ts"]
        AC["actionController.ts"]
    end
    subgraph "Kernel — Microkernel"
        K["sensorKernel.ts\nregistry: type to module"]
    end
    subgraph "Strategy Modules"
        BASE["BaseSensorModule.ts\nTemplate Method"]
        TM["TemperatureModule.ts"]
        PM["PeopleCountModule.ts"]
        AM["AirQualityModule.ts"]
    end
    subgraph Services
        MS["*ModuleService.ts\nper-metric persistence"]
        AS["ActionService.ts"]
    end
    IC --> K
    RC --> K
    WC --> K
    TC --> MS
    AC --> AS
    K --> BASE
    BASE --> TM
    BASE --> PM
    BASE --> AM
    TM --> MS
    PM --> MS
    AM --> MS
    AC --> AJ["actions.json\ndata-driven routing"]

Configuration

VariableDefaultPurpose
REDIS_URLredis://localhost:6379Broker for telemetry:raw publishing and the read-path cache.
MONGO_URI / DB URLTime-series storage for signals and thresholds.

The temperature module additionally calls the notification-service push endpoint when a reading breaches its bound.


The Microkernel

SensorKernel is a pure dispatch registry. Modules are registered once at startup; request handling is then a lookup with no branching on sensor type in the core.

class SensorKernel {
  register(module: ISensorModule): this  // throws on duplicate type
  resolve(type: string): ISensorModule | undefined
  getRegisteredTypes(): readonly string[]
}

Duplicate registration throws synchronously — it is a configuration bug and should crash at boot, not silently overwrite.


Ingestion: Fast Path / Slow Path

The critical constraint is that hardware clients are released immediately. The controller validates synchronously, flushes the response, and only then processes.

sequenceDiagram
    autonumber
    participant HW as Sensor
    participant IC as IngestionController
    participant K as SensorKernel
    participant M as Module
    participant DB as MongoDB
    participant R as Redis
    HW->>IC: POST /ingest { type, ...data }
    IC->>K: resolve(type)
    alt unknown type
        IC-->>HW: 404
    else known
        IC->>M: validate(data)   (pure, synchronous)
        alt invalid
            IC-->>HW: 422 + errors
        else valid
            IC-->>HW: 202 Accepted
            Note over IC: setImmediate — response is flushed first
            IC->>M: process(data)
            M->>DB: persist
            M->>R: PUBLISH telemetry:raw
        end
    end

The Module Contract

ISensorModule and ValidationResult

validate() is pure and synchronous (no I/O) and returns an immutable ValidationResult rather than throwing, so the controller can return machine-readable errors before processing. process() is impure and runs only after a successful validate().

BaseSensorModule — Template Method

Concrete modules extend BaseSensorModule<T> rather than implementing ISensorModule directly. The base seals the ingestion pipeline:

async process(payload: unknown): Promise<void> {
  await this.persist(payload);                         // child-specific
  this.publishTelemetry(this.buildTelemetryEvent(payload)); // base-owned
}

A child therefore implements only what is unique: type, model, validate, persist, buildTelemetryEvent, getAllLatest, getDashboardData.

Lifecycle methods: create and apply

Two contract methods extend a module beyond pure ingestion:


Data Models

Signals

Each metric is its own MongoDB time-series collection. The engine groups readings from the same building into compressed buckets internally; Mongoose reads and writes plain documents — the bucketing is transparent.

All three schemas share the following time-series options:

Two secondary indexes support dashboard queries that filter on the measurement timestamp (Unix epoch from the hardware) rather than the createdAt time axis:

CollectionMetric-specific fields
Temperaturetemperature
PeopleCountpeopleCount
AirQualitypm25, pm10, co2, voc, temperature, humidity, aqi, indoor_aqi, optional scenario

BuildingThreshold

One document per building (buildingId unique). It holds building-level default bounds and an array of per-room overrides, each with optional temperature { minTemp, maxTemp }, peopleCount { maxPeople }, and airQuality { maxAqi, maxCo2 }.

Sensor

The registry of physical sensors, written by the module create() path: { buildingId, roomId, sensorId, sensorType }. A plain (non-time-series) collection. sensorId is unique.

One sensor per building

The schema currently also marks buildingId as unique, which limits registration to a single sensor per building. This is a known constraint to revisit — the intended key is sensorId.

Action

The shape of a dispatched command (actionSignal.ts): { buildingId, roomId, sensorId, sensorType, timestamp, actionName, actionArguments }, available for persisting/auditing actions sent to a sensor.


HTTP API

Method · PathDescription
POST /ingestIngest a reading. 202 on accept, 422 on validation failure, 404 on unknown type. Public (device-facing hot path).
GET read endpointsLatest-per-room, all-latest-per-building, and bucketed dashboard time-series, served by readController. JWT.
POST /sensorRegister a physical sensor (writerControllermodule.create). 202 on accept, 404 on unknown type. JWT.
GET /sensors/buildings/:buildingId[/rooms/:roomId]List the sensors registered for a building, optionally scoped to a room. JWT.
POST /executeActionDispatch a command to a sensor’s actuator API, routed via actions.json. 200 on success, 502 on downstream failure, 404 when no route matches. JWT.
PATCH /thresholds/...Update building- or room-level thresholds, served by thresholdController. JWT.
PUT /thresholds/buildings/:idReceives the building clone (rooms, name) pushed by the twin-service. JWT — twin forwards the caller’s bearer token; the browser also calls it directly with its cookie.
GET /contractsThe metric catalog (peopleCount, temperature, airQuality). Public.

Auth boundary

requireAuthentication (src/middlewares/authentication.ts) is mounted after the rate limiter, so everything below /ingest, /contracts, and /health requires a verified JWT (cookie or forwarded bearer). The middleware responds 401 directly rather than throwing, because sensor-service has no global error handler. /ingest stays open as the device-facing hot path — moving it to device-token auth is tracked separately.


Thresholds

Thresholds are static, human-defined configuration. They live in a single shared BuildingThreshold document with nested slices per metric. When a domain service updates its slice it uses scoped dot-notation so it cannot corrupt a neighbour’s configuration:

findOneAndUpdate({ buildingId }, { $set: { "temperature.maxTemp": 25 } })

A room-level bound, when present, overrides the building default for that room during evaluation.


Read-Path Caching

getAllLatest(buildingId) — which drives the initial dashboard load — runs a MongoDB aggregation. To collapse duplicate work when many users open the same building, results are cached in Redis under sensor:latest:{type}:{buildingId} with a 10-second TTL. The live telemetry path bypasses the cache entirely: every write publishes to telemetry:raw, so the frontend receives real-time updates via the socket without waiting for cache expiry.


Sensor Registry & Actions

Beyond ingesting readings, the service now tracks which sensors exist and can dispatch commands back to them. These are the write and command counterparts to the read/ingest paths.

Registration

POST /sensor registers a physical sensor. writerController resolves the module by sensorType and calls module.create(), which the base class implements by inserting a Sensor document. As with ingestion, the handler validates the type synchronously and returns 202 Accepted without waiting on the write. Registered sensors are then listable via the GET /sensors/... endpoints and are what the frontend’s room-sensor panel renders.

Actions: a data-driven command path

POST /executeAction sends a command — e.g. increase, decrease — to the actuator API that fronts a sensor. Unlike ingestion, this path is synchronous: it awaits the downstream call and reports the real outcome (200 on success, 502 when the endpoint errors or is unreachable, 404 when no route matches). The frontend supplies { sensorId, actionName, actionArguments }.

Routing is entirely driven by actions.json, so onboarding a new sensor or action is a config edit, never a code change. The table is keyed actionName → sensorId → endpoint:

{
  "increase": {
    "123": {
      "url": "http://gateway/simulator/tp/control/receive",
      "method": "POST",
      "arguments": { "0": "value" }
    }
  }
}

The file is resolved relative to the compiled module (the package root, independent of the working directory) and re-read on every request, so edits take effect without a restart. ActionService.getSensorId(roomId, buildingId) is available to resolve a sensor id from its location when a caller does not pass one directly.

sequenceDiagram
    autonumber
    participant UI as Frontend
    participant AC as actionController
    participant J as actions.json
    participant API as Actuator API
    UI->>AC: POST /executeAction { sensorId, actionName, actionArguments }
    AC->>J: lookup actionName → sensorId
    alt no route
        AC-->>UI: 404
    else route found
        AC->>AC: map positional args to named fields
        AC->>API: method url { ...mappedArgs }
        alt ok
            API-->>AC: 2xx
            AC-->>UI: 200 accepted
        else error / unreachable
            AC-->>UI: 502
        end
    end

Extension Guide

Adding a sensor type requires no change to the controller or kernel:

  1. Create a module extending BaseSensorModule<T> and implement the unique members (type, model, validate, persist, buildTelemetryEvent, read methods).
  2. Register it during boot:
const kernel = new SensorKernel()
  .register(new TemperatureModule())
  .register(new PeopleCountModule())
  .register(new AirQualityModule())
  .register(new LightLevelModule()); // new type

The base class supplies the telemetry:raw publish and the shared getLatest, so a new module only writes what is genuinely specific to it.