Gateway P0 — Plan 1: Foundation + MQTT TLS + WiFi Provisioning

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

Goal: Land the partition-table foundation, per-device MQTT credentials over TLS from NVS, and the WiFi AP-mode provisioning portal — the first three P0 deliverables on the critical path (F + P3 + P4 in the spec).

Architecture: Adds an src/provisioning/ module that owns NVS-backed config (creds and wifi namespaces). The MQTT client swaps WiFiClient for WiFiClientSecure and reads credentials at boot from NvsCreds instead of secrets.h. WifiProvisioner falls back to AP mode (captive portal via IotWebConf) when WiFi credentials are missing or repeatedly fail. A tools/factory_flash.py script writes per-device NVS images at production time.

Tech Stack: PlatformIO, Arduino-ESP32, WiFiClientSecure (mbedTLS), Preferences (NVS wrapper), IotWebConf (already in lib_deps), Unity for native host tests, esptool.py+nvs_partition_gen.py for factory flashing.

Scope of this plan: Foundation (Section 3), P3 (Section 4), P4 (Section 5) of the spec. Out of scope: P1 OTA (Plan 3), P2 Task WDT (Plan 2), P5 async Modbus (Plan 4).


File map

File Status Responsibility
gateway_firmware/partitions_two_ota.csv NEW Dual-OTA partition table with dedicated creds/wifi NVS
gateway_firmware/platformio.ini MODIFY Wire partitions; add native_provisioning, native_nvs_creds envs
gateway_firmware/src/provisioning/nvs_creds.h NEW Typed accessors for creds namespace
gateway_firmware/src/provisioning/nvs_creds.cpp NEW NVS read/write impl over Preferences
gateway_firmware/src/provisioning/wifi_provisioner.h NEW Boot-flow state machine + AP-mode portal API
gateway_firmware/src/provisioning/wifi_provisioner.cpp NEW Impl wrapping IotWebConf
gateway_firmware/src/mqtt/tb_ca.h NEW ThingsBoard CA chain (PROGMEM)
gateway_firmware/src/mqtt/mqtt_client.h MODIFY Swap WiFiClientWiFiClientSecure; expose setCACert
gateway_firmware/src/mqtt/mqtt_client.cpp MODIFY TLS connect + port 8883
gateway_firmware/src/mqtt_manager.cpp MODIFY Load creds from NvsCreds
gateway_firmware/src/config.h MODIFY Drop hardcoded MQTT_SERVER/MQTT_PORT as runtime defaults only
gateway_firmware/src/main.cpp MODIFY New boot flow (load NVS → connect → AP fallback)
gateway_firmware/test/mocks/Preferences.h NEW Host-native mock of Arduino-ESP32 Preferences
gateway_firmware/test/mocks/WiFiClientSecure.h NEW Host-native mock matching WiFiClient API + setCACert
gateway_firmware/test/mocks/IotWebConf.h NEW Host-native mock of IotWebConf
gateway_firmware/test/native_nvs_creds/test_nvs_creds.cpp NEW Unit tests for NvsCreds
gateway_firmware/test/native_provisioning/test_wifi_provisioner.cpp NEW Boot decision FSM tests
gateway_firmware/test/test_mqtt_client/test_mqtt_client.cpp MODIFY Assert setCACert invoked, port 8883
gateway_firmware/tools/factory_flash.py NEW Per-device NVS image + flash script
gateway_firmware/tools/README.md NEW Document factory-flash workflow

