Device Messages: Telemetry, POST, Init
Every device sends three kinds of JSON message to the back end, each to its own CoAP topic:
| Topic | When | What it says |
|---|---|---|
device/<device_id>/post |
Once per boot, shortly after the network is up | Which sensor modules were found, and the telemetry keys (with units) each one will produce |
device/<device_id>/init |
Once per boot, after the first full sensor read (≈2 min) and, on AED, after damper homing | Whether each sensor actually returned readings, and whether each damper homed |
device/<device_id>/telemetry |
Every 2 minutes | Readings, as flat "key": number pairs |
All three are CoAP POST (confirmable) requests carrying a JSON body, sent from the device to the Thread border router gateway on the default CoAP port 5683. Every message carries device_id, so it can be cross-referenced in the back-end database.
Telemetry is kept small: it carries only keys and numbers. It does not include the sensor's name, the module that produced the reading, or units. The POST message supplies those once per boot. To learn which module produced temp_supply, look it up in that device's most recent POST.
Boot sequence
- Power on: sensors are created from the board's sensor table and probed. Damper drivers are checked over SPI (AED only).
- POST is sent within seconds of joining the network. Each page is resent until the server confirms it (see Delivery).
- First telemetry cycle (about 2 minutes after boot): every sensor is read once. That read is the init test, and its values are sent as normal telemetry.
- Init is sent after that cycle, confirmed the same way. On AED it waits until both dampers have finished homing.
- Telemetry continues every 2 minutes.
POST and init are delivered once per boot. A new POST/init pair (new timestamp) means the device rebooted.
Telemetry — device/<device_id>/telemetry
{"timestamp":1760800120,"device_id":"4253d73e-22e3-b1e9-0000-000000000000","seq":0,
"pm2.5_supply":3.4,"humidity_supply":45.12,"temp_supply":21.35,"voc_supply":100,"nox_supply":1,
"pm2.5_exhaust":4.1,"humidity_exhaust":47.3,"temp_exhaust":22.1,"voc_exhaust":96,"nox_exhaust":1,
"dp_supply":12.5,"dp_exhaust":11.0,"fan_rpm_supply":1200,"fan_rpm_exhaust":1100,
"temp_supply_remote":19.8,"temp_exhaust_remote":20.5,"rssi":-62}
| Field | Meaning |
|---|---|
timestamp |
Unix time (s) of the reading cycle |
device_id |
Device identifier, also in the topic |
seq |
Message counter. It restarts at 0 on boot and increments per message, so a gap means a lost message |
| everything else | One reading: "key": number |
A payload is limited to 1024 bytes. If a cycle has more readings than fit, it is split across messages that share the same timestamp and have consecutive seq values.
Values are rounded to a fixed number of decimal places per key (the Decimals column below), with trailing zeros dropped: 45.0 is sent as 45, 21.30 as 21.3. Telemetry is sent confirmable but is not resent; a lost cycle shows as a seq gap.
Key naming
- The key is the quantity:
temp,humidity,pm2.5,co2,dp. - Location is a suffix: when a unit has more than one reading of the same quantity,
_supplyor_exhaustis appended (temp_supply,co2_exhaust,fan_rpm_supply). - Remote-mounted sensors (on a STEMMA cable) add
_remoteafter the location:temp_supply_remote. - No units in keys. Units are fixed per key. They are listed below and published in every POST.
Keys by device
AED (ERV)
| Key | Unit | Decimals | Source module |
|---|---|---|---|
pm2.5_supply, pm2.5_exhaust |
µg/m³ (ug/m3) |
1 | SEN66 |
humidity_supply, humidity_exhaust |
%RH | 1 | SEN66 |
temp_supply, temp_exhaust |
°C (C) |
2 | SEN66 |
voc_supply, voc_exhaust |
index | 0 | SEN66 |
nox_supply, nox_exhaust |
index | 0 | SEN66 |
dp_supply, dp_exhaust |
Pa | 2 | SDP31 |
fan_wiper_supply, fan_wiper_exhaust |
steps (0–127) | 0 | DS3502 |
fan_rpm_supply, fan_rpm_exhaust |
rpm | 0 | EMC2303 |
air_velocity |
m/s | 2 | FS3000-1005 |
voc_raw_supply, voc_raw_exhaust |
ticks | 0 | SGP40 |
temp_supply_remote, temp_exhaust_remote |
°C | 2 | STS40 |
rtc_time |
s (Unix time) | 0 | MCP79411 |
dp_target, dp_error |
Pa | 2 | pressure control |
dp_avg_supply, dp_avg_exhaust, dp_avg_delta |
Pa | 2 | pressure control |
dp_latest_supply, dp_latest_exhaust, dp_latest_delta |
Pa | 2 | pressure control |
pid_output, fan_supply, fan_exhaust, damper_supply, damper_exhaust |
% | 1 (pid_output), else 0 |
pressure control |
pressure_action |
-1 supply active, 0 settle, +1 exhaust active | 0 | pressure control |
rssi |
dBm | 0 | Thread radio (parent link) |
SENSE
| Key | Unit | Decimals | Source module |
|---|---|---|---|
pm1.0, pm2.5, pm4.0, pm10.0 |
µg/m³ | 1 | SEN66 |
humidity |
%RH | 1 | SEN66 |
temp |
°C | 2 | SEN66 |
voc, nox |
index | 0 | SEN66 |
co2 |
ppm | 0 | SEN66 |
co, hcho, … (named for the detected gas) |
ppm | 3 | TB600 (EC-Sense; currently disabled) |
rssi |
dBm | 0 | Thread radio |
AED without ERV
| Key | Unit | Decimals | Source module |
|---|---|---|---|
pm1.0_exhaust … co2_exhaust |
as SENSE | as SENSE | SEN66 |
co_raw_exhaust, hcho_raw_exhaust, o3_raw_exhaust, so2_raw_exhaust |
V | 4 | ADS1115 |
air_velocity |
m/s | 2 | FS3000-1005 |
dp_filter |
Pa | 2 | SDP810-125Pa |
fan_speed_supply, fan_speed_exhaust |
% | 0 | EMC2303 |
fan_rpm_supply, fan_rpm_exhaust |
rpm | 0 | EMC2303 |
rssi |
dBm | 0 | Thread radio |
MC
| Key | Unit | Decimals | Source module |
|---|---|---|---|
dp, dp_temp |
Pa, °C | 2, 1 | SDP31 |
uv_thermistor |
V | 4 | ADS1115 |
voc_raw |
ticks | 0 | SGP40 |
temp_remote |
°C | 2 | STS40 |
rtc_time |
s | 0 | MCP79411 |
air_velocity |
m/s | 2 | FS3000-1005 |
led_red, led_blue, logo_led |
0/1 | 0 | GPIO LED, LP5861 |
rssi |
dBm | 0 | Thread radio |
POST (power-on self test) — device/<device_id>/post
The POST message lists every module the firmware is configured for, whether it was found, and the keys it will report.
{"device_id":"4253d73e-22e3-b1e9-0000-000000000000","report":"post","timestamp":1760800000,"page":0,"pages":3,"items":[
{"name":"erv_supply","model":"SEN66","status":"found",
"keys":{"pm2.5_supply":"ug/m3","humidity_supply":"%RH","temp_supply":"C","voc_supply":"index","nox_supply":"index"}},
{"name":"erv_fan0_rpm","model":"DS3502","status":"unverified","keys":{"fan_wiper_supply":"steps"}},
{"name":"sgp40_0","model":"SGP40","status":"not_found","err":-5},
{"name":"sts40_0","model":"STS40","status":"found","keys":{"temp_exhaust_remote":"C"}},
{"name":"damper_supply","model":"TMC2130","status":"found"},
{"name":"pressure_control","model":"firmware","status":"found","keys":{"dp_target":"Pa","pid_output":"%"}},
{"name":"thread","model":"radio","status":"found","keys":{"rssi":"dBm"}}]}
| Item field | Meaning |
|---|---|
name |
The firmware's name for this slot (stable per board type) |
model |
Part number of the module fitted (SEN66, STS40, TMC2130, …). This changes if the product switches module suppliers |
status |
found: answered its probe. not_found: did not answer, or could not be created; err is a negative errno. unverified: configured, but the module has no probe, so presence is only confirmed by the init test |
keys |
Every telemetry key this module will produce, with its unit. Present for found and unverified items |
damper_* items report whether the TMC2130 stepper driver answered over SPI. pressure_control and thread list keys that the firmware itself produces.
Init (initialization test) — device/<device_id>/init
The init message confirms that each module actually produced readings, and on AED that each damper homed.
{"device_id":"4253d73e-22e3-b1e9-0000-000000000000","report":"init","timestamp":1760800120,"page":1,"pages":2,"items":[
{"name":"erv_supply","model":"SEN66","status":"pass","values":5},
{"name":"sgp40_0","model":"SGP40","status":"fail","values":0},
{"name":"Fan1_RPM","model":"EMC2303","status":"fail","values":0,"implausible":1},
{"name":"sts40_1","model":"STS40","status":"not_run"},
{"name":"damper_supply","model":"TMC2130","test":"homing","status":"pass","range_steps":4986},
{"name":"damper_exhaust","model":"TMC2130","test":"homing","status":"fail","err":-116}]}
| Item field | Meaning |
|---|---|
status |
pass: produced readings, all plausible. fail: no readings, or at least one implausible reading. not_run: the module was never created (see POST) |
values |
Plausible readings received in the test cycle |
implausible |
Readings outside the module's sanity range. A fan reading 0 rpm counts here, because fans are driven at boot, as does an RTC set before 2020 |
range_steps |
Damper travel between the two hard stops found by homing, in microsteps |
err |
Negative errno for a failed damper homing (-116 = stop not found, -11 = the two homing passes disagreed) |
The readings taken in the test cycle are sent as normal telemetry with the same timestamp, so the actual values are available too.
Paging
POST and init can exceed one CoAP payload, so they are split into pages of whole items (≤1024 bytes each over CoAP). page counts from 0, and pages is the total. A full report is all pages with the same device_id, report and timestamp.
Delivery confirmation and retries
POST and init pages are sent as confirmable CoAP POSTs, and the device tracks each page until it is confirmed:
- Confirmed means the server answered with a 2.xx code (
2.01 Createdor2.04 Changed). The back end must answerpostandinitwith 2.xx once it has stored the page; anything else is treated as not delivered. - Not confirmed: no answer after CoAP's own retransmissions (about a minute), or a 4.xx/5.xx answer. Only the unconfirmed pages are resent, after a backoff that starts at 30 s and doubles up to 30 min. A server that keeps rejecting a report therefore sees at most one attempt every 30 min.
- Not sent yet (not joined to the network, no free buffers): the device tries again every few seconds, without backoff.
- When every page is confirmed, the device logs
post delivered (N pages), orinit delivered, and stops sending that report until the next boot.
A page can arrive more than once (the server's confirmation is lost, so the device resends). De-duplicate on (device_id, report, timestamp, page). If the device has to rebuild a report with a different page count (for example an EC-Sense module identifies its gas after the first attempt), it resends every page. All pages of that attempt share the same pages.
Reading POST and init over BLE
The phone app can read the same reports from the Vibrant BLE service:
| Characteristic | UUID | Access |
|---|---|---|
report |
00000500-c1ec-46e2-ac97-7d4c7b4bb930 |
Write / Read |
- Write the selection:
{"report":"post","page":0}, or"init". - Read the characteristic. The value is that page, in the same JSON as the CoAP message, up to 512 bytes. Most phone BLE stacks fetch the full value automatically using long reads.
- If
pages> 1, repeat forpage1 …pages-1.
BLE pages are smaller (≤500 bytes), so page/pages differ from the CoAP copy of the same report. The items are identical. Reports can be read at any time: before the init test completes, its items show not_run. A page past the end returns {"report":…,"page":N,"pages":M,"error":"no such page"}.
Firmware notes
- Each sensor class declares its keys and units in
keys()and its part number inmodel(). The firmware logskey X not declaredif it emits an undeclared key, so the POST and the telemetry stay in step. - Location suffixes come from the
labelcolumn of the board's sensor table insrc/main.cpp. - Reports are rendered on demand from a few bytes of stored results per sensor (
src/SelfTest.cpp). No report JSON is held in RAM. - Each key's unit and decimal places live in the sensor's
keys()table (SensorKey). Firmware-produced keys (pressure control,rssi) are insrc/SystemKeys.hpp. - Board sensor tables, with each sensor's label, role and options, are in
src/SensorConfig.cpp. Telemetry writing and report delivery are insrc/Telemetry.cpp.