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.hCreate:
gateway_firmware/test/mocks/esp_ota_ops.hStep 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.cppModify:
gateway_firmware/platformio.iniStep 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.hCreate:
gateway_firmware/src/ota/firmware_ca.hStep 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.cppStep 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.cppStep 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
confirmHealthyafter 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.cppModify:
gateway_firmware/src/main.cppStep 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.cppModify:
gateway_firmware/src/command/command_worker.cppStep 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.mdModify:
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
- Build firmware v1.4.0. Flash via
factory_flash.py. Confirm device joins TB. - Build firmware v1.4.1 with one trivial difference. Upload binary to your HTTPS server. Compute SHA256:
shasum -a 256 firmware.bin. - In TB, set shared attributes on the device:
fw_version="1.4.1",fw_url="https://...",fw_checksum="<hex>". - Observe serial:
OTA: DOWNLOADING→OTA: UPDATING→ reboot →OTA: UPDATED.
- Step 4: Bench OTA — rollback path
- Repeat Step 3 but set
fw_checksumto a deliberately wrong value. - Confirm serial shows
OTA: FAILED checksum; device does not reboot; firmware version unchanged. - 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 reasonrollback(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 |