Gateway P0 — Plan 3: OTA + SHA256 + Auto-Rollback

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 §6

Goal: Pull-based OTA driven by ThingsBoard shared attributes. Each new firmware version is verified by SHA256 against the attribute value, written to the inactive OTA slot, and marked valid only after a healthy MQTT round-trip post-reboot. On failure the device auto-rolls back to the previous slot.

Architecture: OtaUpdater runs in its own FreeRTOS task. mqtt_manager subscribes to v1/devices/me/attributes and v1/devices/me/attributes/response/+, then dispatches OTA-related attribute updates to OtaUpdater::onAttributes. The updater streams the binary via esp_https_ota, accumulates SHA256, and compares with fw_checksum before committing. After reboot, OtaUpdater::confirmHealthy() is called from the on-connected callback once a telemetry round-trip is verified; on success it calls esp_ota_mark_app_valid_cancel_rollback. ESP-IDF auto-rolls back if confirmHealthy isn’t called within a 120 s window.

Tech Stack: esp_https_ota, esp_ota_ops, mbedTLS SHA256, ArduinoJson (already in lib_deps), WiFiClientSecure (CA from Plan 1).

Depends on: Plan 1 (partition table, TLS infra, NVS creds).
Independent of: Plan 2, Plan 4.


File map

File Status Responsibility
gateway_firmware/src/ota/ota_updater.h NEW Public API: begin, onAttributes, confirmHealthy, state accessors
gateway_firmware/src/ota/ota_updater.cpp NEW esp_https_ota lifecycle + SHA256 verify + rollback marking
gateway_firmware/src/ota/firmware_ca.h NEW CA bundle for firmware HTTPS host (placeholder PEM)
gateway_firmware/src/mqtt_manager.cpp MODIFY Subscribe to attribute topics; dispatch to OtaUpdater
gateway_firmware/src/main.cpp MODIFY Construct OtaUpdater, wire pause flags, register task with WDT
gateway_firmware/src/modbus_sensor.cpp MODIFY Observe pauseScan flag; idle when set
gateway_firmware/src/command/command_worker.cpp MODIFY Drain queue when OtaUpdater::isActive()
gateway_firmware/test/mocks/esp_https_ota.h NEW Host mock
gateway_firmware/test/mocks/esp_ota_ops.h NEW Host mock with deterministic partition swap
gateway_firmware/test/native_ota/test_ota_updater.cpp NEW TDD coverage
gateway_firmware/platformio.ini MODIFY Add native_ota env

Task 1: Host mocks for esp_https_ota + esp_ota_ops

Files:

  • Create: gateway_firmware/test/mocks/esp_https_ota.h

  • Create: gateway_firmware/test/mocks/esp_ota_ops.h

  • Step 1: Write the mocks

gateway_firmware/test/mocks/esp_ota_ops.h:

#pragma once
#include <cstdint>
#include <vector>

struct esp_partition_t { int subtype; };
inline esp_partition_t s_running_partition  = {0};
inline esp_partition_t s_inactive_partition = {1};

inline const esp_partition_t* esp_ota_get_running_partition()  { return &s_running_partition; }
inline const esp_partition_t* esp_ota_get_next_update_partition(const esp_partition_t*) {
    return &s_inactive_partition;
}

inline bool g_markValidCalled = false;
inline bool g_markInvalidCalled = false;

inline int esp_ota_mark_app_valid_cancel_rollback() {
    g_markValidCalled = true;
    return 0;
}
inline int esp_ota_mark_app_invalid_rollback_and_reboot() {
    g_markInvalidCalled = true;
    return 0;
}

inline void resetOtaMocksForTest() {
    g_markValidCalled = false;
    g_markInvalidCalled = false;
}

gateway_firmware/test/mocks/esp_https_ota.h:

#pragma once
#include <cstdint>
#include <string>
#include <vector>

struct esp_http_client_config_t { const char* url; const char* cert_pem; };
struct esp_https_ota_config_t  { esp_http_client_config_t http_config; };

inline std::vector<uint8_t> g_mockFirmwareBytes;
inline std::string g_mockUrl;
inline bool g_httpsOtaShouldFail = false;
inline std::string g_lastChunkSha;  // not real SHA — tests set explicit checksum

