OTA, Alerts & Hardware
9. OTA (pull-based)
| Check | Expected |
|---|---|
config.get ota.manifest_url returns empty |
[WRN][OTA] No manifest_url logged at boot |
Set ota.manifest_url to a valid URL, restart |
[INF][OTA] Enabled - check every 21600s |
Publish any payload to <prefix>/cmd/ota |
[INF][OTA] MQTT OTA trigger received; fetches manifest (requires ota.cmd_topic set) |
Publish empty to <prefix>/cli/ota.check |
Version-checked OTA via config manifest_url |
Publish --force to <prefix>/cli/ota.check |
Flashes whatever the manifest points at, regardless of version. Useful for dev iteration without version bumps |
Publish --force <url> to <prefix>/cli/ota.check |
Force flash from an override URL (one-off manifest, not touching config) |
| Manifest version matches current, normal mode | [INF][OTA] Already up to date; no flash |
| Manifest version matches current, force mode | [WRN][OTA] Force flag set - re-flashing same or older version then flash |
| Manifest version is newer | Download + verify + install + reboot |
| Manifest SHA256 mismatch | [ERR][OTA] SHA256 mismatch - no flash |
9a. Debug CLI (partition + chip + MQTT state)
| Check | Expected |
|---|---|
cli/ota.status payload "" |
Running / boot / next partition labels + addresses + rollback state (valid, pending_verify, etc) + running firmware version |
cli/chip.info payload "" |
Chip model (ESP32-S3), revision, cores, flash size, PSRAM size + free, CPU frequency |
cli/net.mqtt payload "" |
Connection state, broker, prefix, subscription table with active flags, RX ring of recent incoming topics with age |
10. OTA (push via web)
| Check | Expected |
|---|---|
| Upload wrong password via Admin - OTA | 401, device keeps running |
Upload valid .bin with correct password |
Done - device rebooting; new version boots |
Serial shows [INF][WebServer] OTA upload complete |
Clean OTA |
Using the upload script (recommended for development):
scripts/ota_upload.py handles access check, password prompt, and upload in one step. Run from the repo root:
# Build first
~/.platformio/penv/bin/pio run -e esp32-owb
# Upload (prompts for password, does not echo it)
python3 scripts/ota_upload.py 192.0.2.10
# Custom username or binary path
python3 scripts/ota_upload.py 192.0.2.10 --user admin --bin build/firmware.bin
The script checks credentials before uploading and prints the current device version so you can confirm which firmware is being replaced. After a successful upload the device restarts automatically.
Verify the new version booted:
curl http://192.0.2.10/api/info
11. Temperature Alerts
Set an alert rule with function: "gte" and value just below current room temperature to trigger immediately.
| Check | Expected |
|---|---|
| Enable one alert rule, save + restart | Ready - 1 alert rule(s) enabled |
| Reading crosses threshold (function/value) | [overheat] sensor: XX.XX C - OVERHEAT (>= YY.Y C) |
MQTT monitor receives <prefix>/alert |
{"value":"[overheat] ..."} |
| Alert fires only once | Hysteresis - second reading doesn’t re-trigger |
| Reading returns to normal | back to normal; alert fires again on next cross |
Cooldown: re-trigger within cooldown_s |
Alert suppressed until cooldown expires |
| Recovery: back to normal during cooldown | Recovery always sends immediately |
sensors: ["temp_1"] filter |
Only temp_1 triggers, other sensors ignored |
sensors: [] (empty) |
All sensors trigger (default) |
Custom message field |
Alert label matches config value |
Set enabled: false |
No alerts fire |
11a. MQTT config workflow
The dedicated cmd/config/* topics were retired. Drive configuration through the CLI bridge instead:
| Check | Expected |
|---|---|
Publish telegram.cooldown_s 600 to <prefix>/cli/config.set |
Config updated, saved to flash, reloaded; response on <prefix>/cli/response |
Verify via API: GET /api/file?path=/config.json&source=littlefs |
Value changed |
Push /config.json\n<json> via <prefix>/cli/fs.write, then publish empty payload to <prefix>/cli/config.reload |
Config replaced, saved, reloaded, /info republishes with the new config_hash |
| Push invalid JSON | Error logged, live config untouched (atomic parse-then-swap) |
config.set on non-existent path |
Error logged, no crash |
11b. Fallback AP
| Check | Expected |
|---|---|
| Set WiFi SSID to invalid, restart | AP appears: <device.name>-setup |
| Connect phone to AP SSID | Captive portal redirects to dashboard |
| Dashboard loads at 192.168.4.1 | Sensor table, config editor, terminal all functional |
ap_password set (min 8 chars) |
AP is WPA2 protected |
ap_timeout_s expires |
AP stops, WiFi scan retries |
| Fix WiFi SSID via config editor in AP mode | Node connects to WiFi on next cycle |
11c. NTP Manual Time Set
| Check | Expected |
|---|---|
net.ntp set 1774674000 |
Time set to 2026-03-28T05:00:00Z (epoch 1774674000) |
net.ntp set 2026-03-28T05:00:00Z |
Same result via ISO 8601 |
net.ntp set 0 |
Invalid time error |
net.ntp (no args) |
Shows current UTC time, server, offset |
12. Webhook
Set webhook.url to a local netcat listener:
nc -l 8080
Config: "webhook": { "url": "http://[your-ip]:8080/test", "message_template": "{{value}}" }
Trigger an alert. Netcat should receive the POST with {"value":"[overheat] ..."}.
13. SD Card Logging
| Check | Expected |
|---|---|
| SD inserted, device boots | [INF][SD] Mounted - X.X MB + Logging to /log00N.csv (max 1024 KB per file) |
| After first sensor read | CSV row: 2026-03-22T14:32:00Z,temperature,{...} |
fs.ls /sd/ |
Log files visible |
fs.cat /sd/log001.csv |
CSV rows with timestamps |
"sd": { "enabled": false } |
[INF][SD] Disabled - no mount |
| Config backup button | /config_backup.json on SD |
Logrotate test:
Set sd.max_file_kb to a small value (e.g. 2) and wait for a few sensor reads. The device should log:
[INF][SD] Rotating - /log001.csv full
[INF][SD] Logging to /log002.csv
Both files should appear in fs.ls /sd/. Reset max_file_kb to 1024 when done.
14. Battery Monitoring
| Check | Expected |
|---|---|
sensors command |
Battery line: batt X.XXV XX% [CHG/DSG] |
battery command |
X.XXV XX% charging/discharging |
module.status |
battery pmu=ok present=yes |
/api/state includes battery object |
{"present":true,"voltage_v":X.XX,"percent":XX,"charging":false} |
| Dashboard shows Battery %, V, Charge State | Three rows in sensor table |
Set battery.enabled to false, restart |
[INF][Battery] Disabled via config - no battery rows on dashboard |
Set battery.enabled to true, restart |
Battery monitoring resumes |
15. Deep Sleep
| Check | Expected |
|---|---|
sleep command with sleep disabled |
Sleep: disabled boot #1 |
Set sleep.enabled: true, sleep_s: 30, wake_s: 30, restart |
[INF][Sleep] Enabled - awake 30s, sleep 30s (boot #1) |
| Wait 30s | Device goes to sleep (unreachable for 30s) |
| Wait another 30s | Device wakes, boot count increments |
sleep command after several cycles |
boot #N incrementing, last OTA check persisted |
Set sleep.enabled: false, restart |
Normal continuous operation restored |
16. Cellular Fallback
| Check | Expected |
|---|---|
| Remove WiFi / move out of range | All networks failed - handing off to cellular |
| LTE-M registration | Registered - HOME or ROAMING |
| MQTT publishes arrive via cellular | Same topics as WiFi path |
WiFi back in range after wifi_check_interval_s |
Device reconnects to WiFi |
Known Limitations (not bugs)
- Serial input in VSCode - use
pio device monitorfrom a real terminal, not the VSCode integrated terminal. Input may not reach the device. - ADS1115 near-zero readings - expected when no load is connected to the current clamp.
- NTP on first boot -
pool.ntp.orgcan take more than 15 s on slow networks; first sensor read may logms/<millis>timestamp. - Cellular + WiFi simultaneous - not supported. Cellular activates only when all WiFi networks fail.