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.cpp

  • Modify: gateway_firmware/platformio.ini

  • Step 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.h or the existing mqtt_client mock

  • Step 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_client still 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.h

  • Create: gateway_firmware/src/state/reset_diagnostics.cpp

  • Step 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.h

  • Modify: gateway_firmware/src/state/health_monitor.cpp

  • Step 1: Add publishBootDiagnostics declaration

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.cpp

  • Step 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 healthMonitor if 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.cpp

  • Step 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 (the modbusScanTask function)

  • Modify: gateway_firmware/src/modbus/display_driver.cpp

  • Modify: 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.md

  • Step 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