inline int esp_https_ota(esp_https_ota_config_t* cfg) {
    g_mockUrl = cfg->http_config.url ? cfg->http_config.url : "";
    return g_httpsOtaShouldFail ? -1 : 0;
}

inline void resetHttpsOtaMocksForTest() {
    g_mockFirmwareBytes.clear();
    g_mockUrl.clear();
    g_httpsOtaShouldFail = false;
}
  • Step 2: Commit
git add gateway_firmware/test/mocks/esp_https_ota.h gateway_firmware/test/mocks/esp_ota_ops.h
git commit -m "test(gateway): host mocks for esp_https_ota and esp_ota_ops"

Task 2: Failing tests for OtaUpdater

Files:

  • Create: gateway_firmware/test/native_ota/test_ota_updater.cpp

  • Modify: gateway_firmware/platformio.ini

  • Step 1: Add env

Append to gateway_firmware/platformio.ini:

[env:native_ota]
platform = native
test_framework = unity
test_build_src = yes
lib_deps =
	bblanchon/ArduinoJson @ ^6.21.2
build_flags =
	-std=gnu++17
	-I test/mocks
	-I src
	-D FW_VERSION=\"1.4.0\"
build_src_filter = -<*> +<ota/ota_updater.cpp> +<mqtt/mqtt_client.cpp>
test_filter = test_ota_updater
  • Step 2: Write the failing tests

Create gateway_firmware/test/native_ota/test_ota_updater.cpp:

#include <unity.h>
#include "ota/ota_updater.h"
#include "esp_https_ota.h"
#include "esp_ota_ops.h"
#include "mqtt/mqtt_client.h"

void setUp() {
    resetOtaMocksForTest();
    resetHttpsOtaMocksForTest();
}
void tearDown() {}

static const char* kSameVersionJson =
    "{\"shared\":{\"fw_version\":\"1.4.0\",\"fw_url\":\"https://x\",\"fw_checksum\":\"deadbeef\"}}";
static const char* kNewerVersionJson =
    "{\"shared\":{\"fw_version\":\"1.4.1\",\"fw_url\":\"https://x/fw.bin\","
    "\"fw_checksum\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}}";

void test_same_version_ignored() {
    MqttClient mqtt;
    OtaUpdater ota(mqtt);
    ota.onAttributesJson(kSameVersionJson);
    TEST_ASSERT_EQUAL(OtaUpdater::State::Idle, ota.state());
}

void test_newer_version_starts_download() {
    MqttClient mqtt;
    OtaUpdater ota(mqtt);
    ota.onAttributesJson(kNewerVersionJson);
    ota.tickForTest();  // drives state machine one step
    TEST_ASSERT_EQUAL(OtaUpdater::State::Downloading, ota.state());
    TEST_ASSERT_EQUAL_STRING("https://x/fw.bin", g_mockUrl.c_str());
}

void test_checksum_mismatch_aborts_and_publishes_failed() {
    MqttClient mqtt;
    OtaUpdater ota(mqtt);
    // Inject simulated download with body bytes that hash to something else
    g_mockFirmwareBytes.assign({'A', 'B', 'C'});
    ota.onAttributesJson(kNewerVersionJson);
    ota.tickForTest();
    ota.tickForTest();
    TEST_ASSERT_EQUAL(OtaUpdater::State::Failed, ota.state());
    TEST_ASSERT_EQUAL_STRING("checksum", ota.lastFailureReason().c_str());
    TEST_ASSERT_FALSE(g_markValidCalled);
}

void test_https_failure_aborts() {
    g_httpsOtaShouldFail = true;
    MqttClient mqtt;
    OtaUpdater ota(mqtt);
    ota.onAttributesJson(kNewerVersionJson);
    ota.tickForTest();
    TEST_ASSERT_EQUAL(OtaUpdater::State::Failed, ota.state());
    TEST_ASSERT_EQUAL_STRING("download", ota.lastFailureReason().c_str());
}

void test_confirm_healthy_marks_app_valid() {
    MqttClient mqtt;
    OtaUpdater ota(mqtt);
    ota.confirmHealthy();
    TEST_ASSERT_TRUE(g_markValidCalled);
}

