Gateway Firmware — P0 Production-Readiness Design
Date: 2026-05-14
Branch: feature/thingsboard-integration
Status: Design — awaiting review
Target scope: Fleet 100+ devices, greenfield (no field devices yet)
1. Goals
Lift gateway_firmware/ from Beta to fleet-deployable by closing 5 P0 gaps:
- P1 OTA + SHA256 checksum + dual-OTA partition + auto-rollback
- P2 FreeRTOS task watchdog for all tasks + reboot recovery
- P3 MQTT TLS + per-device credentials in NVS
- P4 WiFi credential provisioning (factory-flash default + AP mode fallback)
- P5 Async Modbus state machine (unblock RPC during scans)
Non-goals: signed firmware with PKI (defer N10), full secure boot v2 / flash encryption, per-device runtime configuration of Modbus slave map (S1), command-history persistence (S3).
2. Constraints & assumptions
- Deployment target: fleet 100+ devices at heterogeneous customer sites.
- Greenfield — free to change partition table.
- OTA distribution: ThingsBoard shared-attribute pushes
fw_url+fw_checksum; binary hosted on an HTTPS server we operate. - Image authenticity model: SHA256 + HTTPS chain-of-trust. No PKI/eFuse signing in this scope.
- MQTT credentials and WiFi defaults are flashed per-device at factory via an
esptool.pyNVS-image script. Single firmware image, per-device differs only in NVS. - WiFi AP-mode portal is the field-installer override path.
- Task WDT trigger → reboot, with
resetReason+ hung-task-name surfaced in first telemetry after boot. - Async Modbus target: full scan duration may remain ~10 s, but RPC p99 latency under load must be < 200 ms.
3. Foundation — Partition table & NVS layout
This must land before any device is flashed for real. Single-app partitions cannot OTA themselves to dual-OTA.
3.1 New file: gateway_firmware/partitions_two_ota.csv
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
phy_init, data, phy, 0x11000, 0x1000,
factory, app, factory, 0x20000, 0x180000,
ota_0, app, ota_0, 0x1A0000, 0x180000,
ota_1, app, ota_1, 0x320000, 0x180000,
nvs_keys, data, nvs_keys,0x4A0000, 0x1000,
creds, data, nvs, 0x4A1000, 0x5000,
spiffs, data, spiffs, 0x4A6000, 0x15A000,
Assumes 4 MB flash (esp32doit-devkit-v1 default). If a 16 MB SKU is procured later, expand ota_* slots.
3.2 platformio.ini changes
[env:esp32doit-devkit-v1]
board_build.partitions = partitions_two_ota.csv
board_upload.flash_size = 4MB
3.3 NVS namespace layout
| Namespace | Keys | Owner |
|---|---|---|
creds |
mqtt_token, mqtt_host, mqtt_port, device_id |
Factory-flash script (read-only at runtime under normal flow) |
wifi |
ssid, psk |
Factory default + AP-mode portal can overwrite |
state |
last_reset_reason, hung_task, boot_count |
Runtime |
config |
(reserved for S1 Modbus slave map) | Future |
creds is intentionally separate from wifi so an installer using the AP portal cannot accidentally overwrite the MQTT token.
3.4 Factory-flash workflow
Python script tools/factory_flash.py (new):
- Take
--token,--device-id,--default-ssid,--default-pskfrom CLI or CSV batch file. - Build a per-device NVS partition image with
nvs_partition_gen.py. - Flash firmware (
firmware.bin) + per-device NVS image viaesptool.py write_flash. - Boot test → confirm device joins WiFi + TB MQTT within 60 s, then ship.
4. P3 — MQTT TLS + per-device credentials
4.1 Modules
- New:
src/provisioning/nvs_creds.{h,cpp}— typed accessors forcredsnamespace. - New:
src/mqtt/tb_ca.h— PROGMEM constant with ThingsBoard CA chain. - Changed:
src/mqtt/mqtt_client.{h,cpp}— replaceWiFiClientwithWiFiClientSecure, port 8883,setCACert(TB_CA_PEM). - Changed:
src/mqtt_manager.cpp+src/main.cpp— load credentials fromNvsCredsinstead of compile-timesecrets.h.
4.2 Behavior
- TB MQTT auth: access token as username, empty password (TB convention).
- If
credsnamespace is empty at boot → log fatal + redirect into AP mode (entry point shared with P4). setInsecure()is not allowed in any build profile, including pilot. Forces correct CA from day one.
4.3 Threat model coverage
| Threat | Mitigation | Residual |
|---|---|---|
| Passive eavesdrop on any hop | TLS 1.2+ | None |
| Compromise of one device | Per-device token, no shared | One token leak only |
| MITM with fake cert | Pinned TB CA | None for this CA |
| TB CA compromise | (out of scope, NICE-TO-HAVE mTLS client cert later) | Defer |
4.4 Tests
test/native_mqtt(existing) extended: mockWiFiClientSecure::setCACert/connect, assert TLS path used.- New:
test/native_nvs_creds— round-trip read/write ofcredsnamespace via mocked Preferences.
5. P4 — WiFi provisioning + AP mode
5.1 Modules
- New:
src/provisioning/wifi_provisioner.{h,cpp}— wraps existingIotWebConfdependency, exposesloadFromNvs(),startApPortal(timeoutMin),saveAndReboot().
5.2 Boot decision flow
boot
├─ load wifi NVS
├─ exists?
│ ├─ yes → try connect (timeout 30s, 3 retries)
│ │ ├─ success → proceed to MQTT (Section 4)
│ │ └─ 3 fails → enter AP mode
│ └─ no → enter AP mode
AP mode
├─ SSID = "HMP-GW-<last4MAC>"
├─ Captive portal at http://192.168.4.1/
├─ Form fields: WiFi SSID, WiFi PSK
│ (TB token field hidden when `creds` already populated)
├─ Save → write `wifi` NVS + reboot
└─ 10-min idle timeout → reboot to retry station mode
5.3 Triggers for AP mode (runtime)
- Boot with empty
wifiNVS. - 3 consecutive station-mode connect failures.
factoryResetRPC (eraseswifinamespace, then reboots).- Hardware button GPIO0 long-press ≥ 5 s (deferred — depends on hardware revision).
5.4 Tests
- New:
test/native_provisioning— mock NVS, drive boot state machine through:- empty
wifi→ AP entered - valid
wifi→ station attempted - 3 failures → AP entered
- portal save → NVS written, reboot scheduled
- empty
6. P1 — OTA + rollback
6.1 Modules
- New:
src/ota/ota_updater.{h,cpp}— ownsesp_https_otalifecycle. - Changed:
src/mqtt_manager.cpp— subscribev1/devices/me/attributesand.../attributes/response/+, dispatch OTA-related keys. - Changed:
src/state/health_monitor.cpp— publishfwVersion,resetReason, OTA state transitions.
6.2 Flow
- After MQTT connect, request shared attributes
["fw_title","fw_version","fw_url","fw_checksum","fw_size"]. - On attribute update where
fw_version != FW_VERSION:- Telemetry:
fw_state = "DOWNLOADING". - Start
esp_https_ota_begin(fw_url)with CA bundle (firmware HTTPS server CA may differ from TB CA — bundled separately). - Stream chunks, accumulate SHA256.
- Telemetry:
- Compare computed SHA256 with
fw_checksum:- mismatch →
esp_ota_abort, telemetryfw_state = "FAILED: checksum". - match →
esp_ota_end,esp_ota_set_boot_partition, telemetryfw_state = "UPDATING", reboot.
- mismatch →
- On next boot: pending-verify window of 120 s waiting for WiFi + MQTT + one successful telemetry round-trip.
- success →
esp_ota_mark_app_valid_cancel_rollback(), telemetryfw_state = "UPDATED". - failure → ESP-IDF auto-boots prior partition on next reset; first telemetry from prior partition reports
fw_state = "FAILED: rollback".
- success →
6.3 Concurrency
- OTA runs in its own task (stack 8 KB, priority above
loopTask). - Before download begins: set
pauseScan = trueflag observed by Modbus scan SM, drain command worker queue, suspend display task. - Resume only on OTA abort path. On success path → reboot, so resume is unreachable.
6.4 Resilience
| Scenario | Outcome |
|---|---|
| Network drop mid-download | esp_https_ota_perform errors → abort + retry in 5 min |
| Power cut mid-download | otadata not committed → boots old partition |
Power cut after esp_ota_end, before mark-valid |
Pending-verify expires → auto-rollback |
| Brick (both OTA slots bad) | Boots factory partition (held image at flash time) |
6.5 Tests
- New:
test/native_ota— mock HTTPClient +esp_ota_*API, cover checksum mismatch abort, chunk failure abort, success path. - Manual integration: bench device served a known-bad-checksum URL, observe rollback marker telemetry on next boot.
7. P2 — Task WDT for all tasks + recovery
7.1 Registration
In src/main.cpp after each xTaskCreate*:
esp_task_wdt_init(TASK_WDT_TIMEOUT_S, true); // panic on timeout
esp_task_wdt_add(loopTaskHandle);
esp_task_wdt_add(modbusScanTaskHandle);
esp_task_wdt_add(displayUpdateTaskHandle);
esp_task_wdt_add(commandWorkerTaskHandle);
esp_task_wdt_add(otaTaskHandle); // only while OTA active
7.2 Feeding cadence (per task)
| Task | Feed point |
|---|---|
loopTask |
Existing 10 ms cadence in loop() |
modbusScanTask |
After each register read in the state machine (Section 8) |
displayUpdateTask |
Each UI tick (~200 ms) |
commandWorkerTask |
After each queue xQueueReceive (1 s timeout) |
otaTask |
After each downloaded chunk |
7.3 Hung-task surfacing
ESP-IDF prints the hung task name to UART on panic, but ops can’t see UART. Capture into RTC RAM so it survives the soft reset:
Install
esp_task_wdt_isr_user_handler→ write hung task name to RTC RAM slot.After boot,
health_monitorreadsesp_reset_reason()+ RTC RAM, emits first telemetry:{"resetReason": "task_wdt", "hungTask": "ModbusScan", "fwVersion": "1.2.3"}Clear RTC RAM slot after publish confirmed (avoid repeating same hung-task report).
7.4 OTA + WDT interaction
If WDT fires during OTA flash write: esp_ota_end was not yet called → otadata uncommitted → boot rolls back. Safe.
7.5 Tests
test/native_health_monitorextended — mockesp_reset_reason()+ RTC RAM accessor, verify mapping into telemetry fields.
8. P5 — Async Modbus state machine
8.1 Problem
Current modbus_sensor::backgroundScanTask already runs in its own task, but uses blocking delay() between reads while holding the modbus_bus mutex. RPCs that need the bus (readModbusRegister, writeI2C-to-Modbus, etc.) wait the full inter-register / inter-slave delay every time. p99 RPC latency observed under load: up to 10 s.
8.2 Refactor
Replace blocking-delay loop with a state machine that holds the mutex only for one I/O step per tick:
enum class ScanState {
IDLE,
SLAVE_BEGIN,
REG_REQUEST,
REG_WAIT,
REG_DONE,
SLAVE_DONE,
PUBLISH,
};
Per tick:
xSemaphoreTake(modbus_bus_mutex, 50ms)— if fail, yield, leave state as-is.- Perform one short step (issue request, or read response, or advance index).
xSemaphoreGive.vTaskDelay(inter_register_delay_ms / portTICK_PERIOD_MS).
8.3 Constraints kept
- Full scan duration roughly unchanged (~10 s) because inter-register/inter-slave delays are sensor requirements, not software artifacts.
- Existing abort/
scan_request_idsemantics retained — setaborted = true, SM returns toIDLEon next tick. - ModbusMaster library unchanged.
8.4 New telemetry
modbusScanDurationMs— wall time of last full scan.modbusErrorCountPerSlave— count of CRC / timeout fails per slave per scan.
8.5 Tests
test/native_modbus_sensorextended — simulate an RPC arriving mid-scan; assert RPC completes within 200 ms while scan progresses.
9. Sequencing
F Partition table + NVS layout (Section 3) [BLOCKER for all flash work]
│
├──► nvs_creds + wifi_provisioner modules
│ ├──► P4a factory_flash.py script
│ ├──► P4b AP mode portal
│ └──► P3 MQTT TLS + load cred from NVS
│
└──► P1a OTA module (esp_https_ota + SHA256 + rollback)
└──► P1b Shared-attribute subscribe + fw lifecycle
P2 Task WDT for all tasks [parallel, no dependency]
P5 Async Modbus state machine [parallel, no dependency, sequenced last]
Recommended 8-week plan
| Week | Sprint | Work | Rationale |
|---|---|---|---|
| 1 | S1 | F partition table + NVS + factory-flash skeleton | Blocks all subsequent flashing |
| 1–2 | S1 | P2 Task WDT (parallel) | Independent; cheap; catches regressions in S2+ |
| 2–3 | S2 | P3 MQTT TLS + per-device cred from NVS | Security gate before pilot |
| 3–4 | S2 | P4 WiFi AP mode portal | Enables field-install path |
| 5–6 | S3 | P1 OTA + rollback | TLS infra reused; needed before pilot expands |
| 6–7 | S3 | P5 Async Modbus state machine | Bigger refactor; sequenced last to limit churn |
| 8 | S4 | 7-day soak + bench OTA integration | Re-audit gate |
Critical path: F → P3 → P4 → P1 (~5 weeks).
Parallel track: P2 + P5 (additional dev).
Verification gates
- End S1: Device flashed via factory script boots into AP mode without panic; WDT registered for every task and observable on UART under induced hang.
- End S2: One pilot device runs 24 h on TLS + WDT, no degradation, no fatal reconnects.
- End S3: Three pilot devices OTA from FW 1.0.0 → 1.0.1; one device served deliberate bad-checksum URL → auto-rollback verified.
- End S4: Five devices soak 7 days; no heap leak (
freeHeapdrift < 10 % of baseline); no unattributed reboots.
10. Out of scope
Tracked but not addressed in this design:
- M3 RPC doc/impl drift (covered by separate in-flight plan).
- M4 MQTT QoS1 on feedback path.
- M5 remote
reboot/factoryResetRPCs beyond what P4 needs. - S1 runtime Modbus slave map.
- S3 cross-reboot command-history persistence.
- N10 signed firmware with secure boot v2.
These remain in gateway_firmware/docs/production-readiness-roadmap.md and should be picked up in the next planning cycle after P0 lands.