Multi-sensor edge sentinel for a monitored space β air quality (BME680), mmWave human presence (LD2410), and on-device ML for acoustic events (INMP441 + TinyML) and vision (ESP32-CAM). One hexagonal core, one pipeline: MQTT β Node-RED β InfluxDB β Grafana.
Sim-first. The full pipeline runs today against a coherent simulated space β an occupancy schedule drives everything at once: the presence radar sees people, COβ/VOC rises with the crowd, the room warms, sound events grow more likely, the camera counts heads. When the hardware arrives, only the adapters change β the domain and use cases ship to the ESP32 verbatim, sensor by sensor. The ones you don't have yet stay simulated.
| What it is | A multi-sensor edge sentinel for a monitored space: air quality (BME680), mmWave human presence (LD2410), on-device ML for acoustic events (INMP441 + TinyML) and vision (ESP32-CAM). One hexagonal core, one pipeline. |
| The one idea | Raw media never enters the pipeline. The audio node runs the classifier on-device and publishes only the verdict; the camera writes the JPEG out-of-band and puts a path in the event. A time-series database is not a blob store, and a microphone that ships audio is a different product with different consent. |
| Two kinds of observation | A Measurement is a scalar reading and goes to InfluxDB's telemetry. An Event is a discrete classification from an edge model and goes to events, with label and channel as tags. Separating the data plane from the event plane is the point. |
| Sim-first | The whole pipeline runs today against a coherent simulated space: one occupancy schedule drives everything at once, so the radar sees people, COβ rises with the crowd, the room warms and sound events grow likelier β together, because they share a cause. |
| Built with | Python 3.10+ (CPython to simulate, MicroPython on target) Β· paho-mqtt Β· Mosquitto Β· Node-RED Β· InfluxDB 2 Β· Grafana |
| Size | 55 tests |
Contents β Readings vs events Β· Architecture Β· Layout Β· Quick start Β· Camera snapshots Β· Screenshots Β· Status
Not every observation is a scalar. This node models two kinds:
Measurementβ a scalar physical reading (temperature, IAQ, target distance). Flows to InfluxDB'stelemetrymeasurement.Eventβ a discrete classification from an edge model (sound =glass_break@ 0.92, vision =personcount 2). Flows to InfluxDB'seventsmeasurement, label & channel as tags.
Raw media never enters the pipeline. The audio node runs the classifier on-device and publishes only the verdict; the camera saves the JPEG out-of-band and puts a path in the event. A time-series DB is not a blob store β separating the data plane from the event plane is the whole point.
flowchart TB
subgraph edge["on the device (edge)"]
bme["BME680"] --> mR["Measurement<br/>temperature, iaqβ¦"]
ld["LD2410"] --> mR
mic["INMP441 + TinyML"] --> eV["Event<br/>audio: glass_break @0.92"]
cam["ESP32-CAM"] --> eV2["Event<br/>vision: person Γ2<br/>+ snapshot path"]
audio_raw["π raw audio"] -.->|never leaves| X1["β"]
img_raw["π· JPEG"] -.->|saved out-of-band| store[("SD / HTTP")]
end
mR --> frame["TelemetryFrame"]
eV --> frame
eV2 --> frame
frame -->|one MQTT packet| split["Node-RED"]
split --> tel[("influx: telemetry")]
split --> evt[("influx: events")]
classDef good fill:#0d1117,stroke:#3fb950,color:#3fb950
classDef bad fill:#2d1215,stroke:#d9534f,color:#d9534f
class mR,eV,eV2,frame,tel,evt good
class audio_raw,img_raw,X1 bad
flowchart LR
subgraph node["π‘οΈ node Β· hexagon"]
direction TB
sensors["SensorPort<br/>BME680 Β· LD2410"]
detectors["DetectorPort<br/>audio Β· vision"]
app["application<br/>CollectTelemetry<br/>DutyCycleScheduler"]
codec["CodecPort<br/>json / bin"]
sensors --> app
detectors --> app
app --> codec
end
codec --> transport{{"TransportPort"}}
transport -->|sim| mqtt["MQTT"]
transport -.->|hw, optional| lora["LoRa P2P"]
lora -.-> gw["gateway<br/>LoRaβMQTT"]
gw --> mqtt
mqtt --> nr["Node-RED<br/>splits readings + events"]
nr --> influx[("InfluxDB 2")]
influx --> graf["Grafana"]
classDef core fill:#8b5cf6,stroke:#6d28d9,color:#fff
classDef infra fill:#0d1117,stroke:#3fb950,color:#3fb950
class sensors,detectors,app,codec core
class mqtt,nr,influx,graf,gw,lora infra
Hexagon rules are enforced by tests/test_architecture.py: domain/ and
application/ import zero adapters and none of
typing / dataclasses / abc / enum / asyncio, so they run unmodified on
MicroPython. Adapters implement ports; runner/run_sim.py is the only
composition root.
node/
βββ domain/ model.py (Measurement + Event) Β· ports.py
βββ application/ collect.py Β· scheduler.py
βββ adapters/
βββ sim/ SpaceWorld + BME680/LD2410 sims + audio/vision detectors
βββ hw/ skeletons + WIRING.md
βββ codec/ json
βββ transport/ console Β· mqtt
runner/run_sim.py composition root (toggle each sensor, scripted anomalies)
deploy/ docker stack + Node-RED flow (readings/events split) + dashboard
tests/ 55 tests, incl. architecture fitness functions
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev,mqtt]"
pytest # 55 passed
python -m runner.run_sim --cycles 5 --speed 10000 # console, no infraFull step-by-step with per-step verification: docs/SETUP.md.
docker compose -f deploy/docker-compose.yml up -d| Service | URL | Credentials |
|---|---|---|
| Node-RED | localhost:1880 |
β |
| InfluxDB | localhost:8086 |
org sentinel Β· bucket sentinel Β· token sentinel-local-dev-token |
| Grafana | localhost:3000 |
admin / admin |
- Node-RED β install
node-red-contrib-influxdbβ importdeploy/nodered/flows-sentinel.jsonβ set the token β Deploy. - Grafana β add InfluxDB datasource (Flux, org/bucket/token above).
- Grafana β Import
deploy/grafana/dashboard-sentinel.json.
A full simulated day with an occupancy rhythm and a scripted glass-break, in ~12 minutes:
python -m runner.run_sim --mqtt localhost --speed 120 --interval 60 \
--anomaly-at 4 --anomaly-label glass_breakWatch presence track the schedule, IAQ climb with the crowd, and the acoustic panel spike near confidence 1.0 when the anomaly fires.
All simulator flags
| Flag | Meaning |
|---|---|
--speed N |
time acceleration (120 = a day in 12 min) |
--interval S |
virtual seconds between frames |
--cycles N |
stop after N frames (0 = forever) |
--mqtt HOST |
publish to broker instead of stdout |
--no-air / --no-presence / --no-audio / --no-vision |
disable a sensor (e.g. only sim what you have) |
--bank DIR |
AI snapshot bank (see tools/generate_bank.py); falls back to Pillow placeholders |
--snapshot-dir DIR |
where camera snapshots are written |
--anomaly-at MIN |
force an acoustic anomaly after MIN virtual minutes |
--anomaly-label L |
anomaly class (default glass_break) |
--fail-rate P |
probability of a simulated sensor fault per read |
--seed N |
deterministic world |
The vision detector writes a real snapshot per detection β served as a gallery, never as a blob in the telemetry frame. Two modes:
- Zero-setup: Pillow renders placeholder frames (silhouettes + overlay).
- Realistic: generate a small AI image bank once with
tools/generate_bank.py(OpenAI backend), then run with--bank docs/bank. The sim serves the bank frame matching the current person count.
Node-RED exposes GET /gallery (auto-refreshing) and GET /snap/:file.
Full steps in docs/SETUP.md.
| Grafana β live dashboard | Sentinel β presence & events |
|---|---|
![]() |
![]() |
| Node-RED β readings/events split | InfluxDB β telemetry + events |
|---|---|
![]() |
![]() |
The simulation phase is complete and runs end to end: 55 tests green and the full MQTT β Node-RED β InfluxDB β Grafana pipeline provisioned.
The simβhardware swap is per-sensor and mechanical β the core never changes:
flowchart LR
sim["SimBME680<br/>(reads SpaceWorld)"] -->|same SensorPort<br/>same measurement names| hw["HwBME680<br/>(reads I2C)"]
swap["one import line<br/>in runner/run_sim.py"] -.-> sim
swap -.-> hw
core["node/domain<br/>node/application"] -.->|untouched| swap
classDef c fill:#8b5cf6,stroke:#6d28d9,color:#fff
classDef s fill:#0d1117,stroke:#3fb950,color:#3fb950
class core c
class sim,hw,swap s
Node A β ambient + presence (ESP32, low power):
-
HwBME680β I2C, gas resistance + IAQ (BSEC optional) -
HwLD2410β UART 256000, target distance per gate
Node B β edge ML (ESP32-S3, PSRAM):
-
HwAudioMLβ INMP441 I2S β TinyML classifier βEvent(raw audio stays on-device) -
HwVisionCamβ OV2640 β person detect βEvent(JPEG saved out-of-band)
Pinout, drivers and the media-handling rationale in
WIRING.md.
Real sensors, TLS/mTLS everywhere, and defence in depth: signed frames, secrets management, an audit trail, and a CI check that enforces the no-raw-media rule rather than trusting it to stay true. Full plan in docs/ROADMAP.md.
Python 3.10+ (CPython sim / MicroPython target) Β· paho-mqtt Β· Eclipse Mosquitto Β· Node-RED Β· InfluxDB 2 Β· Grafana Β· pytest Β· ruff Β· GitHub Actions.
MIT