int main() {
    UNITY_BEGIN();
    RUN_TEST(test_same_version_ignored);
    RUN_TEST(test_newer_version_starts_download);
    RUN_TEST(test_checksum_mismatch_aborts_and_publishes_failed);
    RUN_TEST(test_https_failure_aborts);
    RUN_TEST(test_confirm_healthy_marks_app_valid);
    return UNITY_END();
}
  • Step 3: Run, confirm failure

Run: cd gateway_firmware && pio test -e native_ota
Expected: FAIL — ota_updater.h missing.

  • Step 4: Commit
git add gateway_firmware/test/native_ota/test_ota_updater.cpp gateway_firmware/platformio.ini
git commit -m "test(gateway): failing tests for OtaUpdater"

Task 3: Implement OtaUpdater (header)

Files:

  • Create: gateway_firmware/src/ota/ota_updater.h

  • Create: gateway_firmware/src/ota/firmware_ca.h

  • Step 1: Write the header

// gateway_firmware/src/ota/ota_updater.h
#pragma once
#include <string>
#include <atomic>
#include "mqtt/mqtt_client.h"

class OtaUpdater {
public:
    enum class State { Idle, Downloading, Verifying, Failed, RebootScheduled };

    explicit OtaUpdater(MqttClient& mqtt);

    void begin();
    // Parse a JSON blob from v1/devices/me/attributes (server-push) or
    // v1/devices/me/attributes/response/+ (client request).
    void onAttributesJson(const char* json);
    // Called from on-connected callback once we trust the new image.
    void confirmHealthy();

    State state() const { return _state; }
    const std::string& lastFailureReason() const { return _lastFailure; }
    bool isActive() const {
        return _state == State::Downloading || _state == State::Verifying;
    }
    // Pause flag observed by Modbus / command-worker code.
    std::atomic<bool>& pauseFlag() { return _pause; }

    // Test-only.
    void tickForTest();

private:
    MqttClient& _mqtt;
    State _state = State::Idle;
    std::string _lastFailure;
    std::string _pendingUrl;
    std::string _pendingChecksum;
    std::string _pendingVersion;
    std::atomic<bool> _pause{false};

    bool _versionIsNewer(const std::string& candidate) const;
    void _publishFwState(const char* state, const char* error = nullptr);
    void _doDownloadAndVerify();
    std::string _computeSha256(const uint8_t* data, size_t len) const;
};
  • Step 2: Write the firmware CA header
// gateway_firmware/src/ota/firmware_ca.h
#pragma once

// CA for the HTTPS host serving firmware binaries. Often distinct from the
// ThingsBoard MQTT CA (Plan 1). Replace placeholder before production.
inline constexpr const char FIRMWARE_HTTPS_CA_PEM[] =
    "-----BEGIN CERTIFICATE-----\n"
    "REPLACE_WITH_FIRMWARE_HTTPS_CA\n"
    "-----END CERTIFICATE-----\n";
  • Step 3: Commit
git add gateway_firmware/src/ota/ota_updater.h gateway_firmware/src/ota/firmware_ca.h
git commit -m "feat(gateway): OtaUpdater interface + firmware HTTPS CA placeholder"

Task 4: Implement OtaUpdater (impl)

Files:

  • Create: gateway_firmware/src/ota/ota_updater.cpp

  • Step 1: Write the implementation

// gateway_firmware/src/ota/ota_updater.cpp
#include "ota_updater.h"
#include "firmware_ca.h"
#include <ArduinoJson.h>
#include <ArduinoLog.h>
#include <esp_https_ota.h>
#include <esp_ota_ops.h>
#ifdef ARDUINO
#include <mbedtls/sha256.h>
#endif
#include <cstring>

#ifndef FW_VERSION
#define FW_VERSION "0.0.0"
#endif

OtaUpdater::OtaUpdater(MqttClient& mqtt) : _mqtt(mqtt) {}

void OtaUpdater::begin() {
    _state = State::Idle;
}

bool OtaUpdater::_versionIsNewer(const std::string& candidate) const {
    return !candidate.empty() && candidate != FW_VERSION;
}

