Gateway RPC: checkHealth, readGPIO, readAllGPIO — Implementation Plan
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.
Goal: Add three synchronous RPC commands (checkHealth, readGPIO, readAllGPIO) to the ESP32 gateway so ThingsBoard can probe health and read PCF8574 relay pin state on demand without going through the async command worker. Also accept writeI2C as an alias for the existing setGPIO write so the server can use the finalized method name without breaking older clients.
Architecture: Handle each new method inline in RpcServer::onMessage. Inject a Pcf8574Driver* into RpcServer for synchronous reads. Use a new sendJsonResponse(requestTopic, bodyJson) helper that publishes raw JSON to the /response/ topic — the existing {pin,value,changed,queued,error} envelope stays exclusive to relay-write methods (setGPIO/writeI2C). Read failures must not flip IoState::ioBoardOnline. writeI2C is implemented as a pure alias inside mapMethodToType — same CMD_TYPE_SET_RELAY path, same validation, same feedback telemetry label (setGPIO stays the canonical label emitted by feedback_publisher.cpp). Tests run under the existing native_rpc PlatformIO env with a fake PCF reader.
Source drift note (2026-05-13 refresh): Since this plan was first written, RpcServer::toResponseTopic was refactored from String toResponseTopic(const char*) to a buffer-based bool toResponseTopic(const char* requestTopic, char* out, size_t outSize). Task 4 has been rewritten to match. test/test_rpc_server/test_main.cpp already defines WiFiClientState g_wifi_client_state; (between Log and g_pubsub_state declarations); Task 5 instructions account for that. The recent perf(gateway): ... commits hardened MQTT/Modbus paths but did NOT touch any structure this plan depends on.
Tech Stack: C++17, Arduino framework, ArduinoJson, PlatformIO, Unity test framework, ESP32, PCF8574 I²C driver.
Spec: docs/superpowers/specs/2026-05-13-gateway-rpc-health-gpio-read-design.md
File Structure
Production files:
gateway_firmware/src/config.h— addFW_VERSIONconstant.gateway_firmware/src/io/pcf8574.h— add tinyIPcf8574Readerinterface; makePcf8574Driverimplement it (virtualreadPort). Keeps the production class concrete but injectable.gateway_firmware/src/mqtt/rpc_server.h— extend constructor withIPcf8574Reader* pcf; add private method declarations.gateway_firmware/src/mqtt/rpc_server.cpp— dispatch new methods; addhandleCheckHealth,handleReadGpio,handleReadAllGpio,sendJsonResponse.gateway_firmware/src/main.cpp— pass&pcf8574()into theRpcServerconstructor.gateway_firmware/rpc_commands.md— document the three new methods plus error table.
Test files:
gateway_firmware/test/test_rpc_server/test_main.cpp— add aFakePcf8574Reader, register the new tests, defineg_wifi_state/g_esp_state/WiFi/ESPmock globals.gateway_firmware/platformio.ini— update thenative_rpcandnative_rpc_water_levelenvs so they can link theWiFi/ESPmock symbols (they are header-only, so no source filter change is needed beyond confirming the test_main.cpp defines them).
Task 1: Add FW_VERSION constant to config
Files:
Modify:
gateway_firmware/src/config.hStep 1: Add the constant near the top of config.h
Open gateway_firmware/src/config.h. Right after the #include "secrets.h" line, add:
// Firmware version reported by checkHealth RPC.
#ifndef FW_VERSION
#define FW_VERSION "1.4.0"
#endif
- Step 2: Verify it still compiles by building one of the unaffected native envs
Run: cd gateway_firmware && pio test -e native_command_types
Expected: existing tests pass.
- Step 3: Commit
git add gateway_firmware/src/config.h
git commit -m "feat(gateway): add FW_VERSION config constant for checkHealth RPC"
Task 2: Introduce IPcf8574Reader interface
Files:
- Modify:
gateway_firmware/src/io/pcf8574.h
We need an abstraction for readPort so the RPC server can be tested with a fake. The smallest change: add an IPcf8574Reader interface with a single virtual method, and make Pcf8574Driver implement it. No production callers change.
- Step 1: Add the interface and inheritance in
pcf8574.h
Replace the entire contents of gateway_firmware/src/io/pcf8574.h (currently 30 lines: the existing include guard, Pcf8574Driver class, and the pcf8574() accessor) so that the file reads:
#ifndef IO_PCF8574_H
#define IO_PCF8574_H
#include <stdint.h>
#include "config.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
class IPcf8574Reader {
public:
virtual ~IPcf8574Reader() = default;
virtual bool readPort(uint8_t& value) = 0;
};
class Pcf8574Driver : public IPcf8574Reader {
public:
void attachMutex(SemaphoreHandle_t mutex) { _mutex = mutex; }
void begin();
bool initialize();
bool readPort(uint8_t& value) override;
bool writePort(uint8_t value);
bool setBit(uint8_t bitIndex, bool logicalOn, bool activeLow, uint8_t& newPortValue);
uint8_t shadowPort() const { return _shadowPort; }
private:
SemaphoreHandle_t _mutex = nullptr;
uint8_t _shadowPort = PCF8574_INITIAL_OUTPUT_MASK;
bool lock();
void unlock();
};
Pcf8574Driver& pcf8574();
#endif
- Step 2: Verify nothing breaks by building the firmware-only PCF tests
Run: cd gateway_firmware && pio test -e native_pcf8574_relay
Expected: existing tests pass.
- Step 3: Commit
git add gateway_firmware/src/io/pcf8574.h
git commit -m "refactor(gateway): extract IPcf8574Reader interface from Pcf8574Driver"
Task 3: Extend RpcServer constructor and header
Files:
- Modify:
gateway_firmware/src/mqtt/rpc_server.h - Modify:
gateway_firmware/src/mqtt/rpc_server.cpp - Modify:
gateway_firmware/src/main.cpp
This task only widens the interface and stores the new dependency — it does NOT yet handle any new method. Existing tests must continue to pass.
- Step 1: Update
rpc_server.h— add include, constructor param, member, and method decls
In gateway_firmware/src/mqtt/rpc_server.h, add #include "io/pcf8574.h" to the includes block (right after #include "mqtt/mqtt_client.h").
Replace the constructor declaration (currently lines 15-18) with:
RpcServer(QueueHandle_t commandQueue,
MqttClient& mqttClient,
IoState* ioState = nullptr,
CommandHistory* commandHistory = nullptr,
IPcf8574Reader* pcf = nullptr);
Add these private method declarations after String toResponseTopic(...):
void handleCheckHealth(const char* requestTopic);
void handleReadGpio(const char* requestTopic, JsonVariantConst params);
void handleReadAllGpio(const char* requestTopic);
void sendJsonResponse(const char* requestTopic, const char* bodyJson);
Add this private member next to the other pointer members:
IPcf8574Reader* _pcf;
- Step 2: Update
rpc_server.cppconstructor to accept and store the new dependency
In gateway_firmware/src/mqtt/rpc_server.cpp, replace the constructor (currently lines 12-19) with:
RpcServer::RpcServer(QueueHandle_t commandQueue,
MqttClient& mqttClient,
IoState* ioState,
CommandHistory* commandHistory,
IPcf8574Reader* pcf)
: _commandQueue(commandQueue),
_mqttClient(mqttClient),
_ioState(ioState),
_commandHistory(commandHistory),
_pcf(pcf) {}
- Step 3: Update
main.cppto pass the real PCF driver
In gateway_firmware/src/main.cpp, replace the line that constructs rpcServer (currently line 65):
static RpcServer rpcServer(commandQueue, mqttClient, &ioState, &commandHistory, &pcf8574());
- Step 4: Run existing RPC tests to confirm we did not break anything
Run: cd gateway_firmware && pio test -e native_rpc
Expected: all existing tests in test_rpc_server still pass (they pass nullptr/omit the last param implicitly via default).
- Step 5: Commit
git add gateway_firmware/src/mqtt/rpc_server.h gateway_firmware/src/mqtt/rpc_server.cpp gateway_firmware/src/main.cpp
git commit -m "refactor(gateway): inject Pcf8574 reader into RpcServer"
Task 4: Add sendJsonResponse helper
Files:
- Modify:
gateway_firmware/src/mqtt/rpc_server.cpp
This helper publishes raw JSON to the topic that mirrors the request, rewriting /request/ → /response/ (the same way the existing sendResponse does it). It is used by all three new handlers.
Note: RpcServer::toResponseTopic writes into a caller-provided buffer and returns bool (see rpc_server.h / rpc_server.cpp). Do not assume a String-returning overload.
- Step 1: Add the helper at the bottom of
rpc_server.cpp
Append to gateway_firmware/src/mqtt/rpc_server.cpp:
void RpcServer::sendJsonResponse(const char* requestTopic, const char* bodyJson) {
char topicBuffer[128];
if (!toResponseTopic(requestTopic, topicBuffer, sizeof(topicBuffer))) {
Log.warningln("rpc response topic truncated");
return;
}
if (!_mqttClient.publish(topicBuffer, bodyJson)) {
Log.warningln("MQTT publish rpc response failed");
}
}
- Step 2: Build to confirm compilation
Run: cd gateway_firmware && pio test -e native_rpc
Expected: all existing tests pass; no warnings about unused private method (it is declared in the header).
- Step 3: Commit
git add gateway_firmware/src/mqtt/rpc_server.cpp
git commit -m "feat(gateway): add sendJsonResponse helper for read-only RPCs"
Task 5: Wire mock globals and FakePcf8574Reader into the rpc test env
Files:
- Modify:
gateway_firmware/test/test_rpc_server/test_main.cpp
The checkHealth handler will call WiFi.status(), WiFi.RSSI(), ESP.getFreeHeap(). Those mock globals are declared extern in test/mocks/{WiFi,Esp}.h and must be defined exactly once in the test binary. We also need a FakePcf8574Reader that returns programmable bytes / failure states.
- Step 1: Add mock includes and global definitions
In gateway_firmware/test/test_rpc_server/test_main.cpp, add these includes near the top of the file (after the existing #include "Arduino.h"):
#include "WiFi.h"
#include "Esp.h"
#include "io/pcf8574.h"
Below the existing ArduinoLogClass Log;, PubSubClientState g_pubsub_state;, and WiFiClientState g_wifi_client_state; lines (all three already present at the top of the file), add:
WiFiState g_wifi_state;
WiFiClass WiFi;
EspState g_esp_state;
EspClass ESP;
class FakePcf8574Reader : public IPcf8574Reader {
public:
bool readPort(uint8_t& value) override {
++read_calls;
if (force_fail) return false;
value = next_value;
return true;
}
uint8_t next_value = 0;
bool force_fail = false;
int read_calls = 0;
};
- Step 2: Reset mock state in
setUp
Replace the existing setUp function with:
void setUp() {
stubResetPubSub();
stubResetLog();
stubResetQueues();
stubSetMillis(100);
stubResetWiFi();
stubResetEsp();
}
- Step 3: Build the env to make sure the new globals link
Run: cd gateway_firmware && pio test -e native_rpc
Expected: existing tests still pass; no linker errors about duplicate or missing WiFi/ESP/g_wifi_state/g_esp_state.
- Step 4: Commit
git add gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "test(gateway): wire WiFi/ESP mock globals and FakePcf8574Reader into rpc tests"
Task 6 (TDD): checkHealth happy path
Files:
Test:
gateway_firmware/test/test_rpc_server/test_main.cppModify:
gateway_firmware/src/mqtt/rpc_server.cppStep 1: Write the failing test
Inside the #if PCF8574_MODE_VALUE == 1 block in test/test_rpc_server/test_main.cpp (alongside the existing setGPIO tests), add:
static void test_check_health_returns_expected_fields() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(true);
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
g_wifi_state.status_result = WL_CONNECTED;
g_wifi_state.rssi_result = -58;
g_esp_state.free_heap = 187432;
stubSetMillis(12345000);
dispatchMessage("v1/devices/me/rpc/request/h1",
"{\"method\":\"checkHealth\"}");
TEST_ASSERT_EQUAL_STRING("v1/devices/me/rpc/response/h1",
g_pubsub_state.last_pub_topic_copy.c_str());
const std::string& body = g_pubsub_state.last_pub_payload_copy;
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"ok\":true"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"uptimeSec\":12345"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"freeHeap\":187432"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"wifiConnected\":true"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"wifiRssi\":-58"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"mqttConnected\":true"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"ioBoardOnline\":true"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"fwVersion\":\"1.4.0\""));
}
Register it in the #if PCF8574_MODE_VALUE == 1 block of main():
RUN_TEST(test_check_health_returns_expected_fields);
- Step 2: Run the test — expect FAIL (method not yet implemented)
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: test_check_health_returns_expected_fields FAILS — the response carries "error":"unknown_method" because checkHealth is not yet mapped.
- Step 3: Implement
handleCheckHealthand dispatch it
In gateway_firmware/src/mqtt/rpc_server.cpp, add the includes at the top (after #include <ArduinoLog.h>):
#include <WiFi.h>
#include <Esp.h>
Add this handler at the bottom of the file:
void RpcServer::handleCheckHealth(const char* requestTopic) {
const bool wifiConnected = WiFi.status() == WL_CONNECTED;
const int wifiRssi = wifiConnected ? WiFi.RSSI() : 0;
const uint32_t uptimeSec = millis() / 1000;
const uint32_t freeHeap = ESP.getFreeHeap();
const bool ioOnline = _ioState != nullptr && _ioState->isIoBoardOnline();
char body[256];
const int n = snprintf(body, sizeof(body),
"{\"ok\":true,\"uptimeSec\":%lu,\"freeHeap\":%lu,"
"\"wifiConnected\":%s,\"wifiRssi\":%d,\"mqttConnected\":true,"
"\"ioBoardOnline\":%s,\"fwVersion\":\"%s\"}",
static_cast<unsigned long>(uptimeSec),
static_cast<unsigned long>(freeHeap),
wifiConnected ? "true" : "false",
wifiRssi,
ioOnline ? "true" : "false",
FW_VERSION);
if (n < 0 || n >= static_cast<int>(sizeof(body))) {
Log.warningln("rpc.checkHealth payload truncated (%d)", n);
return;
}
sendJsonResponse(requestTopic, body);
}
Now wire dispatch into onMessage. Locate the existing block in onMessage that calls mapMethodToType (currently rpc_server.cpp lines 49-55):
command_type_t type;
const char* method = doc["method"] | "";
if (mapMethodToType(method, type) == nullptr) {
sendResponse(topic, false, parsedPinPreview, parsedValuePreview, "unknown_method");
Log.verboseln("rpc.onMessage latency_ms=%lu", millis() - startedAt);
return;
}
Insert the new method handling before that block:
const char* method = doc["method"] | "";
if (strcmp(method, "checkHealth") == 0) {
handleCheckHealth(topic);
Log.verboseln("rpc.checkHealth latency_ms=%lu", millis() - startedAt);
return;
}
command_type_t type;
if (mapMethodToType(method, type) == nullptr) {
sendResponse(topic, false, parsedPinPreview, parsedValuePreview, "unknown_method");
Log.verboseln("rpc.onMessage latency_ms=%lu", millis() - startedAt);
return;
}
(Note: remove the now-duplicate const char* method = ... declaration that was previously inside the second block.)
- Step 4: Run the test — expect PASS
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: test_check_health_returns_expected_fields PASSES; all previous tests still pass.
- Step 5: Commit
git add gateway_firmware/src/mqtt/rpc_server.cpp gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "feat(gateway): handle checkHealth RPC with runtime snapshot"
Task 7 (TDD): readGPIO happy path
Files:
Test:
gateway_firmware/test/test_rpc_server/test_main.cppModify:
gateway_firmware/src/mqtt/rpc_server.cppStep 1: Write the failing test
In the #if PCF8574_MODE_VALUE == 1 block, add:
static void test_read_gpio_returns_logical_value_active_low() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(true);
FakePcf8574Reader pcf;
// PCF8574_RELAY_ACTIVE_LOW=1: bit=0 means ON. Set pin 3 bit low.
pcf.next_value = static_cast<uint8_t>(~(1U << 3));
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r1",
"{\"method\":\"readGPIO\",\"params\":{\"pin\":3}}");
TEST_ASSERT_EQUAL_STRING("v1/devices/me/rpc/response/r1",
g_pubsub_state.last_pub_topic_copy.c_str());
const std::string& body = g_pubsub_state.last_pub_payload_copy;
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"pin\":3"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"value\":1"));
TEST_ASSERT_EQUAL_INT(1, pcf.read_calls);
}
Register the new test in main():
RUN_TEST(test_read_gpio_returns_logical_value_active_low);
- Step 2: Run — expect FAIL
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: FAIL ("error":"unknown_method").
- Step 3: Implement
handleReadGpioand dispatch
Append to rpc_server.cpp:
void RpcServer::handleReadGpio(const char* requestTopic, JsonVariantConst params) {
if (!params["pin"].is<int>()) {
sendJsonResponse(requestTopic, "{\"error\":\"invalid_pin\"}");
return;
}
const int pin = params["pin"].as<int>();
if (pin < 0 || pin > 7) {
char body[64];
snprintf(body, sizeof(body),
"{\"pin\":%d,\"error\":\"invalid_pin\"}", pin);
sendJsonResponse(requestTopic, body);
return;
}
if (_ioState != nullptr && !_ioState->isIoBoardOnline()) {
sendJsonResponse(requestTopic, "{\"error\":\"io_board_offline\"}");
return;
}
if (_pcf == nullptr) {
sendJsonResponse(requestTopic, "{\"error\":\"io_read_failed\"}");
return;
}
uint8_t portValue = 0;
if (!_pcf->readPort(portValue)) {
Log.errorln("rpc.readGPIO i2c readPort failed");
sendJsonResponse(requestTopic, "{\"error\":\"io_read_failed\"}");
return;
}
const int bit = (portValue >> pin) & 0x1;
const int logical = PCF8574_RELAY_ACTIVE_LOW ? (bit == 0 ? 1 : 0) : bit;
char body[48];
snprintf(body, sizeof(body), "{\"pin\":%d,\"value\":%d}", pin, logical);
sendJsonResponse(requestTopic, body);
}
Insert dispatch in onMessage, right after the checkHealth branch added in Task 6:
if (strcmp(method, "readGPIO") == 0) {
handleReadGpio(topic, doc["params"]);
Log.verboseln("rpc.readGPIO latency_ms=%lu", millis() - startedAt);
return;
}
- Step 4: Run — expect PASS
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: all tests pass.
- Step 5: Commit
git add gateway_firmware/src/mqtt/rpc_server.cpp gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "feat(gateway): handle readGPIO RPC with active-low translation"
Task 8 (TDD): readGPIO validation and error paths
Files:
- Test:
gateway_firmware/test/test_rpc_server/test_main.cpp
The implementation in Task 7 already covers these cases; we add tests to lock the behavior in.
- Step 1: Add validation tests
In #if PCF8574_MODE_VALUE == 1:
static void test_read_gpio_rejects_missing_pin() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r2",
"{\"method\":\"readGPIO\",\"params\":{}}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"invalid_pin\""));
TEST_ASSERT_EQUAL_INT(0, pcf.read_calls);
}
static void test_read_gpio_rejects_out_of_range_pin() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r3",
"{\"method\":\"readGPIO\",\"params\":{\"pin\":8}}");
const std::string& body = g_pubsub_state.last_pub_payload_copy;
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"pin\":8"));
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"error\":\"invalid_pin\""));
TEST_ASSERT_EQUAL_INT(0, pcf.read_calls);
}
static void test_read_gpio_rejects_negative_pin() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r4",
"{\"method\":\"readGPIO\",\"params\":{\"pin\":-1}}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"invalid_pin\""));
TEST_ASSERT_EQUAL_INT(0, pcf.read_calls);
}
static void test_read_gpio_io_board_offline() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(false);
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r5",
"{\"method\":\"readGPIO\",\"params\":{\"pin\":2}}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"io_board_offline\""));
TEST_ASSERT_EQUAL_INT(0, pcf.read_calls);
}
static void test_read_gpio_io_read_failure_leaves_io_state_online() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(true);
FakePcf8574Reader pcf;
pcf.force_fail = true;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/r6",
"{\"method\":\"readGPIO\",\"params\":{\"pin\":2}}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"io_read_failed\""));
TEST_ASSERT_TRUE(ioState.isIoBoardOnline());
}
Register each test in main():
RUN_TEST(test_read_gpio_rejects_missing_pin);
RUN_TEST(test_read_gpio_rejects_out_of_range_pin);
RUN_TEST(test_read_gpio_rejects_negative_pin);
RUN_TEST(test_read_gpio_io_board_offline);
RUN_TEST(test_read_gpio_io_read_failure_leaves_io_state_online);
- Step 2: Run — expect PASS
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: all new tests pass on the existing implementation.
- Step 3: Commit
git add gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "test(gateway): cover readGPIO validation and failure paths"
Task 9 (TDD): readAllGPIO happy path and failure modes
Files:
Test:
gateway_firmware/test/test_rpc_server/test_main.cppModify:
gateway_firmware/src/mqtt/rpc_server.cppStep 1: Write the failing tests
In #if PCF8574_MODE_VALUE == 1:
static void test_read_all_gpio_returns_values_and_raw() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(true);
FakePcf8574Reader pcf;
// raw=0b00110010 (50). Active-low means bits set = OFF.
// pins 1,4,5 are high (OFF -> 0), others low (ON -> 1).
pcf.next_value = 0x32;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/a1",
"{\"method\":\"readAllGPIO\"}");
const std::string& body = g_pubsub_state.last_pub_payload_copy;
TEST_ASSERT_NOT_EQUAL(std::string::npos, body.find("\"raw\":50"));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
body.find("\"values\":[1,0,1,1,0,0,1,1]"));
}
static void test_read_all_gpio_io_board_offline() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(false);
FakePcf8574Reader pcf;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/a2",
"{\"method\":\"readAllGPIO\"}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"io_board_offline\""));
TEST_ASSERT_EQUAL_INT(0, pcf.read_calls);
}
static void test_read_all_gpio_read_failure_leaves_io_state_online() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
IoState ioState;
ioState.setIoBoardOnline(true);
FakePcf8574Reader pcf;
pcf.force_fail = true;
RpcServer rpcServer(commandQueue, mqttClient, &ioState, nullptr, &pcf);
rpcServer.begin();
dispatchMessage("v1/devices/me/rpc/request/a3",
"{\"method\":\"readAllGPIO\"}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"io_read_failed\""));
TEST_ASSERT_TRUE(ioState.isIoBoardOnline());
}
Register each in main():
RUN_TEST(test_read_all_gpio_returns_values_and_raw);
RUN_TEST(test_read_all_gpio_io_board_offline);
RUN_TEST(test_read_all_gpio_read_failure_leaves_io_state_online);
- Step 2: Run — expect FAIL (unknown_method)
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: the three new tests fail with unknown_method.
- Step 3: Implement
handleReadAllGpioand dispatch
Append to rpc_server.cpp:
void RpcServer::handleReadAllGpio(const char* requestTopic) {
if (_ioState != nullptr && !_ioState->isIoBoardOnline()) {
sendJsonResponse(requestTopic, "{\"error\":\"io_board_offline\"}");
return;
}
if (_pcf == nullptr) {
sendJsonResponse(requestTopic, "{\"error\":\"io_read_failed\"}");
return;
}
uint8_t portValue = 0;
if (!_pcf->readPort(portValue)) {
Log.errorln("rpc.readAllGPIO i2c readPort failed");
sendJsonResponse(requestTopic, "{\"error\":\"io_read_failed\"}");
return;
}
int values[8];
for (int i = 0; i < 8; ++i) {
const int bit = (portValue >> i) & 0x1;
values[i] = PCF8574_RELAY_ACTIVE_LOW ? (bit == 0 ? 1 : 0) : bit;
}
char body[96];
snprintf(body, sizeof(body),
"{\"values\":[%d,%d,%d,%d,%d,%d,%d,%d],\"raw\":%u}",
values[0], values[1], values[2], values[3],
values[4], values[5], values[6], values[7],
static_cast<unsigned>(portValue));
sendJsonResponse(requestTopic, body);
}
Insert dispatch in onMessage, right after the readGPIO branch added in Task 7:
if (strcmp(method, "readAllGPIO") == 0) {
handleReadAllGpio(topic);
Log.verboseln("rpc.readAllGPIO latency_ms=%lu", millis() - startedAt);
return;
}
- Step 4: Run — expect PASS
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: all tests pass.
- Step 5: Commit
git add gateway_firmware/src/mqtt/rpc_server.cpp gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "feat(gateway): handle readAllGPIO RPC returning logical and raw port"
Task 10 (TDD): writeI2C alias for setGPIO
Files:
- Test:
gateway_firmware/test/test_rpc_server/test_main.cpp - Modify:
gateway_firmware/src/mqtt/rpc_server.cpp
The server has finalized writeI2C as the RPC method for single-pin relay writes. We accept it as a pure alias of setGPIO: same validation, same queue, same response envelope. feedback_publisher.cpp keeps emitting the "method":"setGPIO" label unchanged — out of scope here.
- Step 1: Write the failing happy-path test (relay mode)
In the #if PCF8574_MODE_VALUE == 1 block of gateway_firmware/test/test_rpc_server/test_main.cpp, add:
static void test_write_i2c_alias_queues_set_relay_command() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
RpcServer rpcServer(commandQueue, mqttClient);
rpcServer.begin();
dispatchMessage(
"v1/devices/me/rpc/request/200",
"{\"method\":\"writeI2C\",\"params\":{\"pin\":2,\"value\":1}}");
const control_command_t queued = stubQueueReadSent<control_command_t>(commandQueue, 0);
TEST_ASSERT_EQUAL(CMD_TYPE_SET_RELAY, queued.type);
TEST_ASSERT_EQUAL_UINT8(2, queued.target_id);
TEST_ASSERT_TRUE(queued.requested_state);
TEST_ASSERT_EQUAL_STRING("v1/devices/me/rpc/response/200",
g_pubsub_state.last_pub_topic_copy.c_str());
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"pin\":2"));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"value\":1"));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"changed\":true"));
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"queued\":true"));
}
static void test_write_i2c_alias_validates_pin() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
RpcServer rpcServer(commandQueue, mqttClient);
rpcServer.begin();
dispatchMessage(
"v1/devices/me/rpc/request/201",
"{\"method\":\"writeI2C\",\"params\":{\"pin\":9,\"value\":1}}");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"invalid_pin\""));
}
Register both inside the same #if PCF8574_MODE_VALUE == 1 block in main(), alongside the existing setGPIO tests:
RUN_TEST(test_write_i2c_alias_queues_set_relay_command);
RUN_TEST(test_write_i2c_alias_validates_pin);
- Step 2: Write the failing water-level-mode test
In the #if PCF8574_MODE_VALUE == 0 block of the same file, add:
static void test_write_i2c_rejected_in_water_level_mode() {
MqttClient mqttClient;
QueueHandle_t commandQueue = xQueueCreate(4, sizeof(control_command_t));
RpcServer rpcServer(commandQueue, mqttClient);
rpcServer.begin();
dispatchMessage(
"v1/devices/me/rpc/request/202",
"{\"method\":\"writeI2C\",\"params\":{\"pin\":1,\"value\":1}}");
TEST_ASSERT_EQUAL(0, commandQueue->send_calls);
TEST_ASSERT_NOT_EQUAL(std::string::npos,
g_pubsub_state.last_pub_payload_copy.find("\"error\":\"pcf8574_mode_water_level\""));
}
Register inside the same #if PCF8574_MODE_VALUE == 0 block in main():
RUN_TEST(test_write_i2c_rejected_in_water_level_mode);
- Step 3: Run the tests — expect FAIL
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: the three new tests fail with "error":"unknown_method" (relay-mode tests) — because mapMethodToType does not yet recognize writeI2C.
Also: cd gateway_firmware && pio test -e native_rpc_water_level -f test_rpc_server
Expected: test_write_i2c_rejected_in_water_level_mode fails for the same reason.
- Step 4: Implement the alias in
mapMethodToType
In gateway_firmware/src/mqtt/rpc_server.cpp, replace the body of mapMethodToType (currently lines 112-118) with:
const char* RpcServer::mapMethodToType(const char* method, command_type_t& outType) const {
if (strcmp(method, "setGPIO") == 0 || strcmp(method, "writeI2C") == 0) {
outType = CMD_TYPE_SET_RELAY;
return method;
}
return nullptr;
}
No other change is needed — writeI2C flows through the same buildCommand, pcf8574_mode_water_level, io_board_offline, command_queue_full, and duplicate_command paths as setGPIO, and sendResponse emits the same {pin,value,changed,queued,error} envelope.
- Step 5: Run the tests — expect PASS
Run sequentially:
cd gateway_firmware
pio test -e native_rpc -f test_rpc_server
pio test -e native_rpc_water_level -f test_rpc_server
Expected: all tests pass, including every pre-existing setGPIO test (the alias must not affect canonical behavior).
- Step 6: Commit
git add gateway_firmware/src/mqtt/rpc_server.cpp gateway_firmware/test/test_rpc_server/test_main.cpp
git commit -m "feat(gateway): accept writeI2C as alias for setGPIO RPC"
Task 11: Regression test — unknown_method and setGPIO untouched
Files:
- Test:
gateway_firmware/test/test_rpc_server/test_main.cpp
We already have test_unknown_method_returns_rejected_schema and test_accepts_valid_set_gpio_and_populates_command_fields from before. This task just confirms they still pass under the new dispatch table.
- Step 1: Run the full rpc test env
Run: cd gateway_firmware && pio test -e native_rpc -f test_rpc_server
Expected: every test in the file passes — including the original setGPIO envelope check ({pin,value,changed,queued}) and unknown_method.
- Step 2: Run the water-level mode env too, to confirm cross-mode behavior
Run: cd gateway_firmware && pio test -e native_rpc_water_level -f test_rpc_server
Expected: pass. The new methods will compile but most water-level-mode tests live in #if PCF8574_MODE_VALUE == 0; the new tests live inside PCF8574_MODE_VALUE == 1 and are skipped here.
- Step 3: Run firmware build to confirm release env still links
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: clean build.
- Step 4: No code change — no commit needed for this task.
Task 12: Update rpc_commands.md documentation
Files:
Modify:
gateway_firmware/rpc_commands.mdStep 1: Add new sections at the end of the file
Append the following to gateway_firmware/rpc_commands.md:
### writeI2C (alias of setGPIO)
The server uses `writeI2C` as the canonical method name when issuing a single-pin relay write. The gateway accepts both `writeI2C` and `setGPIO`; they share the same parameters, validation, response envelope, and feedback telemetry (the feedback message still reports `"method":"setGPIO"`).
**Example Request**:
```json
{ "method": "writeI2C", "params": { "pin": 1, "value": 1 } }
```
The response envelope is identical to `setGPIO`:
```json
{ "pin": 1, "value": 1, "changed": true, "queued": true }
```
### 4. Check Health
Returns an in-process snapshot of firmware/runtime health. Synchronous; never errors.
- **Method**: `checkHealth`
- **Parameters**: None
- Note: This supersedes the legacy `checkConnection`.
**Example Request**:
```json
{ "method": "checkHealth" }
```
**Example Response**:
```json
{
"ok": true,
"uptimeSec": 12345,
"freeHeap": 187432,
"wifiConnected": true,
"wifiRssi": -58,
"mqttConnected": true,
"ioBoardOnline": true,
"fwVersion": "1.4.0"
}
```
### 5. Read GPIO
Reads the logical state of a single PCF8574 relay pin (0–7). Synchronous and read-only — does not pass through the command worker.
- **Method**: `readGPIO`
- **Parameters**:
- `pin` (integer 0–7)
**Example Request**:
```json
{ "method": "readGPIO", "params": { "pin": 3 } }
```
**Example Response**:
```json
{ "pin": 3, "value": 1 }
```
### 6. Read All GPIO
Reads the logical state of all eight PCF8574 relay pins. Synchronous and read-only.
- **Method**: `readAllGPIO`
- **Parameters**: None
**Example Request**:
```json
{ "method": "readAllGPIO" }
```
**Example Response**:
```json
{
"values": [0, 1, 0, 0, 1, 1, 0, 0],
"raw": 50
}
```
## Error Codes
| `error` | Cause |
|-----------------------|------------------------------------------------------------|
| `invalid_payload` | Body is not valid JSON or is missing required structure. |
| `unknown_method` | `method` is not one of the supported names. |
| `invalid_pin` | `readGPIO`/`setGPIO`/`writeI2C` `pin` is missing or outside `[0, 7]`. |
| `invalid_value` | `setGPIO`/`writeI2C` `value` is missing or not `0`/`1`. |
| `io_board_offline` | PCF8574 is not initialized or has been marked offline. |
| `io_read_failed` | I²C read returned no data. |
| `command_queue_full` | Async command queue is full (`setGPIO`/`writeI2C` only). |
| `duplicate_command` | A command with this request ID has already been processed. |
| `pcf8574_mode_water_level` | `setGPIO`/`writeI2C` is disabled in water-level PCF mode. |
Read failures (`io_read_failed`) do **not** flip `ioBoardOnline` — only the async command worker may do that.
- Step 2: Commit
git add gateway_firmware/rpc_commands.md
git commit -m "docs(gateway): document checkHealth, readGPIO, readAllGPIO RPCs"
Task 13: Final verification
- Step 1: Run the full set of firmware test envs touched by this change
Run sequentially:
cd gateway_firmware
pio test -e native_rpc
pio test -e native_rpc_water_level
pio test -e native_pcf8574_relay
pio test -e native_telemetry
Expected: all pass.
- Step 2: Run a clean firmware build
Run: cd gateway_firmware && pio run -e esp32doit-devkit-v1
Expected: clean build, no warnings introduced by this change.
- Step 3: Report verification status
Summarize:
- which test envs passed
- whether
pio runproduced a clean build - explicitly note that hardware-in-the-loop verification was NOT performed (no ESP32 device available in this session)
Out of scope (do not touch)
- Removing the legacy
src/rpc_handler.cpp(tracked separately). - Exposing Modbus subsystem health (
modbus_last_ok_msdoes not exist yet). - Any change to the
setGPIOasync pipeline, command history, or feedback publisher.