Gateway P0 — Plan 2: Task Watchdog for All Tasks
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Spec: docs/superpowers/specs/2026-05-14-gateway-p0-production-readiness-design.md §7
Goal: Subscribe every long-running FreeRTOS task to the ESP-IDF task watchdog, persist the hung-task name across the watchdog-triggered reset, and surface resetReason + hungTask + fwVersion in the first telemetry packet after boot.
Architecture: Each task that runs longer than the WDT period gets esp_task_wdt_add(handle) at spawn time, plus a feed point in its inner loop. An ISR-context hook copies the offending task name into RTC RAM (preserved across soft reset). After boot, HealthMonitor reads RTC RAM + esp_reset_reason() and includes them in the first telemetry packet, then clears RTC RAM.
Tech Stack: ESP-IDF esp_task_wdt, RTC_NOINIT_ATTR data segment, existing HealthMonitor class.
Scope: Spec §7 only. Independent of Plans 1/3/4 — can land in any order, but doing this first surfaces regressions introduced by Plans 1, 3, and 4.
File map
| File | Status | Responsibility |
|---|---|---|
gateway_firmware/src/state/reset_diagnostics.h |
NEW | Public API: read/clear RTC RAM slot |
gateway_firmware/src/state/reset_diagnostics.cpp |
NEW | RTC RAM storage + WDT hook + esp_reset_reason mapping |
gateway_firmware/src/state/health_monitor.h |
MODIFY | Add boot-once telemetry path |
gateway_firmware/src/state/health_monitor.cpp |
MODIFY | Include resetReason, hungTask, fwVersion once |
gateway_firmware/src/main.cpp |
MODIFY | Register every task with WDT; install ISR hook; feed per task |
gateway_firmware/src/modbus_sensor.cpp |
MODIFY | Feed WDT inside modbusScanTask poll loop |
gateway_firmware/src/modbus/display_driver.cpp |
MODIFY | Feed WDT inside update task |
gateway_firmware/src/command/command_worker.cpp |
MODIFY | Feed WDT after each queue receive |
gateway_firmware/test/mocks/esp_task_wdt.h |
MODIFY | Track subscribed handles for assertions |
gateway_firmware/test/native_health_monitor/test_health_monitor.cpp |
NEW | First-boot telemetry assertions |
gateway_firmware/platformio.ini |
MODIFY | Add native_health_monitor env |
Task 1: Failing test — first-boot telemetry contains diagnostics
Files:
Create:
gateway_firmware/test/native_health_monitor/test_health_monitor.cppModify:
gateway_firmware/platformio.iniStep 1: Add env to
platformio.ini
[env:native_health_monitor]
platform = native
test_framework = unity
test_build_src = yes
build_flags =
-std=gnu++17
-I test/mocks
-I src
-D FW_VERSION=\"1.4.0\"
build_src_filter = -<*> +<state/health_monitor.cpp> +<state/reset_diagnostics.cpp> +<mqtt/mqtt_client.cpp>
test_filter = test_health_monitor
- Step 2: Write the failing test
Create gateway_firmware/test/native_health_monitor/test_health_monitor.cpp:
#include <unity.h>
#include <string>
#include "mqtt/mqtt_client.h"
#include "state/health_monitor.h"
#include "state/reset_diagnostics.h"
// The mqtt_client host mock exposes lastPublishedPayload(); if it doesn't yet,
// add it as part of this task. Otherwise capture publishes via a wrapper.
extern std::string g_lastTelemetryPayload; // provided by mqtt_client mock
void setUp() {
ResetDiagnostics::clearForTest();
g_lastTelemetryPayload.clear();
}
void tearDown() {}
void test_first_publish_includes_reset_reason_and_fw_version() {
ResetDiagnostics::injectForTest(ResetReason::TaskWdt, "ModbusScan");
MqttClient mqtt;
HealthMonitor hm(mqtt);
hm.publishBootDiagnostics(); // new method
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_lastTelemetryPayload.find("\"resetReason\":\"task_wdt\""));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_lastTelemetryPayload.find("\"hungTask\":\"ModbusScan\""));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_lastTelemetryPayload.find("\"fwVersion\":\"1.4.0\""));
}
void test_second_call_does_not_republish_boot_diagnostics() {
ResetDiagnostics::injectForTest(ResetReason::PowerOn, "");
MqttClient mqtt;
HealthMonitor hm(mqtt);
hm.publishBootDiagnostics();
g_lastTelemetryPayload.clear();
hm.publishBootDiagnostics(); // second call — must be a no-op
TEST_ASSERT_TRUE(g_lastTelemetryPayload.empty());
}
void test_rtc_slot_cleared_after_publish() {
ResetDiagnostics::injectForTest(ResetReason::TaskWdt, "DisplayUpd");
MqttClient mqtt;
HealthMonitor hm(mqtt);
hm.publishBootDiagnostics();
TEST_ASSERT_EQUAL_STRING("", ResetDiagnostics::lastHungTask().c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_first_publish_includes_reset_reason_and_fw_version);
RUN_TEST(test_second_call_does_not_republish_boot_diagnostics);
RUN_TEST(test_rtc_slot_cleared_after_publish);
return UNITY_END();
}
- Step 3: Run, confirm failure
Run: cd gateway_firmware && pio test -e native_health_monitor
Expected: FAIL — reset_diagnostics.h missing.
- Step 4: Commit
git add gateway_firmware/test/native_health_monitor/test_health_monitor.cpp gateway_firmware/platformio.ini
git commit -m "test(gateway): failing first-boot diagnostics tests"
Task 2: Capture publishes in mqtt_client mock
Files:
Modify:
gateway_firmware/test/mocks/PubSubClient.hor the existingmqtt_clientmockStep 1: Add global publish capture
If the test_mqtt_client suite already has a publish-capture mechanism, reuse it. Otherwise add to gateway_firmware/test/mocks/PubSubClient.h (or the closest existing mock) a global:
#include <string>
inline std::string g_lastTelemetryPayload;
And in the publish mock body (host-only path), set:
if (topic && payload) {
g_lastTelemetryPayload = std::string(payload);
}
- Step 2: Verify existing
test_mqtt_clientstill passes
Run: cd gateway_firmware && pio test -e native_mqtt
Expected: PASS.
- Step 3: Commit
git add gateway_firmware/test/mocks/PubSubClient.h
git commit -m "test(gateway): capture last published payload in mqtt mock"
Task 3: Implement ResetDiagnostics
Files:
Create:
gateway_firmware/src/state/reset_diagnostics.hCreate:
gateway_firmware/src/state/reset_diagnostics.cppStep 1: Write the header
// gateway_firmware/src/state/reset_diagnostics.h
#pragma once
#include <string>
enum class ResetReason : uint8_t {
Unknown = 0,
PowerOn,
Software,
Panic,
TaskWdt,
BrownOut,
};
class ResetDiagnostics {
public:
// Read once at boot. Internally caches; safe to call multiple times.
static void captureFromHardware();
// Mark last hung task (called from WDT ISR hook).
static void recordHungTask(const char* taskName);
static ResetReason lastReason();
static std::string lastReasonString();
static std::string lastHungTask();
static void clear(); // after successful telemetry publish
// Test-only.
static void clearForTest();
static void injectForTest(ResetReason reason, const std::string& taskName);
};
- Step 2: Write the implementation
// gateway_firmware/src/state/reset_diagnostics.cpp
#include "reset_diagnostics.h"
#include <cstring>
#ifdef ARDUINO
#include <esp_system.h>
#include <esp_task_wdt.h>
#endif
namespace {
#ifdef ARDUINO
RTC_NOINIT_ATTR char rtc_hung_task[32];
RTC_NOINIT_ATTR uint32_t rtc_magic;
constexpr uint32_t kMagic = 0xC0DECAFE;
#else
static char rtc_hung_task[32];
static uint32_t rtc_magic = 0;
constexpr uint32_t kMagic = 0xC0DECAFE;
#endif
ResetReason s_reason = ResetReason::Unknown;
std::string s_hungTask;
bool s_captured = false;
ResetReason mapEspResetReason(int rr) {
#ifdef ARDUINO
switch (rr) {
case ESP_RST_POWERON: return ResetReason::PowerOn;
case ESP_RST_SW: return ResetReason::Software;
case ESP_RST_PANIC: return ResetReason::Panic;
case ESP_RST_TASK_WDT: return ResetReason::TaskWdt;
case ESP_RST_INT_WDT: return ResetReason::TaskWdt; // collapse
case ESP_RST_BROWNOUT: return ResetReason::BrownOut;
default: return ResetReason::Unknown;
}
#else
(void)rr;
return ResetReason::Unknown;
#endif
}
} // namespace
void ResetDiagnostics::captureFromHardware() {
if (s_captured) return;
s_captured = true;
#ifdef ARDUINO
s_reason = mapEspResetReason(esp_reset_reason());
#endif
if (rtc_magic == kMagic) {
rtc_hung_task[sizeof(rtc_hung_task) - 1] = '\0';
s_hungTask = rtc_hung_task;
} else {
s_hungTask.clear();
}
}
void ResetDiagnostics::recordHungTask(const char* taskName) {
if (!taskName) return;
std::strncpy(rtc_hung_task, taskName, sizeof(rtc_hung_task) - 1);
rtc_hung_task[sizeof(rtc_hung_task) - 1] = '\0';
rtc_magic = kMagic;
}
ResetReason ResetDiagnostics::lastReason() { return s_reason; }
std::string ResetDiagnostics::lastReasonString() {
switch (s_reason) {
case ResetReason::PowerOn: return "power_on";
case ResetReason::Software: return "software";
case ResetReason::Panic: return "panic";
case ResetReason::TaskWdt: return "task_wdt";
case ResetReason::BrownOut: return "brownout";
default: return "unknown";
}
}
std::string ResetDiagnostics::lastHungTask() { return s_hungTask; }
void ResetDiagnostics::clear() {
s_hungTask.clear();
rtc_magic = 0;
rtc_hung_task[0] = '\0';
}
void ResetDiagnostics::clearForTest() {
s_reason = ResetReason::Unknown;
s_hungTask.clear();
s_captured = false;
rtc_magic = 0;
rtc_hung_task[0] = '\0';
}
void ResetDiagnostics::injectForTest(ResetReason r, const std::string& t) {
s_captured = true;
s_reason = r;
s_hungTask = t;
}
- Step 3: Compile check
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds.
- Step 4: Commit
git add gateway_firmware/src/state/reset_diagnostics.h gateway_firmware/src/state/reset_diagnostics.cpp
git commit -m "feat(gateway): ResetDiagnostics — RTC-RAM hung-task + reset reason"
Task 4: Boot-once diagnostics in HealthMonitor
Files:
Modify:
gateway_firmware/src/state/health_monitor.hModify:
gateway_firmware/src/state/health_monitor.cppStep 1: Add
publishBootDiagnosticsdeclaration
In health_monitor.h, inside the class public section:
void publishBootDiagnostics();
Private members:
bool _bootDiagnosticsPublished = false;
- Step 2: Implement boot publish
In health_monitor.cpp, add at the top:
#include "state/reset_diagnostics.h"
#ifndef FW_VERSION
#define FW_VERSION "unknown"
#endif
Below _publish:
void HealthMonitor::publishBootDiagnostics() {
if (_bootDiagnosticsPublished) return;
ResetDiagnostics::captureFromHardware();
const std::string reason = ResetDiagnostics::lastReasonString();
const std::string hung = ResetDiagnostics::lastHungTask();
char payload[224];
int len;
if (hung.empty()) {
len = snprintf(payload, sizeof(payload),
"{\"resetReason\":\"%s\",\"fwVersion\":\"%s\"}",
reason.c_str(), FW_VERSION);
} else {
len = snprintf(payload, sizeof(payload),
"{\"resetReason\":\"%s\",\"hungTask\":\"%s\",\"fwVersion\":\"%s\"}",
reason.c_str(), hung.c_str(), FW_VERSION);
}
if (len < 0 || len >= static_cast<int>(sizeof(payload))) {
Log.warningln("boot diagnostics truncated (%d)", len);
return;
}
if (!_mqttClient.publish("v1/devices/me/telemetry", payload)) {
Log.warningln("MQTT publish boot diagnostics failed");
return; // try again next call
}
ResetDiagnostics::clear();
_bootDiagnosticsPublished = true;
}
- Step 3: Run health monitor tests
Run: cd gateway_firmware && pio test -e native_health_monitor
Expected: PASS 3/3.
- Step 4: Commit
git add gateway_firmware/src/state/health_monitor.h gateway_firmware/src/state/health_monitor.cpp
git commit -m "feat(gateway): HealthMonitor publishes reset reason + fwVersion at boot"
Task 5: Hook publishBootDiagnostics after first MQTT connect
Files:
Modify:
gateway_firmware/src/mqtt_manager.cppStep 1: Call boot diagnostics in the on-connected callback
In initMQTT() inside mqtt_manager.cpp, modify the onConnected lambda:
mqttClient.onConnected([]() {
if (!mqttClient.publish(MQTT_LWT_TOPIC, MQTT_ONLINE_PAYLOAD, 0, true)) {
Log.warningln("MQTT publish online failed (packet too large?)");
}
healthMonitor.publishBootDiagnostics();
publishFullStateTelemetry();
});
- Step 2: Forward-declare
healthMonitorif needed
Add at the top of mqtt_manager.cpp:
#include "state/health_monitor.h"
extern HealthMonitor healthMonitor;
- Step 3: Run telemetry test
Run: cd gateway_firmware && pio test -e native_telemetry
Expected: PASS.
- Step 4: Commit
git add gateway_firmware/src/mqtt_manager.cpp
git commit -m "feat(gateway): publish boot diagnostics after MQTT connect"
Task 6: Subscribe every task to WDT in main.cpp
Files:
Modify:
gateway_firmware/src/main.cppStep 1: Expose task handles
Make the existing task handles globally visible. In modbus_sensor.h add a public getter:
TaskHandle_t scanTaskHandle() const { return scan_task_handle_; }
In modbus/display_driver.h, add a similar accessor.
Open gateway_firmware/src/modbus/display_driver.h and add inside the class:
TaskHandle_t updateTaskHandle() const { return _updateTaskHandle; }
(If the field is named differently, match the existing name.)
- Step 2: Subscribe after task creation in
main.cpp
After modbusSensor.startBackgroundScan(); in setup():
#if TASK_WDT_TIMEOUT_S > 0
if (modbusSensor.scanTaskHandle()) {
esp_task_wdt_add(modbusSensor.scanTaskHandle());
}
#endif
After display.startUpdateTask(...):
#if TASK_WDT_TIMEOUT_S > 0
if (display.updateTaskHandle()) {
esp_task_wdt_add(display.updateTaskHandle());
}
#endif
(The loop task already calls esp_task_wdt_add(NULL) — leave that.)
- Step 3: Install ISR hook
Add to setup() immediately after esp_task_wdt_init:
#if TASK_WDT_TIMEOUT_S > 0 && defined(ARDUINO)
// Capture which task tripped the WDT into RTC RAM before reset.
esp_task_wdt_set_user_handler([](TaskHandle_t task) {
const char* name = pcTaskGetName(task);
ResetDiagnostics::recordHungTask(name ? name : "<unknown>");
});
#endif
If esp_task_wdt_set_user_handler is not available in the installed ESP-IDF version, fall back to a static extern "C" void esp_task_wdt_isr_user_handler() overload that calls ResetDiagnostics::recordHungTask("unknown"). Document the chosen path in AGENTS.md (Task 8 below).
- Step 4: Build
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds.
- Step 5: Commit
git add gateway_firmware/src/main.cpp gateway_firmware/src/modbus_sensor.h gateway_firmware/src/modbus/display_driver.h
git commit -m "feat(gateway): subscribe all FreeRTOS tasks to task WDT"
Task 7: Feed WDT inside each task’s loop
Files:
Modify:
gateway_firmware/src/modbus_sensor.cpp(themodbusScanTaskfunction)Modify:
gateway_firmware/src/modbus/display_driver.cppModify:
gateway_firmware/src/command/command_worker.cpp(only if it runs as its own task; otherwise skip)Step 1: Feed WDT in modbus scan task
In gateway_firmware/src/modbus_sensor.cpp, modify modbusScanTask:
static void modbusScanTask(void* param) {
ModbusSensorReader* reader = static_cast<ModbusSensorReader*>(param);
for (;;) {
reader->serviceBackgroundScan();
#if TASK_WDT_TIMEOUT_S > 0
esp_task_wdt_reset();
#endif
vTaskDelay(pdMS_TO_TICKS(kScanTaskPollIntervalMs));
}
}
Add #include "esp_task_wdt.h" at the top if not present.
- Step 2: Feed WDT in display update task
Open gateway_firmware/src/modbus/display_driver.cpp. Locate the task body (loop). Inside the for(;;) loop, after the existing tick work and before the vTaskDelay, add:
#if TASK_WDT_TIMEOUT_S > 0
esp_task_wdt_reset();
#endif
Add #include "esp_task_wdt.h" at the top.
- Step 3: Build and verify
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds. No new warnings.
- Step 4: Commit
git add gateway_firmware/src/modbus_sensor.cpp gateway_firmware/src/modbus/display_driver.cpp
git commit -m "feat(gateway): feed task WDT inside modbus + display task loops"
Task 8: Update docs
Files:
Modify:
gateway_firmware/AGENTS.mdStep 1: Replace the WDT bullet
Open gateway_firmware/AGENTS.md. Find the existing line:
- No hardware watchdog
Replace with:
- Every FreeRTOS task (loop, ModbusScan, DisplayUpd) is subscribed to the
ESP-IDF task watchdog (`TASK_WDT_TIMEOUT_S=30` in `config.h`). On trigger,
the ISR hook captures `pcTaskGetName(task)` into RTC RAM (`RTC_NOINIT_ATTR`),
the device reboots, and the first telemetry packet after boot publishes
`{resetReason, hungTask, fwVersion}` via `HealthMonitor::publishBootDiagnostics`.
- Step 2: Commit
git add gateway_firmware/AGENTS.md
git commit -m "docs(gateway): document task WDT + boot diagnostics flow"
Task 9: Verification gate
- Step 1: Run all native tests
cd gateway_firmware
pio test -e native
pio test -e native_mqtt
pio test -e native_modbus_sensor
pio test -e native_telemetry
pio test -e native_wifi_link
pio test -e native_command_types
pio test -e native_command_history
pio test -e native_rpc
pio test -e native_pcf8574_relay
pio test -e native_boot_init
pio test -e native_health_monitor
Expected: every env PASS.
- Step 2: Build firmware
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: success.
- Step 3: Bench WDT simulation
On a bench device, temporarily insert into modbusScanTask:
if (millis() > 60000) { while(true) {} } // remove after test
Flash, wait ~1 minute. Device should reboot. Next MQTT telemetry must contain "resetReason":"task_wdt" and "hungTask":"ModbusScan". Remove the test code afterward.
- Step 4: Tag
git tag gateway-p0-plan2-complete
Spec coverage check
| Spec section | Tasks |
|---|---|
| §7.1 Task WDT registration | T6 |
| §7.2 Feeding cadence | T7 |
| §7.3 Hung-task surfacing via RTC RAM | T3, T6 |
| §7.4 OTA + WDT interaction | Noted; concrete code in Plan 3 |
| §7.5 Health monitor test | T1, T4 |