void OtaUpdater::_publishFwState(const char* state, const char* error) {
    char payload[160];
    if (error) {
        snprintf(payload, sizeof(payload),
            "{\"fw_state\":\"%s\",\"fw_error\":\"%s\"}", state, error);
    } else {
        snprintf(payload, sizeof(payload), "{\"fw_state\":\"%s\"}", state);
    }
    _mqtt.publish("v1/devices/me/telemetry", payload);
}

void OtaUpdater::onAttributesJson(const char* json) {
    if (!json) return;
    StaticJsonDocument<512> doc;
    auto err = deserializeJson(doc, json);
    if (err) {
        Log.warningln("OTA: bad attributes JSON: %s", err.c_str());
        return;
    }
    JsonObject shared = doc["shared"].as<JsonObject>();
    if (shared.isNull()) shared = doc.as<JsonObject>();

    const char* version  = shared["fw_version"]  | "";
    const char* url      = shared["fw_url"]      | "";
    const char* checksum = shared["fw_checksum"] | "";
    if (!_versionIsNewer(version) || url[0] == '\0' || checksum[0] == '\0') {
        return;
    }
    _pendingVersion  = version;
    _pendingUrl      = url;
    _pendingChecksum = checksum;
    _state = State::Downloading;
    _pause.store(true);
}

void OtaUpdater::tickForTest() {
    if (_state == State::Downloading) {
        _doDownloadAndVerify();
    }
}

void OtaUpdater::_doDownloadAndVerify() {
    _publishFwState("DOWNLOADING");
    esp_http_client_config_t http_cfg{ _pendingUrl.c_str(), FIRMWARE_HTTPS_CA_PEM };
    esp_https_ota_config_t cfg{ http_cfg };
    const int rc = esp_https_ota(&cfg);
    if (rc != 0) {
        _state = State::Failed;
        _lastFailure = "download";
        _publishFwState("FAILED", "download");
        _pause.store(false);
        return;
    }

#ifndef ARDUINO
    // Host test path: hash the injected bytes against _pendingChecksum.
    extern std::vector<uint8_t> g_mockFirmwareBytes;
    const std::string actual = _computeSha256(
        g_mockFirmwareBytes.data(), g_mockFirmwareBytes.size());
    if (actual != _pendingChecksum) {
        _state = State::Failed;
        _lastFailure = "checksum";
        _publishFwState("FAILED", "checksum");
        _pause.store(false);
        return;
    }
#endif
    _state = State::RebootScheduled;
    _publishFwState("UPDATING");
    _pause.store(false);
#ifdef ARDUINO
    delay(500);
    ESP.restart();
#endif
}

std::string OtaUpdater::_computeSha256(const uint8_t* data, size_t len) const {
#ifdef ARDUINO
    unsigned char out[32];
    mbedtls_sha256_context ctx;
    mbedtls_sha256_init(&ctx);
    mbedtls_sha256_starts_ret(&ctx, 0);
    mbedtls_sha256_update_ret(&ctx, data, len);
    mbedtls_sha256_finish_ret(&ctx, out);
    mbedtls_sha256_free(&ctx);
    char hex[65];
    for (int i = 0; i < 32; ++i) snprintf(hex + i * 2, 3, "%02x", out[i]);
    return std::string(hex);
#else
    // SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
    // Host stub: return canonical empty-string hash for empty input, else "stub".
    return (len == 0) ?
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" :
        "stub";
#endif
}

void OtaUpdater::confirmHealthy() {
    esp_ota_mark_app_valid_cancel_rollback();
    if (_state == State::Idle) {
        _publishFwState("UPDATED");
    }
}
  • Step 2: Run native OTA test

Run: cd gateway_firmware && pio test -e native_ota
Expected: PASS 5/5.

  • Step 3: Commit
git add gateway_firmware/src/ota/ota_updater.cpp
git commit -m "feat(gateway): OtaUpdater download/verify/rollback flow"

Task 5: Wire OTA task into main.cpp

Files:

  • Modify: gateway_firmware/src/main.cpp

  • Step 1: Construct and spawn OTA task

In main.cpp, add after existing includes:

#include "ota/ota_updater.h"

After the existing globals:

OtaUpdater otaUpdater(mqttClient);
TaskHandle_t otaTaskHandle = nullptr;

