Skip to content

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

  1. Power on: sensors are created from the board's sensor table and probed. Damper drivers are checked over SPI (AED only).
  2. POST is sent within seconds of joining the network. Each page is resent until the server confirms it (see Delivery).
  3. 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.
  4. Init is sent after that cycle, confirmed the same way. On AED it waits until both dampers have finished homing.
  5. 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, _supply or _exhaust is appended (temp_supply, co2_exhaust, fan_rpm_supply).
  • Remote-mounted sensors (on a STEMMA cable) add _remote after 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 Created or 2.04 Changed). The back end must answer post and init with 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), or init 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
  1. Write the selection: {"report":"post","page":0}, or "init".
  2. 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.
  3. If pages > 1, repeat for page 1 … 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 in model(). The firmware logs key X not declared if it emits an undeclared key, so the POST and the telemetry stay in step.
  • Location suffixes come from the label column of the board's sensor table in src/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 in src/SystemKeys.hpp.
  • Board sensor tables, with each sensor's label, role and options, are in src/SensorConfig.cpp. Telemetry writing and report delivery are in src/Telemetry.cpp.