Each src/provisioning/* file owns one concern (credential storage vs WiFi state machine). The tb_ca.h constant lives next to the MQTT client because that’s the only consumer. Keeping the AP portal behind a thin WifiProvisioner interface means the IotWebConf dependency is one swap away from being replaced later.


Conventions used in every task

  • Tests use Unity (pio test -e <env>).
  • Run native tests from gateway_firmware/. Example: cd gateway_firmware && pio test -e native_nvs_creds.
  • Commit after each green test step. Commit messages follow the existing repo style (feat(gateway): / test(gateway): / chore(gateway):).
  • Never use git add -A or git add .. Stage specific paths.
  • secrets.h is gradually drained — by end of plan it should not contain MQTT_USER/MQTT_PASSWORD anymore.

Task 1: Add dual-OTA partition table

Files:

  • Create: gateway_firmware/partitions_two_ota.csv

  • Modify: gateway_firmware/platformio.ini (lines 22–36)

  • Step 1: Create the partition CSV

Create gateway_firmware/partitions_two_ota.csv with exactly this content:

# 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,
  • Step 2: Wire the partition table in platformio.ini

Add two lines under [env:esp32doit-devkit-v1] after monitor_speed = 115200:

board_build.partitions = partitions_two_ota.csv
board_upload.flash_size = 4MB
  • Step 3: Verify firmware still builds

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds; PlatformIO log shows Partitions: partitions_two_ota.csv.

  • Step 4: Commit
git add gateway_firmware/partitions_two_ota.csv gateway_firmware/platformio.ini
git commit -m "feat(gateway): dual-OTA partition table with creds/wifi NVS"

Task 2: Host-native Preferences mock

Files:

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

The Arduino-ESP32 Preferences API persists key-value pairs in an NVS namespace. For host tests, we need a mock that behaves like an in-memory map keyed by namespace.

  • Step 1: Write the mock

Create gateway_firmware/test/mocks/Preferences.h:

#pragma once
#include <cstdint>
#include <cstring>
#include <map>
#include <string>

class Preferences {
public:
    bool begin(const char* ns, bool readOnly = false) {
        (void)readOnly;
        _ns = ns ? ns : "";
        return true;
    }
    void end() {}
    bool clear() { _store()[_ns].clear(); return true; }
    bool remove(const char* key) { return _store()[_ns].erase(key) > 0; }

    size_t putString(const char* key, const char* value) {
        _store()[_ns][key] = value ? value : "";
        return std::strlen(value ? value : "");
    }
    size_t putString(const char* key, const std::string& value) {
        _store()[_ns][key] = value;
        return value.size();
    }
    size_t putUInt(const char* key, uint32_t value) {
        _store()[_ns][key] = std::to_string(value);
        return sizeof(uint32_t);
    }
    bool isKey(const char* key) {
        auto& ns = _store()[_ns];
        return ns.find(key) != ns.end();
    }
    std::string getString(const char* key, const char* defaultValue = "") {
        auto& ns = _store()[_ns];
        auto it = ns.find(key);
        return it == ns.end() ? std::string(defaultValue ? defaultValue : "") : it->second;
    }
    size_t getString(const char* key, char* out, size_t maxLen) {
        std::string v = getString(key, "");
        size_t n = v.size() < maxLen - 1 ? v.size() : maxLen - 1;
        std::memcpy(out, v.data(), n);
        out[n] = '\0';
        return n;
    }
    uint32_t getUInt(const char* key, uint32_t defaultValue = 0) {
        auto& ns = _store()[_ns];
        auto it = ns.find(key);
        return it == ns.end() ? defaultValue : static_cast<uint32_t>(std::stoul(it->second));
    }

    static void resetAllForTest() { _store().clear(); }

private:
    std::string _ns;
    using NsMap = std::map<std::string, std::string>;
    static std::map<std::string, NsMap>& _store() {
        static std::map<std::string, NsMap> s;
        return s;
    }
};
  • Step 2: Commit
git add gateway_firmware/test/mocks/Preferences.h
git commit -m "test(gateway): add host-native Preferences mock"

Task 3: NvsCreds — failing test first

Files:

  • Create: gateway_firmware/test/native_nvs_creds/test_nvs_creds.cpp

  • Modify: gateway_firmware/platformio.ini (add env)

  • Step 1: Add native env for the new test

Append to gateway_firmware/platformio.ini:

[env:native_nvs_creds]
platform = native
test_framework = unity
test_build_src = yes
build_flags =
	-std=gnu++17
	-I test/mocks
	-I src
build_src_filter = -<*> +<provisioning/nvs_creds.cpp>
test_filter = test_nvs_creds
  • Step 2: Write the failing test

Create gateway_firmware/test/native_nvs_creds/test_nvs_creds.cpp:

#include <unity.h>
#include "Preferences.h"
#include "provisioning/nvs_creds.h"

void setUp() { Preferences::resetAllForTest(); }
void tearDown() {}

void test_get_returns_empty_when_namespace_blank() {
    NvsCreds creds;
    TEST_ASSERT_EQUAL_STRING("", creds.mqttToken().c_str());
    TEST_ASSERT_EQUAL_STRING("", creds.deviceId().c_str());
    TEST_ASSERT_FALSE(creds.hasMqttToken());
}

void test_round_trip_token_and_device_id() {
    {
        NvsCreds writer;
        writer.setMqttToken("token-abc");
        writer.setDeviceId("dev-001");
        writer.setMqttHost("tb.example.com");
        writer.setMqttPort(8883);
    }
    NvsCreds reader;
    TEST_ASSERT_EQUAL_STRING("token-abc", reader.mqttToken().c_str());
    TEST_ASSERT_EQUAL_STRING("dev-001", reader.deviceId().c_str());
    TEST_ASSERT_EQUAL_STRING("tb.example.com", reader.mqttHost().c_str());
    TEST_ASSERT_EQUAL_UINT16(8883, reader.mqttPort());
    TEST_ASSERT_TRUE(reader.hasMqttToken());
}

void test_wifi_credentials_separate_namespace() {
    {
        NvsCreds w;
        w.setWifiSsid("HMP-IoT");
        w.setWifiPsk("supersecret");
    }
    NvsCreds r;
    TEST_ASSERT_EQUAL_STRING("HMP-IoT", r.wifiSsid().c_str());
    TEST_ASSERT_EQUAL_STRING("supersecret", r.wifiPsk().c_str());
    TEST_ASSERT_TRUE(r.hasWifiCredentials());
}

void test_clear_wifi_does_not_touch_creds() {
    {
        NvsCreds w;
        w.setMqttToken("tok");
        w.setWifiSsid("net");
        w.setWifiPsk("psk");
    }
    NvsCreds c;
    c.clearWifi();
    TEST_ASSERT_FALSE(c.hasWifiCredentials());
    TEST_ASSERT_TRUE(c.hasMqttToken());
}

int main() {
    UNITY_BEGIN();
    RUN_TEST(test_get_returns_empty_when_namespace_blank);
    RUN_TEST(test_round_trip_token_and_device_id);
    RUN_TEST(test_wifi_credentials_separate_namespace);
    RUN_TEST(test_clear_wifi_does_not_touch_creds);
    return UNITY_END();
}
  • Step 3: Run test, confirm it fails to compile

Run: cd gateway_firmware && pio test -e native_nvs_creds
Expected: FAIL — nvs_creds.h: No such file or directory.

  • Step 4: Commit failing test
git add gateway_firmware/test/native_nvs_creds/test_nvs_creds.cpp gateway_firmware/platformio.ini
git commit -m "test(gateway): failing tests for NvsCreds module"

Task 4: Implement NvsCreds

Files:

  • Create: gateway_firmware/src/provisioning/nvs_creds.h

  • Create: gateway_firmware/src/provisioning/nvs_creds.cpp

  • Step 1: Write the header

// gateway_firmware/src/provisioning/nvs_creds.h
#pragma once
#include <string>
#include <cstdint>

class NvsCreds {
public:
    // `creds` namespace (MQTT identity, written once at factory time).
    std::string mqttToken() const;
    std::string mqttHost() const;
    uint16_t    mqttPort() const;
    std::string deviceId() const;
    bool        hasMqttToken() const;

    void setMqttToken(const std::string& token);
    void setMqttHost(const std::string& host);
    void setMqttPort(uint16_t port);
    void setDeviceId(const std::string& id);

    // `wifi` namespace (writable at runtime via AP portal).
    std::string wifiSsid() const;
    std::string wifiPsk() const;
    bool        hasWifiCredentials() const;

    void setWifiSsid(const std::string& ssid);
    void setWifiPsk(const std::string& psk);
    void clearWifi();
};
  • Step 2: Write the implementation
// gateway_firmware/src/provisioning/nvs_creds.cpp
#include "nvs_creds.h"
#include <Preferences.h>

namespace {
constexpr const char* NS_CREDS = "creds";
constexpr const char* NS_WIFI  = "wifi";

constexpr const char* K_TOKEN  = "mqtt_token";
constexpr const char* K_HOST   = "mqtt_host";
constexpr const char* K_PORT   = "mqtt_port";
constexpr const char* K_DEV    = "device_id";
constexpr const char* K_SSID   = "ssid";
constexpr const char* K_PSK    = "psk";

std::string readString(const char* ns, const char* key) {
    Preferences p;
    if (!p.begin(ns, true)) return "";
    std::string v = p.getString(key, "").c_str();
    p.end();
    return v;
}

void writeString(const char* ns, const char* key, const std::string& value) {
    Preferences p;
    if (!p.begin(ns, false)) return;
    p.putString(key, value);
    p.end();
}
}  // namespace

std::string NvsCreds::mqttToken() const  { return readString(NS_CREDS, K_TOKEN); }
std::string NvsCreds::mqttHost() const   { return readString(NS_CREDS, K_HOST); }
std::string NvsCreds::deviceId() const   { return readString(NS_CREDS, K_DEV); }
bool        NvsCreds::hasMqttToken() const { return !mqttToken().empty(); }

uint16_t NvsCreds::mqttPort() const {
    Preferences p;
    if (!p.begin(NS_CREDS, true)) return 0;
    uint32_t v = p.getUInt(K_PORT, 0);
    p.end();
    return static_cast<uint16_t>(v);
}

void NvsCreds::setMqttToken(const std::string& token) { writeString(NS_CREDS, K_TOKEN, token); }
void NvsCreds::setMqttHost(const std::string& host)   { writeString(NS_CREDS, K_HOST, host); }
void NvsCreds::setDeviceId(const std::string& id)     { writeString(NS_CREDS, K_DEV, id); }

void NvsCreds::setMqttPort(uint16_t port) {
    Preferences p;
    if (!p.begin(NS_CREDS, false)) return;
    p.putUInt(K_PORT, port);
    p.end();
}

std::string NvsCreds::wifiSsid() const { return readString(NS_WIFI, K_SSID); }
std::string NvsCreds::wifiPsk() const  { return readString(NS_WIFI, K_PSK); }
bool        NvsCreds::hasWifiCredentials() const { return !wifiSsid().empty(); }

void NvsCreds::setWifiSsid(const std::string& ssid) { writeString(NS_WIFI, K_SSID, ssid); }
void NvsCreds::setWifiPsk(const std::string& psk)   { writeString(NS_WIFI, K_PSK, psk); }

void NvsCreds::clearWifi() {
    Preferences p;
    if (!p.begin(NS_WIFI, false)) return;
    p.clear();
    p.end();
}
  • Step 3: Run native test, expect green

Run: cd gateway_firmware && pio test -e native_nvs_creds
Expected: PASS 4/4.

  • Step 4: Run firmware build to ensure no link breakage

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds (module compiled but not yet linked into runtime).

  • Step 5: Commit
git add gateway_firmware/src/provisioning/nvs_creds.h gateway_firmware/src/provisioning/nvs_creds.cpp
git commit -m "feat(gateway): NvsCreds module backing creds/wifi NVS namespaces"

Task 5: WiFiClientSecure mock for host tests

Files:

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

  • Step 1: Write the mock

#pragma once
#include "WiFiClient.h"
#include <string>

class WiFiClientSecure : public WiFiClient {
public:
    void setCACert(const char* pem) { _caCert = pem ? pem : ""; }
    void setInsecure() { _insecure = true; }
    const std::string& caCertForTest() const { return _caCert; }
    bool insecureForTest() const { return _insecure; }

private:
    std::string _caCert;
    bool _insecure = false;
};
  • Step 2: Commit
git add gateway_firmware/test/mocks/WiFiClientSecure.h
git commit -m "test(gateway): add WiFiClientSecure host mock"

Task 6: Failing test — MqttClient must use TLS + CA cert

Files:

  • Modify: gateway_firmware/test/test_mqtt_client/test_mqtt_client.cpp

  • Step 1: Add a new test case

Open gateway_firmware/test/test_mqtt_client/test_mqtt_client.cpp and add (at the bottom, before main):

void test_begin_pins_ca_cert_and_uses_port_8883() {
    extern const char TB_CA_PEM[];  // defined in src/mqtt/tb_ca.h
    MqttClient c;
    c.setCACert(TB_CA_PEM);
    c.begin("api.hmpiot.com", 8883);
    TEST_ASSERT_EQUAL_STRING(TB_CA_PEM, c.tlsCaCertForTest());
    TEST_ASSERT_EQUAL_UINT16(8883, c.brokerPortForTest());
    TEST_ASSERT_FALSE(c.tlsInsecureForTest());
}

In the main function RUN_TEST block, add:

RUN_TEST(test_begin_pins_ca_cert_and_uses_port_8883);
  • Step 2: Run, confirm failure

Run: cd gateway_firmware && pio test -e native_mqtt
Expected: FAIL — tb_ca.h missing or MqttClient lacks setCACert/tlsCaCertForTest.

  • Step 3: Commit failing test
git add gateway_firmware/test/test_mqtt_client/test_mqtt_client.cpp
git commit -m "test(gateway): mqtt_client must pin CA cert and use 8883"

Task 7: TB CA cert header + MqttClient TLS swap

Files:

  • Create: gateway_firmware/src/mqtt/tb_ca.h

  • Modify: gateway_firmware/src/mqtt/mqtt_client.h

  • Modify: gateway_firmware/src/mqtt/mqtt_client.cpp

  • Step 1: Add CA cert header

Create gateway_firmware/src/mqtt/tb_ca.h with a placeholder cert (the operator replaces this with the real TB server CA before flashing production):

#pragma once

// ThingsBoard server CA chain (PEM, concatenated). Replace this content with
// the actual CA(s) for your TB deployment before production flash. Keep the
// const char name `TB_CA_PEM` stable — it's referenced by mqtt_client.cpp.
inline constexpr const char TB_CA_PEM[] =
    "-----BEGIN CERTIFICATE-----\n"
    "REPLACE_WITH_THINGSBOARD_CA_PEM\n"
    "-----END CERTIFICATE-----\n";
  • Step 2: Update mqtt_client.h

Replace #include <WiFiClient.h> with #include <WiFiClientSecure.h>. Change the _wifiClient member type. Add the new public/test hooks:

// near top
#include <WiFiClientSecure.h>

// in public section (after setLwt):
void setCACert(const char* pem);

// add for tests:
const char* tlsCaCertForTest() const { return _caCertPem ? _caCertPem : ""; }
uint16_t    brokerPortForTest() const { return _port; }
bool        tlsInsecureForTest() const { return _caCertPem == nullptr; }

// in private members:
WiFiClientSecure _wifiClient;
const char*      _caCertPem = nullptr;
uint16_t         _port = 0;

Remove the old WiFiClient _wifiClient; line.

  • Step 3: Update mqtt_client.cpp

In begin(), store the port and apply the CA cert before opening the socket:

void MqttClient::begin(const char* broker, uint16_t port) {
    if (_caCertPem) {
        _wifiClient.setCACert(_caCertPem);
    }
    _pubSub.setServer(broker, port);
    _pubSub.setSocketTimeout(MQTT_SOCKET_TIMEOUT_S);
    _wifiClient.setTimeout(MQTT_SOCKET_TIMEOUT_S);
    _failureCount = 0;
    _backoffMs = kMinBackoffMs;
    _state = DISCONNECTED;
    _pendingConnect = true;
    _port = port;
}

void MqttClient::setCACert(const char* pem) {
    _caCertPem = pem;
}

(The constructor _pubSub(_wifiClient) keeps compiling because WiFiClientSecure derives from WiFiClient in both real and mock headers.)

  • Step 4: Run the MQTT test suite

Run: cd gateway_firmware && pio test -e native_mqtt
Expected: PASS — all prior tests still green + new TLS test green.

  • Step 5: Run firmware build

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

  • Step 6: Commit
git add gateway_firmware/src/mqtt/tb_ca.h gateway_firmware/src/mqtt/mqtt_client.h gateway_firmware/src/mqtt/mqtt_client.cpp
git commit -m "feat(gateway): MQTT TLS via WiFiClientSecure + pinned CA cert"

Task 8: Load MQTT credentials from NVS in mqtt_manager

Files:

  • Modify: gateway_firmware/src/mqtt_manager.cpp

  • Modify: gateway_firmware/src/config.h

  • Step 1: Replace hardcoded credentials in mqtt_manager.cpp

Replace the body of initMQTT() with:

#include "provisioning/nvs_creds.h"
#include "mqtt/tb_ca.h"

void initMQTT() {
    static NvsCreds creds;
    const std::string host = !creds.mqttHost().empty() ? creds.mqttHost() : std::string(MQTT_SERVER);
    const uint16_t   port = creds.mqttPort() != 0 ? creds.mqttPort() : static_cast<uint16_t>(MQTT_PORT);
    static std::string tokenStorage = creds.mqttToken();

    mqttClient.setCACert(TB_CA_PEM);
    mqttClient.begin(host.c_str(), port);
    if (!tokenStorage.empty()) {
        mqttClient.setCredentials(tokenStorage.c_str(), "");
    }
    mqttClient.setLwt(MQTT_LWT_TOPIC, MQTT_LWT_PAYLOAD_OFFLINE, MQTT_LWT_QOS, MQTT_LWT_RETAIN);
    mqttClient.onConnected([]() {
        if (!mqttClient.publish(MQTT_LWT_TOPIC, MQTT_ONLINE_PAYLOAD, 0, true)) {
            Log.warningln("MQTT publish online failed (packet too large?)");
        }
        publishFullStateTelemetry();
    });
}
  • Step 2: Make MQTT_SERVER/MQTT_PORT runtime defaults only

Open gateway_firmware/src/config.h. Replace:

#define MQTT_SERVER "api.hmpiot.com"
#define MQTT_PORT 1883

with:

// Runtime defaults — overridden by NVS `creds` namespace if populated.
// Port now defaults to 8883 (TLS); a device with no NVS host falls back here.
#ifndef MQTT_SERVER
#define MQTT_SERVER "api.hmpiot.com"
#endif
#ifndef MQTT_PORT
#define MQTT_PORT 8883
#endif
  • Step 3: Remove MQTT_USER/MQTT_PASSWORD consumption

mqtt_manager.cpp no longer references MQTT_USER/MQTT_PASSWORD (Step 1 dropped that line). Search and confirm:

Run: cd gateway_firmware && grep -rn "MQTT_USER\|MQTT_PASSWORD" src/
Expected: no matches in src/.

  • Step 4: Run the telemetry env (covers mqtt_manager)

Run: cd gateway_firmware && pio test -e native_telemetry
Expected: PASS.

  • Step 5: Run firmware build

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds. If secrets.h still defines MQTT_USER/MQTT_PASSWORD, that’s fine — they’re unused.

  • Step 6: Commit
git add gateway_firmware/src/mqtt_manager.cpp gateway_firmware/src/config.h
git commit -m "feat(gateway): load MQTT creds + host from NVS at boot"

Task 9: IotWebConf mock for host tests

Files:

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

The real IotWebConf library binds to a captive portal web server. For host tests we only model the state transitions we care about.

  • Step 1: Write the mock
#pragma once
#include <functional>
#include <string>

class IotWebConf {
public:
    void init() { _started = true; _portalActive = true; }
    void doLoop() {}
    bool isPortalActiveForTest() const { return _portalActive; }
    void simulateSaveForTest(const std::string& ssid, const std::string& psk) {
        _ssid = ssid; _psk = psk;
        if (_onSaveCb) _onSaveCb(ssid, psk);
        _portalActive = false;
    }
    void onSaveCallback(std::function<void(const std::string&, const std::string&)> cb) {
        _onSaveCb = std::move(cb);
    }
    void stop() { _portalActive = false; }

private:
    bool _started = false;
    bool _portalActive = false;
    std::string _ssid;
    std::string _psk;
    std::function<void(const std::string&, const std::string&)> _onSaveCb;
};
  • Step 2: Commit
git add gateway_firmware/test/mocks/IotWebConf.h
git commit -m "test(gateway): add IotWebConf host mock"

Task 10: Failing tests for WifiProvisioner

Files:

  • Create: gateway_firmware/test/native_provisioning/test_wifi_provisioner.cpp

  • Modify: gateway_firmware/platformio.ini

  • Step 1: Add native env

Append to gateway_firmware/platformio.ini:

[env:native_provisioning]
platform = native
test_framework = unity
test_build_src = yes
build_flags =
	-std=gnu++17
	-I test/mocks
	-I src
build_src_filter = -<*> +<provisioning/nvs_creds.cpp> +<provisioning/wifi_provisioner.cpp>
test_filter = test_wifi_provisioner
  • Step 2: Write the failing test file

Create gateway_firmware/test/native_provisioning/test_wifi_provisioner.cpp:

#include <unity.h>
#include "Preferences.h"
#include "provisioning/nvs_creds.h"
#include "provisioning/wifi_provisioner.h"

void setUp() { Preferences::resetAllForTest(); }
void tearDown() {}

void test_empty_nvs_enters_ap_mode() {
    WifiProvisioner p;
    p.begin();
    TEST_ASSERT_EQUAL(WifiProvisioner::Mode::AccessPoint, p.mode());
}

void test_existing_nvs_attempts_station() {
    { NvsCreds c; c.setWifiSsid("home"); c.setWifiPsk("password"); }
    WifiProvisioner p;
    p.begin();
    TEST_ASSERT_EQUAL(WifiProvisioner::Mode::Station, p.mode());
    TEST_ASSERT_EQUAL_STRING("home", p.attemptedSsidForTest().c_str());
}

void test_three_consecutive_failures_switch_to_ap() {
    { NvsCreds c; c.setWifiSsid("home"); c.setWifiPsk("password"); }
    WifiProvisioner p;
    p.begin();
    p.reportConnectFailure();
    p.reportConnectFailure();
    p.reportConnectFailure();
    TEST_ASSERT_EQUAL(WifiProvisioner::Mode::AccessPoint, p.mode());
}

void test_portal_save_writes_nvs_and_signals_reboot() {
    WifiProvisioner p;
    p.begin();  // empty NVS → AP mode
    p.simulatePortalSaveForTest("new-ssid", "new-psk");
    NvsCreds c;
    TEST_ASSERT_EQUAL_STRING("new-ssid", c.wifiSsid().c_str());
    TEST_ASSERT_EQUAL_STRING("new-psk", c.wifiPsk().c_str());
    TEST_ASSERT_TRUE(p.rebootPending());
}

void test_factory_reset_clears_wifi_keeps_creds() {
    { NvsCreds c; c.setWifiSsid("s"); c.setWifiPsk("p"); c.setMqttToken("tok"); }
    WifiProvisioner p;
    p.factoryResetWifi();
    NvsCreds c;
    TEST_ASSERT_FALSE(c.hasWifiCredentials());
    TEST_ASSERT_TRUE(c.hasMqttToken());
    TEST_ASSERT_TRUE(p.rebootPending());
}

int main() {
    UNITY_BEGIN();
    RUN_TEST(test_empty_nvs_enters_ap_mode);
    RUN_TEST(test_existing_nvs_attempts_station);
    RUN_TEST(test_three_consecutive_failures_switch_to_ap);
    RUN_TEST(test_portal_save_writes_nvs_and_signals_reboot);
    RUN_TEST(test_factory_reset_clears_wifi_keeps_creds);
    return UNITY_END();
}
  • Step 3: Run, confirm failure

Run: cd gateway_firmware && pio test -e native_provisioning
Expected: FAIL — wifi_provisioner.h: No such file.

  • Step 4: Commit
git add gateway_firmware/test/native_provisioning/test_wifi_provisioner.cpp gateway_firmware/platformio.ini
git commit -m "test(gateway): failing tests for WifiProvisioner boot FSM"

Task 11: Implement WifiProvisioner

Files:

  • Create: gateway_firmware/src/provisioning/wifi_provisioner.h

  • Create: gateway_firmware/src/provisioning/wifi_provisioner.cpp

  • Step 1: Write the header

// gateway_firmware/src/provisioning/wifi_provisioner.h
#pragma once
#include <string>
#include <cstdint>

class WifiProvisioner {
public:
    enum class Mode { Idle, Station, AccessPoint };

    void begin();
    void tick();

    Mode mode() const { return _mode; }
    bool rebootPending() const { return _rebootPending; }

    void reportConnectSuccess();
    void reportConnectFailure();
    void factoryResetWifi();

    // Test-only hooks.
    const std::string& attemptedSsidForTest() const { return _attemptedSsid; }
    void simulatePortalSaveForTest(const std::string& ssid, const std::string& psk);

private:
    Mode _mode = Mode::Idle;
    uint8_t _consecutiveFailures = 0;
    static constexpr uint8_t kFailureThreshold = 3;
    bool _rebootPending = false;
    std::string _attemptedSsid;

    void enterStation(const std::string& ssid);
    void enterAp();
};
  • Step 2: Write the implementation
// gateway_firmware/src/provisioning/wifi_provisioner.cpp
#include "wifi_provisioner.h"
#include "nvs_creds.h"

void WifiProvisioner::begin() {
    NvsCreds creds;
    if (creds.hasWifiCredentials()) {
        enterStation(creds.wifiSsid());
    } else {
        enterAp();
    }
}

void WifiProvisioner::tick() {
    // Hardware-side scheduling lives in main.cpp; this class is a pure FSM.
}

void WifiProvisioner::reportConnectSuccess() {
    _consecutiveFailures = 0;
}

void WifiProvisioner::reportConnectFailure() {
    if (_consecutiveFailures < UINT8_MAX) {
        ++_consecutiveFailures;
    }
    if (_consecutiveFailures >= kFailureThreshold) {
        enterAp();
    }
}

void WifiProvisioner::factoryResetWifi() {
    NvsCreds creds;
    creds.clearWifi();
    _rebootPending = true;
}

void WifiProvisioner::simulatePortalSaveForTest(const std::string& ssid, const std::string& psk) {
    NvsCreds creds;
    creds.setWifiSsid(ssid);
    creds.setWifiPsk(psk);
    _rebootPending = true;
}

void WifiProvisioner::enterStation(const std::string& ssid) {
    _mode = Mode::Station;
    _attemptedSsid = ssid;
    _consecutiveFailures = 0;
}

void WifiProvisioner::enterAp() {
    _mode = Mode::AccessPoint;
    _consecutiveFailures = 0;
}
  • Step 3: Run native test

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

  • Step 4: Commit
git add gateway_firmware/src/provisioning/wifi_provisioner.h gateway_firmware/src/provisioning/wifi_provisioner.cpp
git commit -m "feat(gateway): WifiProvisioner FSM (station / AP / factory-reset)"

Task 12: Wire WifiProvisioner into main.cpp

Files:

  • Modify: gateway_firmware/src/main.cpp

This task only wires the FSM. The actual IotWebConf HTTP server runs only on-device; we don’t have host-test coverage for that, so we keep the on-device portal launch behind #ifdef ARDUINO.

  • Step 1: Add include + global

In main.cpp, near the other includes:

#include "provisioning/nvs_creds.h"
#include "provisioning/wifi_provisioner.h"
#ifdef ARDUINO
#include <IotWebConf.h>
#endif

Below the existing globals:

WifiProvisioner wifiProvisioner;
  • Step 2: Replace the hardcoded WiFi call

In setup(), replace:

wifiLink.begin(WIFI_SSID, WIFI_PASSWORD);

with:

wifiProvisioner.begin();
if (wifiProvisioner.mode() == WifiProvisioner::Mode::Station) {
    NvsCreds creds;
    static std::string ssidStorage = creds.wifiSsid();
    static std::string pskStorage  = creds.wifiPsk();
    wifiLink.begin(ssidStorage.c_str(), pskStorage.c_str());
} else {
    // AP mode: skip station-mode init. The on-device portal starts here.
#ifdef ARDUINO
    static DNSServer dns;
    static WebServer web(80);
    static IotWebConf iwc("HMP-GW", &dns, &web, "configme");
    iwc.init();
    iwc.onConfigSaved([]() {
        // IotWebConf persists creds internally; mirror to our NVS namespace.
        // Replace with the actual IotWebConf parameter accessors.
        NvsCreds c;
        c.setWifiSsid(iwc.getThingName());  // placeholder field — see Step 3
        // Real wiring of SSID/PSK parameters happens in Step 3 below.
        wifiProvisioner.simulatePortalSaveForTest(c.wifiSsid(), c.wifiPsk());
        ESP.restart();
    });
#endif
}
  • Step 3: Wire IotWebConf SSID/PSK parameters

Inside the #ifdef ARDUINO block above, replace the placeholder section with the real IotWebConf parameter setup. Locate the iwc.init() call and replace the block with:

#ifdef ARDUINO
    static DNSServer dns;
    static WebServer web(80);
    static IotWebConf iwc("HMP-GW", &dns, &web, "configme");
    // IotWebConf provides built-in WiFi-credential parameters. Reading them
    // after save: iwc.getWifiSsid() / iwc.getWifiPassword().
    iwc.init();
    iwc.setConfigSavedCallback([]() {
        NvsCreds c;
        c.setWifiSsid(iwc.getWifiSsidParameter()->valueBuffer);
        c.setWifiPsk(iwc.getWifiPasswordParameter()->valueBuffer);
        ESP.restart();
    });
#endif

(If the running IotWebConf version exposes different accessor names, adjust at compile time — the host test only verifies the FSM, not the portal binding.)

  • Step 4: Add a 10-minute idle reboot timer in AP mode

Still in setup(), after the AP-mode #ifdef ARDUINO block:

if (wifiProvisioner.mode() == WifiProvisioner::Mode::AccessPoint) {
    Log.warningln("entered AP mode; rebooting in 10 minutes if no config saved");
}

In loop(), before the existing delay(10);:

#ifdef ARDUINO
    static const uint32_t kApIdleRebootMs = 10UL * 60UL * 1000UL;
    static uint32_t apEntryMs = millis();
    if (wifiProvisioner.mode() == WifiProvisioner::Mode::AccessPoint &&
        millis() - apEntryMs > kApIdleRebootMs) {
        Log.warningln("AP idle timeout, rebooting");
        ESP.restart();
    }
#endif
  • Step 5: Run the boot_init env

Run: cd gateway_firmware && pio test -e native_boot_init
Expected: PASS (boot-init test does not exercise station/AP branching; we just ensure no regression).

  • Step 6: Run firmware build

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds. If IotWebConf accessor names differ from Step 3, fix them now using the library header in .pio/libdeps/esp32doit-devkit-v1/IotWebConf2/src/IotWebConf.h.

  • Step 7: Commit
git add gateway_firmware/src/main.cpp
git commit -m "feat(gateway): boot flow uses WifiProvisioner + AP portal"

Task 13: factory_flash.py — per-device NVS image generator

Files:

  • Create: gateway_firmware/tools/factory_flash.py
  • Create: gateway_firmware/tools/README.md

This script wraps nvs_partition_gen.py (shipped with ESP-IDF) and esptool.py. It is run once per device at factory time.

  • Step 1: Write the script

Create gateway_firmware/tools/factory_flash.py:

#!/usr/bin/env python3
"""Per-device factory flash helper.

Generates an NVS partition image populated with this device's MQTT token,
device id, host, port, and (optionally) default WiFi credentials, then flashes
the firmware + NVS image via esptool.
"""

import argparse
import csv
import os
import subprocess
import sys
import tempfile
from pathlib import Path


NVS_GEN = "nvs_partition_gen.py"  # ship with ESP-IDF or pip install esp-idf-nvs-partition-gen
ESPTOOL = "esptool.py"

# Offsets must match partitions_two_ota.csv.
CREDS_OFFSET = 0x4A1000
CREDS_SIZE   = 0x5000
WIFI_NS      = "wifi"
CREDS_NS     = "creds"


def build_nvs_csv(out: Path, token: str, device_id: str, host: str, port: int,
                  default_ssid: str | None, default_psk: str | None):
    rows = [
        ["key", "type", "encoding", "value"],
        [CREDS_NS, "namespace", "", ""],
        ["mqtt_token", "data", "string", token],
        ["device_id", "data", "string", device_id],
        ["mqtt_host", "data", "string", host],
        ["mqtt_port", "data", "u32", str(port)],
    ]
    if default_ssid is not None:
        rows.append([WIFI_NS, "namespace", "", ""])
        rows.append(["ssid", "data", "string", default_ssid])
        rows.append(["psk", "data", "string", default_psk or ""])
    with out.open("w", newline="") as f:
        csv.writer(f).writerows(rows)


def generate_nvs_bin(csv_path: Path, out_bin: Path):
    subprocess.run([
        sys.executable, "-m", "esp_idf_nvs_partition_gen.nvs_partition_gen",
        "generate", str(csv_path), str(out_bin), hex(CREDS_SIZE)
    ], check=True)


def flash(firmware: Path, nvs_bin: Path, port: str):
    subprocess.run([
        ESPTOOL, "--chip", "esp32", "--port", port, "--baud", "921600",
        "write_flash",
        "0x10000", str(firmware),                # factory partition
        hex(CREDS_OFFSET), str(nvs_bin),
    ], check=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--token", required=True, help="ThingsBoard access token")
    ap.add_argument("--device-id", required=True)
    ap.add_argument("--host", default="api.hmpiot.com")
    ap.add_argument("--port", type=int, default=8883)
    ap.add_argument("--default-ssid", default=None,
                    help="Optional default WiFi SSID; omit to leave AP-mode-only")
    ap.add_argument("--default-psk", default=None)
    ap.add_argument("--firmware", default=".pio/build/esp32doit-devkit-v1/firmware.bin")
    ap.add_argument("--serial-port", required=True,
                    help="e.g. /dev/ttyUSB0 or COM5")
    args = ap.parse_args()

    fw = Path(args.firmware)
    if not fw.exists():
        sys.exit(f"firmware not found: {fw}; run `pio run -e esp32doit-devkit-v1` first")

    with tempfile.TemporaryDirectory() as td:
        csv_path = Path(td) / "creds.csv"
        nvs_bin  = Path(td) / "creds.bin"
        build_nvs_csv(csv_path, args.token, args.device_id, args.host, args.port,
                      args.default_ssid, args.default_psk)
        generate_nvs_bin(csv_path, nvs_bin)
        flash(fw, nvs_bin, args.serial_port)
    print(f"flashed device {args.device_id} on {args.serial_port}")


if __name__ == "__main__":
    main()
  • Step 2: Document the workflow

Create gateway_firmware/tools/README.md:

# Factory-flash workflow

`factory_flash.py` programs one device with its per-device MQTT identity.

## Prerequisites

- `pip install esp-idf-nvs-partition-gen esptool`
- Built firmware: `pio run -e esp32doit-devkit-v1`

## Per-device flash

```bash
python tools/factory_flash.py \
  --token "<TB_ACCESS_TOKEN>" \
  --device-id "gw-00001" \
  --serial-port /dev/ttyUSB0 \
  --default-ssid "HMP-Default" \
  --default-psk "default-pass"

Batch from CSV

Feed a CSV of device_id,token rows into a shell loop:

while IFS=, read -r id token; do
  python tools/factory_flash.py --device-id "$id" --token "$token" --serial-port /dev/ttyUSB0
done < devices.csv

After flash, monitor the serial port to confirm MQTT connected within 60 s.


- [ ] **Step 3: Smoke-test help output**

Run: `cd gateway_firmware && python3 tools/factory_flash.py --help`
Expected: help text printed; non-zero exit code only if Python missing.

- [ ] **Step 4: Commit**

```bash
git add gateway_firmware/tools/factory_flash.py gateway_firmware/tools/README.md
git commit -m "feat(gateway): factory_flash.py for per-device NVS provisioning"

Task 14: Clean up secrets.h references

Files:

  • Modify: gateway_firmware/include/secrets.h.example

  • Step 1: Drop MQTT fields from the example template

Open gateway_firmware/include/secrets.h.example. Remove these lines (if present):

#define MQTT_USER "..."
#define MQTT_PASSWORD "..."

Keep WIFI_SSID/WIFI_PASSWORD as compile-time fallbacks for the Wokwi env (they’re consumed only by [env:wokwi] build flags).

  • Step 2: Add a comment explaining the change

At the top of secrets.h.example:

// MQTT credentials moved to NVS (see tools/factory_flash.py).
// secrets.h now only holds dev-only fallbacks for Wokwi.
  • Step 3: Verify firmware build still passes

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

  • Step 4: Commit
git add gateway_firmware/include/secrets.h.example
git commit -m "chore(gateway): drop MQTT_USER/PASSWORD from secrets template"

Task 15: Documentation update

Files:

  • Modify: gateway_firmware/AGENTS.md

  • Step 1: Update the “Known Issues” section

Open gateway_firmware/AGENTS.md. Replace the existing line:

- No OTA firmware update

(in the Known Issues block, around the bottom) — leave the OTA bullet for now (handled in Plan 3) but add above it:

- MQTT credentials and WiFi SSID/PSK now live in NVS (`creds`/`wifi` namespaces).
  Use `tools/factory_flash.py` for per-device provisioning. AP-mode portal
  ("HMP-GW-<MAC4>") is the field-installer override path.
- MQTT runs over TLS on port 8883 with a pinned ThingsBoard CA
  (`src/mqtt/tb_ca.h`). Replace the placeholder PEM before production flash.
  • Step 2: Commit
git add gateway_firmware/AGENTS.md
git commit -m "docs(gateway): describe NVS provisioning + MQTT TLS in AGENTS.md"

Task 16: Final verification gate

  • Step 1: Run every native test environment
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_nvs_creds
pio test -e native_provisioning

Expected: every env reports PASS.

  • Step 2: Run firmware build

Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: build succeeds; partition table prints partitions_two_ota.csv.

  • Step 3: Bench smoke test

Flash a single device using tools/factory_flash.py with a real test token. On boot, serial monitor must show:

  1. Logger initialized
  2. MQTT client id: hmpiot-gw-<MAC>
  3. MQTT connected within 60 s (assumes valid WiFi + reachable TB broker)

If WiFi NVS is empty, monitor must show entered AP mode instead, and SSID HMP-GW-<MAC4> must appear on a nearby device’s WiFi scan.

  • Step 4: Tag the plan as complete
git tag gateway-p0-plan1-complete

Spec coverage check

Spec section Tasks
§3.1 Partition CSV T1
§3.2 platformio.ini partition wiring T1
§3.3 NVS namespace layout T3, T4, T13
§3.4 Factory-flash workflow T13
§4.1 Modules (nvs_creds, tb_ca, mqtt_client TLS swap, mqtt_manager NVS read) T3, T4, T6, T7, T8
§4.2 TLS behavior (TB token as user, no setInsecure) T7, T8
§4.3 Threat-model coverage covered by §4.1 implementation
§4.4 Tests (native_mqtt extension, native_nvs_creds) T3, T6
§5.1 WifiProvisioner module T10, T11
§5.2 Boot decision flow T11, T12
§5.3 AP-mode triggers (empty NVS, 3 fails, factory reset) T10, T11
§5.4 native_provisioning tests T10

No P1 / P2 / P5 spec content is in scope — those land in Plans 2–4.