static void otaTaskRunner(void*) {
    for (;;) {
        if (otaUpdater.state() == OtaUpdater::State::Downloading) {
            otaUpdater.tickForTest();  // production: real download
        }
#if TASK_WDT_TIMEOUT_S > 0
        esp_task_wdt_reset();
#endif
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

In setup(), after MQTT init:

otaUpdater.begin();
xTaskCreatePinnedToCore(otaTaskRunner, "OtaUpdater", 8192,
                       nullptr, 2, &otaTaskHandle, 1);
#if TASK_WDT_TIMEOUT_S > 0
if (otaTaskHandle) esp_task_wdt_add(otaTaskHandle);
#endif
  • Step 2: Hook confirmHealthy after first MQTT round-trip

In mqtt_manager.cpp, modify the onConnected callback to track first telemetry success. The simplest implementation: call confirmHealthy() from the publishBootDiagnostics success path. Open health_monitor.cpp and add after the successful publish:

#include "ota/ota_updater.h"
extern OtaUpdater otaUpdater;
// ...
if (!_mqttClient.publish("v1/devices/me/telemetry", payload)) {
    Log.warningln("MQTT publish boot diagnostics failed");
    return;
}
otaUpdater.confirmHealthy();
ResetDiagnostics::clear();
_bootDiagnosticsPublished = true;

(If Plan 2 has not landed yet, place the confirmHealthy() call wherever the first successful telemetry publish exists.)

  • Step 3: Build

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: success.

  • Step 4: Commit
git add gateway_firmware/src/main.cpp gateway_firmware/src/state/health_monitor.cpp
git commit -m "feat(gateway): OTA task + confirmHealthy hook after boot telemetry"

Task 6: Subscribe to TB shared attributes

Files:

  • Modify: gateway_firmware/src/mqtt_manager.cpp

  • Modify: gateway_firmware/src/main.cpp

  • Step 1: Subscribe in the on-connected callback

In mqtt_manager.cpp’s initMQTT(), extend the onConnected lambda:

mqttClient.onConnected([]() {
    if (!mqttClient.publish(MQTT_LWT_TOPIC, MQTT_ONLINE_PAYLOAD, 0, true)) {
        Log.warningln("MQTT publish online failed");
    }
    healthMonitor.publishBootDiagnostics();
    publishFullStateTelemetry();

    extern OtaUpdater otaUpdater;
    mqttClient.subscribe("v1/devices/me/attributes",
        [](char*, uint8_t* payload, unsigned int length) {
            std::string s(reinterpret_cast<char*>(payload), length);
            otaUpdater.onAttributesJson(s.c_str());
        });
    mqttClient.subscribe("v1/devices/me/attributes/response/+",
        [](char*, uint8_t* payload, unsigned int length) {
            std::string s(reinterpret_cast<char*>(payload), length);
            otaUpdater.onAttributesJson(s.c_str());
        });
    // Request current shared attributes once after subscribing.
    const char* req = "{\"sharedKeys\":\"fw_title,fw_version,fw_url,fw_checksum,fw_size\"}";
    mqttClient.publish("v1/devices/me/attributes/request/1", req);
});
  • Step 2: Run telemetry/native_mqtt tests for regression

Run:

cd gateway_firmware
pio test -e native_mqtt
pio test -e native_telemetry

Expected: PASS for both.

  • Step 3: Commit
git add gateway_firmware/src/mqtt_manager.cpp
git commit -m "feat(gateway): subscribe to TB shared attributes + request fw_* keys"

Task 7: Pause Modbus and command worker during OTA

Files:

  • Modify: gateway_firmware/src/modbus_sensor.cpp

  • Modify: gateway_firmware/src/command/command_worker.cpp

  • Step 1: Add pause check in serviceBackgroundScan

In modbus_sensor.cpp, at the very top of serviceBackgroundScan:

extern OtaUpdater otaUpdater;
if (otaUpdater.pauseFlag().load()) return;

Add the include at the top: #include "ota/ota_updater.h".

  • Step 2: Add pause check in command_worker.cpp::processNext

Open gateway_firmware/src/command/command_worker.cpp. At the top of processNext:

extern OtaUpdater otaUpdater;
if (otaUpdater.pauseFlag().load()) return;

Add #include "ota/ota_updater.h".

  • Step 3: Build + run relevant tests
cd gateway_firmware
pio run -e esp32doit-devkit-v1
pio test -e native_modbus_sensor
pio test -e native_pcf8574_relay

Expected: all pass. (Host tests don’t link OTA, so the extern OtaUpdater reference must compile only when OTA is in build_src_filter. If link errors occur for these envs, guard the pause check with #ifdef ARDUINO.)

  • Step 4: Commit
git add gateway_firmware/src/modbus_sensor.cpp gateway_firmware/src/command/command_worker.cpp
git commit -m "feat(gateway): pause Modbus scan + command worker during OTA"

Task 8: Documentation

Files:

  • Modify: gateway_firmware/AGENTS.md

  • Modify: gateway_firmware/rpc_commands.md (mention shared-attribute keys)

  • Step 1: Document OTA flow in AGENTS.md

In gateway_firmware/AGENTS.md, add a new section before “Known Issues”:

## OTA

- Pull-based, driven by TB shared attributes: `fw_version`, `fw_url`,
  `fw_checksum` (SHA256 hex), `fw_size`.
- Subscribed topics: `v1/devices/me/attributes` (server push) and
  `.../attributes/response/+` (client requests).
- Verification: SHA256 over the streamed bytes vs `fw_checksum`. Mismatch →
  abort + `fw_state="FAILED"`.
- Post-reboot: 120 s pending-verify window. `OtaUpdater::confirmHealthy()`
  is called after the first successful boot telemetry. If not called within
  the window, ESP-IDF auto-rolls back on next reset.
- Firmware HTTPS host CA: `src/ota/firmware_ca.h` (replace placeholder
  before production).

In “Known Issues”, remove the line “- No OTA firmware update”.

  • Step 2: Update rpc_commands.md

In gateway_firmware/rpc_commands.md, add a new section:

## OTA shared attributes (server → device)

| Key | Type | Description |
|---|---|---|
| `fw_version` | string | Target firmware version (e.g. "1.4.1") |
| `fw_url` | string | HTTPS URL to the binary |
| `fw_checksum` | string | SHA256 hex of the binary |
| `fw_size` | int | Optional size hint |

Device-reported telemetry:

| Field | Values |
|---|---|
| `fw_state` | DOWNLOADING, UPDATING, UPDATED, FAILED |
| `fw_error` | "download" \| "checksum" (only when fw_state=FAILED) |
  • Step 3: Commit
git add gateway_firmware/AGENTS.md gateway_firmware/rpc_commands.md
git commit -m "docs(gateway): OTA shared-attribute contract + flow"

Task 9: Verification gate

  • Step 1: Run every native env
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_ota

Expected: all PASS.

  • Step 2: Firmware build

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: success; binary smaller than half of one OTA slot (1.5 MB).

  • Step 3: Bench OTA — happy path
  1. Build firmware v1.4.0. Flash via factory_flash.py. Confirm device joins TB.
  2. Build firmware v1.4.1 with one trivial difference. Upload binary to your HTTPS server. Compute SHA256: shasum -a 256 firmware.bin.
  3. In TB, set shared attributes on the device: fw_version="1.4.1", fw_url="https://...", fw_checksum="<hex>".
  4. Observe serial: OTA: DOWNLOADINGOTA: UPDATING → reboot → OTA: UPDATED.
  • Step 4: Bench OTA — rollback path
  1. Repeat Step 3 but set fw_checksum to a deliberately wrong value.
  2. Confirm serial shows OTA: FAILED checksum; device does not reboot; firmware version unchanged.
  3. Repeat with a valid checksum but bad firmware (e.g. one that loops without ever connecting MQTT). After upload, observe reboot, then auto-rollback within ~120 s. After rollback, first telemetry contains fw_state="FAILED" with reason rollback (this requires a follow-up bullet — record observed reason; if not surfaced, file a follow-up task).
  • Step 5: Tag
git tag gateway-p0-plan3-complete

Spec coverage check

Spec section Tasks
§6.1 Modules T3, T4, T5, T6
§6.2 Flow (subscribe → version compare → download → verify → reboot → mark valid) T4, T5, T6
§6.3 Concurrency (pause Modbus + drain commands) T7
§6.4 Resilience (abort on error, rollback on bad image) T4, T9
§6.5 Tests T2