MQTT Topics
Reference for every topic a thesada-fw device touches. Use this when wiring up dashboards, building integrations, or auditing what the device sends.
Topic prefix
Each device has a configured topic prefix that anchors every topic it owns. The prefix lives at mqtt.topic_prefix in config.json; default is thesada/node.
Two shapes are common in deployments:
| Shape | Example prefix | When to use |
|---|---|---|
| 3-tier legacy | thesada/owb | Single-tenant deployments. Older firmware defaults. |
| 4-tier | thesada/<tenant>/<device> | Multi-tenant deployments. Tenant slug is the second segment, device name third. |
The firmware does not care which shape you pick - the prefix is opaque to it. The wider Thesada platform parses 4-tier prefixes to route by tenant; standalone deployments work fine with 3-tier.
In every section below, <prefix> stands for whatever this device’s prefix is.
At a glance
| Topic | Direction | Retained | QoS | When |
|---|---|---|---|---|
<prefix>/status | publish | yes | 0 | LWT offline; online on connect |
<prefix>/info | publish | yes | 0 | Once per connect |
<prefix>/info/retained_topics | publish | yes | 0 | Once per connect, debounced re-emit on late additions |
<prefix>/sensor/<kind>/<name> | publish | no | 0 | Per sensor read interval |
<prefix>/alert | publish | no | 0 | Per fired alert |
<prefix>/status/ota | publish | no | 0 | OTA progress / outcome |
<prefix>/cellular/active | publish | no | 0 | Cellular fallback toggled on |
<prefix>/cellular/rssi | publish | no | 0 | Periodic during cellular session |
<prefix>/cli/<command> | subscribe | no | 0 | Operator CLI invocation |
<prefix>/cli/response | publish | no | 0 | Reply to a CLI invocation |
<prefix>/cmd/lua/reload | subscribe | no | 0 | Hot-reload Lua scripts |
homeassistant/<component>/<dev>/<uid>/config | publish | yes | 0 | HA autodiscovery, once per connect |
QoS 0 throughout: the Thesada device fleet trades guaranteed delivery for connection liveness. The firmware uses retained for state-of-the-world topics so a fresh subscriber catches up, and uses LWT for offline detection.
Lifecycle topics
<prefix>/status
Online / offline marker. The firmware sets the broker’s Last Will and Testament to publish offline (retained) on disconnect, then publishes online (retained) on every successful connect. Subscribers see one of two payloads at any moment:
online
offline
<prefix>/info
Retained JSON blob describing the device. Published once per successful connect; the retained record gives a fresh subscriber the full device metadata immediately.
{
"firmware_version": "1.3.11",
"hardware_type": "esp32-s3",
"board": "s3-bare",
"chip_model": "esp32-s3",
"chip_revision": 0,
"chip_cores": 2,
"mac": "48:27:e2:e8:b4:34",
"psram": true,
"build_time": "Apr 30 2026 14:45:01",
"config_hash": "a3b1...e2",
"scripts_main_hash": "c4d2...8f",
"scripts_rules_hash": "0000...00"
}
The three hash fields are SHA-256 of the live config and the Lua scripts. Use them for drift detection: if the hash you see on the broker disagrees with what your management database expects, the device is running a different config or script set than you think.
<prefix>/info/retained_topics
Retained JSON array listing every topic this device currently retains on the broker. Built fresh on each connect; debounce-republished if a module retains a new topic after the initial connect.
[
"thesada/sht31/status",
"thesada/sht31/info",
"thesada/sht31/info/retained_topics",
"homeassistant/sensor/sht31/sht31_sht31_temp/config",
"homeassistant/sensor/sht31/sht31_sht31_humidity/config"
]
The point: when a device is decommissioned, the retained payloads it owns persist on the broker forever (that is what retained means). A management process can read this manifest, then publish empty retained payloads on each listed topic to clean up.
Sensor topics
<prefix>/sensor/<kind>/<name> for per-sensor live readings. Payload is the bare value as a string; subscribers parse it as a number when they expect one.
| Topic shape | Example | Payload |
|---|---|---|
<prefix>/sensor/temperature/<name> | <prefix>/sensor/temperature/boiler | 21.4 |
<prefix>/sensor/humidity/<name> | <prefix>/sensor/humidity/sht31 | 54.2 |
<prefix>/sensor/current/<name> | <prefix>/sensor/current/house_pump | 2.31 |
<prefix>/sensor/power/<name> | <prefix>/sensor/power/house_pump | 277.2 |
<prefix>/sensor/battery/percent | (singleton) | 87 |
<prefix>/sensor/battery/voltage | (singleton) | 4.012 |
<prefix>/sensor/battery/charging | (singleton) | Charging / Discharging |
<prefix>/sensor/wifi/rssi | (singleton) | -54 |
<prefix>/sensor/wifi/ssid | (singleton) | MyWiFi |
<prefix>/sensor/wifi/ip | (singleton) | 192.168.1.50 |
<prefix>/sensor/eth/ip | (singleton, ETH boards) | 10.0.0.42 |
<prefix>/sensor/eth/speed | (singleton, ETH boards) | 100 |
<prefix>/sensor/eth/mac | (singleton, ETH boards) | 8c:4f:00:11:22:33 |
<prefix>/sensor/heap/free | (singleton) | 123456 |
<prefix>/sensor/heap/min_free | (singleton) | 89012 |
<prefix>/sensor/heap/max_alloc_block | (singleton) | 38400 |
<prefix>/sensor/heap/psram_free | (singleton, PSRAM boards) | 4194304 |
<prefix>/sensor/uptime | (singleton) | 12345 |
<prefix>/sensor/pwm | (singleton, PWM boards) | 45 |
<prefix>/cellular/rssi | (singleton, cellular boards) | -83 |
Sensors are not retained: the value at any moment is the most recent publish, and a subscriber that joined late reads the next interval. Heap stats publish every 5 minutes; sensor reads follow each module’s configured interval (sensors[].interval_s, sht31.interval_s, etc).
The current/<name> and power/<name> topics from the ADS1115 module also publish a roll-up payload on <prefix>/sensor/current and <prefix>/sensor/power for boards that aggregate.
Names come from the device’s config.json. A sensor named “House Pump” in config slugifies to house_pump for the topic. Renaming a sensor changes the topic, which is intentional - the topic IS the name.
Alerts
<prefix>/alert
JSON payload, fired when a Lua alert rule matches.
{
"device": "owb",
"alert_id": "boiler_overtemp",
"severity": "crit",
"message": "Boiler 78.4 C above 75 C threshold",
"metric": "temperature.boiler",
"value": 78.4,
"ts": 1735000000,
"free_heap": 184320
}
Severity is one of info, warn, crit. The free_heap field is the firmware’s last sampled free heap at alert-publish time; useful when triaging whether an alert misfired during a low-heap period.
Cellular boards mirror alerts on the same topic via the cellular module’s queue when WiFi is down, so subscribers do not need a separate cellular alert path.
<prefix>/status/ota
Plain-string OTA progress events.
checking
downloading
verifying
applied: 1.3.11
failed: connection_lost
Useful for live OTA dashboards. Not retained; rolling status is captured on the broker only while the OTA is happening.
CLI bridge
The firmware subscribes to <prefix>/cli/# so any topic under it is treated as a command. Topic suffix is the command name; payload is the argument string.
Inbound: <prefix>/cli/<command>
mosquitto_pub -t 'thesada/owb/cli/chip.info' -m ''
mosquitto_pub -t 'thesada/owb/cli/ota.check' -m '--force'
mosquitto_pub -t 'thesada/owb/cli/config.set' -m 'sleep.deep_sleep_minutes 15'
Some commands take multi-line payloads (fs.write, fs.append, cert.set); see the CLI Reference for the wire contracts.
Outbound: <prefix>/cli/response
JSON envelope with the original command, success flag, and one entry per output line.
{
"cmd": "chip.info",
"ok": true,
"output": [
"ESP32-S3 rev 0 cores=2 flash=8 MB",
"PSRAM: 8 MB octal SPI"
]
}
Subscribe before publishing to avoid missing the response (it is QoS 0 + not retained).
<prefix>/cmd/lua/reload
The Lua module also subscribes to a fixed reload trigger separate from the CLI bridge. Publishing any payload (empty works) re-runs every script under /scripts/ in load order. Equivalent to issuing lua.reload via the CLI.
Home Assistant discovery
When mqtt.ha_discovery: true in config.json (default), the firmware publishes Home Assistant MQTT autodiscovery configs (retained) on connect. Topic shape:
homeassistant/<component>/<device_id>/<unique_id>/config
<component> is the HA entity component (sensor, binary_sensor, etc - currently only sensor is used). <device_id> is the device’s name from config.json. <unique_id> is per-entity, derived from the device id and a per-sensor suffix.
Each config payload references the sensor’s state topic from the table above and an availability topic of <prefix>/status, so HA marks the entity unavailable when the device drops offline. Worked-out example for an SHT31 humidity sensor on a device named sht31:
{
"name": "SHT31 Humidity",
"stat_t": "thesada/sht31/sensor/humidity/sht31",
"uniq_id": "sht31_sht31_humidity",
"avty_t": "thesada/sht31/status",
"unit_of_meas": "%",
"dev_cla": "humidity",
"stat_cla": "measurement",
"dev": {
"ids": "sht31",
"name": "SHT31 Test Node",
"mf": "Thesada",
"sw": "1.3.11"
}
}
The firmware emits one of these per active sensor on connect: temperature, humidity, current, power, battery (percent / voltage / charging), wifi (rssi / ssid / ip), eth (ip / speed / mac), heap (free / min / max_alloc / psram), uptime. Disabling a module via build flags removes its discovery configs from the published set.
Authentication and ACLs
The firmware connects with one of:
- Username + password from
config.jsonmqtt.user/mqtt.password. The fallback path used by unpaired devices. - mTLS client certificate stored in NVS. When present,
cert.applytriggers a reconnect using the cert as identity (no username, no password). The broker maps the certificate CN onto the MQTT username viause_identity_as_username, so the connection arrives as if the device had logged in with username = CN.
ACLs live on the broker side via the Mosquitto dynamic-security plugin. Per-device pairing creates a role scoped to <prefix>/# plus the device’s homeassistant/... discovery namespace. Operators wire those out of band; the firmware does not configure ACLs.
Putting it together
A typical subscribe pattern for a single device:
mosquitto_sub -v -t 'thesada/owb/#'
For Home Assistant discovery on a single device:
mosquitto_sub -v -t 'homeassistant/sensor/owb/#'
For a tenanted multi-device deployment, the wider thesada/# works at the broker layer; combine with broker-side ACLs to scope by tenant.
To inspect what one device retains right now:
mosquitto_sub -t 'thesada/owb/info/retained_topics' -W 5 -C 1 | jq .