Gateway RPC: checkHealth, readGPIO, readAllGPIO — Design

  • Date: 2026-05-13
  • Scope: gateway_firmware/
  • Status: Approved (pending user spec review)

1. Goal

Add three synchronous RPC commands to the ESP32 gateway so ThingsBoard (and operators) can:

  1. Probe firmware/runtime health on demand (checkHealth).
  2. Read the logical state of a single PCF8574 relay pin (readGPIO).
  3. Read the logical state of all eight PCF8574 relay pins (readAllGPIO).

These reads are read-only and must not flow through the existing async command pipeline used by setGPIO.

2. Non-goals

  • Reading ESP32-native GPIOs (only PCF8574 pins 0–7 are exposed).
  • Long-running diagnostics or self-tests beyond an in-process snapshot.
  • Mutating any device state. Read failures must not flip IoState::ioBoardOnline.
  • Reviving the legacy src/rpc_handler.cpp; new commands live in src/mqtt/rpc_server.cpp only.

3. Architecture

All three RPCs are handled inline in RpcServer::onMessage. They are synchronous and never touch command_queue, command_worker, or command_history.

onMessage(topic, payload):
  parse JSON, extract method
  switch method:
    "setGPIO"      -> existing async queue path (unchanged)
    "checkHealth"  -> handleCheckHealth(topic)
    "readGPIO"     -> handleReadGpio(topic, params)
    "readAllGPIO"  -> handleReadAllGpio(topic)
    else           -> existing "unknown_method" response

A new helper sendJsonResponse(requestTopic, bodyJson) rewrites /request/ to /response/ and publishes. The existing sendResponse(topic, accepted, pin, value, error) envelope ({pin,value,changed,queued,error}) remains exclusive to setGPIO; reads/health use their own JSON shapes because changed/queued are meaningless for reads.

Constructor change

RpcServer gains a pointer dependency on the PCF8574 driver (used only by the two read handlers). In main.cpp the real Pcf8574Driver& is passed; tests pass a fake. This is the only constructor-signature change.

RpcServer(QueueHandle_t commandQueue,
          MqttClient& mqttClient,
          IoState* ioState,
          CommandHistory* commandHistory,
          Pcf8574Driver* pcf);   // NEW

Health field sources

Read directly inside handleCheckHealth, mirroring HealthMonitor::_publish():

Field Source
uptimeSec millis() / 1000
freeHeap ESP.getFreeHeap()
wifiConnected WiFi.status() == WL_CONNECTED
wifiRssi WiFi.RSSI() if connected, else 0
mqttConnected always true at response time
ioBoardOnline IoState::isIoBoardOnline()
fwVersion new constexpr const char* FW_VERSION in config.h

4. RPC Specifications

4.1 checkHealth

Request

{ "method": "checkHealth" }

Response

{
  "ok": true,
  "uptimeSec": 12345,
  "freeHeap": 187432,
  "wifiConnected": true,
  "wifiRssi": -58,
  "mqttConnected": true,
  "ioBoardOnline": true,
  "fwVersion": "1.4.0"
}

checkHealth never errors.

4.2 readGPIO

Request

{ "method": "readGPIO", "params": { "pin": 3 } }

Validation: pin must be an integer in [0, 7].

Preconditions: IoState::isIoBoardOnline() must be true.

Reading: call Pcf8574Driver::readPort(value). Logical state for the requested pin is computed from value using PCF8574_RELAY_ACTIVE_LOW:

bit = (value >> pin) & 0x1
logical = PCF8574_RELAY_ACTIVE_LOW ? (bit == 0 ? 1 : 0) : bit

Success response

{ "pin": 3, "value": 1 }

4.3 readAllGPIO

Request

{ "method": "readAllGPIO" }

Preconditions: same isIoBoardOnline() check.

Success response

{
  "values": [0, 1, 0, 0, 1, 1, 0, 0],
  "raw": 50
}
  • values[i] — logical ON/OFF for pin i after active-low translation.
  • raw — the raw PCF8574 port byte (debug aid).

5. Error Model

Condition Response
Invalid JSON {"error":"invalid_payload"}
Unknown method {"error":"unknown_method"}
readGPIO missing/out-of-range pin {"error":"invalid_pin"}
readGPIO/readAllGPIO and !isIoBoardOnline() {"error":"io_board_offline"}
Pcf8574Driver::readPort() returns false {"error":"io_read_failed"}
MQTT not connected No response published (existing behavior).

Read failures must not call IoState::setIoBoardOnline(false). That side effect is reserved for command_worker.

For readGPIO, the invalid_pin error response includes the offending pin value when available (mirroring how setGPIO echoes its parsed pin).

6. Logging

  • Each handler logs at verbose: rpc.<method> latency_ms=<n> to match the existing pattern in onMessage.
  • I²C readPort() failure logs at error.

7. Testing

New tests live under test/native_rpc/ (extends existing pio test -e native_rpc environment). A fake Pcf8574Driver (or a thin IPcf8574Reader interface — pick whichever requires the smallest change to the production class) supplies controlled bytes / failure states.

  1. checkHealth happy path — response contains expected keys, types correct, uptimeSec is monotonic across two calls.
  2. readGPIO happy path — fake driver returns a known byte; assert value reflects active-low translation for multiple pin indices.
  3. readGPIO validation — pin missing / non-integer / -1 / 8invalid_pin.
  4. readGPIO io_board_offlineIoState offline → io_board_offline, fake driver readPort never invoked.
  5. readGPIO io_read_failed — fake driver returns false → io_read_failed, IoState unchanged (asserted).
  6. readAllGPIO happy path — known byte → values array correctly translated, raw equals the input byte.
  7. readAllGPIO failure / offline — same as 4 & 5.
  8. Regression — setGPIO still queues and responds with the existing {pin,value,changed,queued} envelope; unknown method still returns unknown_method.

8. Documentation

Update gateway_firmware/rpc_commands.md:

  • Add sections for checkHealth, readGPIO, readAllGPIO with request/response examples.
  • Add an error-code table covering: invalid_payload, unknown_method, invalid_pin, io_board_offline, io_read_failed.
  • Note that the legacy checkConnection is superseded by checkHealth.

9. Out-of-Scope / Follow-ups

  • Removing the dead src/rpc_handler.cpp is intentionally not part of this change; track separately.
  • Modbus subsystem health (e.g. modbus_last_ok_ms) is not exposed yet because it is not tracked anywhere today; revisit when that timestamp exists.