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 WiFiClient → WiFiClientSecure; 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 -Aorgit add .. Stage specific paths. secrets.his gradually drained — by end of plan it should not containMQTT_USER/MQTT_PASSWORDanymore.
Task 1: Add dual-OTA partition table
Files:
Create:
gateway_firmware/partitions_two_ota.csvModify:
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.cppModify:
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.hCreate:
gateway_firmware/src/provisioning/nvs_creds.cppStep 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.hStep 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.cppStep 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.hModify:
gateway_firmware/src/mqtt/mqtt_client.hModify:
gateway_firmware/src/mqtt/mqtt_client.cppStep 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.cppModify:
gateway_firmware/src/config.hStep 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_PORTruntime 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_PASSWORDconsumption
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.cppModify:
gateway_firmware/platformio.iniStep 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.hCreate:
gateway_firmware/src/provisioning/wifi_provisioner.cppStep 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.exampleStep 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.mdStep 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:
Logger initializedMQTT client id: hmpiot-gw-<MAC>MQTT connectedwithin 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.