ThingsBoard server-side RPC turns an MQTT-connected ESP8266 into a remotely controlled device, but relay control needs more than a callback and a digitalWrite(). A safe implementation must correlate request and response topics, validate the method and parameter types, restrict GPIO access, report the resulting state, re-subscribe after reconnect, and avoid repeating an actuator action when a request is delivered again.
This satellite guide focuses only on the ThingsBoard RPC lifecycle and relay state synchronization. For device creation, DHT22 telemetry, and the broader architecture, start with Connect ESP8266 to ThingsBoard Using MQTT.
ThingsBoard server-side RPC flow
- The device subscribes to
v1/devices/me/rpc/request/+. - ThingsBoard publishes a request to
v1/devices/me/rpc/request/REQUEST_ID. - The ESP8266 extracts
REQUEST_IDfrom the topic suffix and validates the JSON body. - After applying an authorized command, it reads back the GPIO output state.
- It publishes the result to
v1/devices/me/rpc/response/REQUEST_ID. - It also publishes
relayEnabledtelemetry so a dashboard can render device state rather than merely assume the click succeeded.
The response request ID must match the incoming topic. It is not a fixed value and should not be generated independently by the device.
Request and response payloads
The accepted RPC request is:
{
"method": "setGpioStatus",
"params": {
"pin": 5,
"enabled": true
}
}
A successful response has this shape:
{
"success": true,
"duplicate": false,
"pin": 5,
"enabled": true
}
A blocked GPIO produces an explicit error rather than changing a pin:
{
"success": false,
"duplicate": false,
"error": "pin_not_allowed"
}
These examples show message format only. They are not captured results from a physical relay.
Hardware and relay safety
An ESP8266 GPIO must not directly drive a contactor, motor, pump, high-current relay coil, or mains-voltage load. GPIO5 in this guide is only a 3.3 V logic control signal.
- Use a relay module with a genuinely compatible 3.3 V input, or use a correctly sized transistor/MOSFET driver.
- Place a flyback diode across a DC relay coil unless the selected module already provides appropriate suppression.
- Use separate load power where required. A non-isolated driver generally needs a common signal ground.
- Use optical or galvanic isolation where the risk assessment requires it, and preserve creepage, clearance, enclosure, and protective-earth requirements.
- Do not use ESP8266 boot-strap or flash-connected pins casually. GPIO5 is commonly safer than GPIO0, GPIO2, or GPIO15 on development boards, but verify the exact board.
This article intentionally does not give mains-wiring instructions. Hazardous-voltage installation and machine control require suitable certified hardware, independent interlocks, and qualified electrical design.
Project structure and secrets
thingsboard-relay/
├── thingsboard_relay.ino
├── secrets.example.h
├── secrets.h
└── .gitignore
Keep the real token and Wi-Fi credentials out of source control:
#pragma once
#define WIFI_SSID "YOUR_WIFI_SSID"
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD"
#define THINGSBOARD_HOST "thingsboard.example.com"
#define THINGSBOARD_PORT 1883
#define THINGSBOARD_TOKEN "YOUR_DEVICE_ACCESS_TOKEN"
Add secrets.h to .gitignore.
Port 1883 is unencrypted MQTT and should be limited to a trusted test network. Production deployments should use the configured ThingsBoard TLS listener with CA certificate validation.
Complete ESP8266 relay RPC code
Install the ESP8266 Arduino core, PubSubClient, and ArduinoJson 7. The sketch uses bounded character buffers, a pin allowlist, a fixed-size completed-request cache, and timed connection retries.
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include "secrets.h"
constexpr uint8_t RELAY_PIN = 5; // GPIO5, often D1 on dev boards
constexpr bool RELAY_ACTIVE_LOW = true; // false for active-high modules
constexpr uint8_t CONTROL_PINS[] = {RELAY_PIN};
constexpr size_t GPIO_STATE_COUNT = 17;
bool gpioStates[GPIO_STATE_COUNT] = {};
constexpr char RPC_SUB_TOPIC[] = "v1/devices/me/rpc/request/+";
constexpr char RPC_REQUEST_PREFIX[] = "v1/devices/me/rpc/request/";
constexpr char RPC_RESPONSE_PREFIX[] = "v1/devices/me/rpc/response/";
constexpr char TELEMETRY_TOPIC[] = "v1/devices/me/telemetry";
constexpr char ATTRIBUTES_TOPIC[] = "v1/devices/me/attributes";
constexpr size_t MAX_RPC_PAYLOAD = 384;
constexpr size_t MAX_REQUEST_ID = 31;
constexpr size_t RECENT_REQUEST_COUNT = 8;
constexpr uint32_t WIFI_RETRY_MS = 15000;
constexpr uint32_t MQTT_RETRY_MIN_MS = 2000;
constexpr uint32_t MQTT_RETRY_MAX_MS = 60000;
struct CompletedRequest {
char id[MAX_REQUEST_ID + 1];
uint8_t pin;
bool enabled;
bool used;
};
CompletedRequest recentRequests[RECENT_REQUEST_COUNT] = {};
size_t nextRequestSlot = 0;
WiFiClient networkClient;
PubSubClient mqtt(networkClient);
uint32_t lastWiFiAttemptAt = 0;
uint32_t lastMqttAttemptAt = 0;
uint32_t mqttRetryMs = MQTT_RETRY_MIN_MS;
bool wifiAttempted = false;
bool isControlPin(uint8_t pin) {
for (uint8_t allowed : CONTROL_PINS) if (allowed == pin) return true;
return false;
}
uint8_t electricalLevel(bool enabled) {
return RELAY_ACTIVE_LOW ? (enabled ? LOW : HIGH) : (enabled ? HIGH : LOW);
}
void initializeSafeOutputs() {
for (uint8_t pin : CONTROL_PINS) {
digitalWrite(pin, electricalLevel(false));
pinMode(pin, OUTPUT);
gpioStates[pin] = false;
}
}
bool setGpioStatus(uint8_t pin, bool enabled) {
if (pin >= GPIO_STATE_COUNT || !isControlPin(pin)) return false;
digitalWrite(pin, electricalLevel(enabled));
gpioStates[pin] = enabled;
return true;
}
bool get_gpio_status(uint8_t pin) {
if (pin >= GPIO_STATE_COUNT || !isControlPin(pin)) return false;
const int level = digitalRead(pin);
gpioStates[pin] = RELAY_ACTIVE_LOW ? (level == LOW) : (level == HIGH);
return gpioStates[pin];
}
bool publishJson(const char *topic, JsonDocument &doc, bool retained = false) {
char payload[384];
const size_t length = serializeJson(doc, payload, sizeof(payload));
if (length == 0 || length >= sizeof(payload) || !mqtt.connected()) return false;
return mqtt.publish(topic, payload, retained);
}
void publishRelayState(const char *source) {
JsonDocument doc;
doc["relayEnabled"] = get_gpio_status(RELAY_PIN);
doc["relayPin"] = RELAY_PIN;
doc["stateSource"] = source;
publishJson(TELEMETRY_TOPIC, doc);
}
void publishDeviceAttributes() {
JsonDocument doc;
doc["relayPin"] = RELAY_PIN;
doc["relayActiveLow"] = RELAY_ACTIVE_LOW;
doc["rpcMethod"] = "setGpioStatus";
publishJson(ATTRIBUTES_TOPIC, doc);
}
bool validRequestId(const char *id) {
const size_t length = strlen(id);
if (length == 0 || length > MAX_REQUEST_ID) return false;
for (size_t i = 0; i < length; ++i) {
if (id[i] < '0' || id[i] > '9') return false;
}
return true;
}
CompletedRequest *findCompletedRequest(const char *id) {
for (auto &entry : recentRequests) {
if (entry.used && strcmp(entry.id, id) == 0) return &entry;
}
return nullptr;
}
void rememberCompletedRequest(const char *id, uint8_t pin, bool enabled) {
CompletedRequest &entry = recentRequests[nextRequestSlot];
snprintf(entry.id, sizeof(entry.id), "%s", id);
entry.pin = pin;
entry.enabled = enabled;
entry.used = true;
nextRequestSlot = (nextRequestSlot + 1U) % RECENT_REQUEST_COUNT;
}
void sendRpcResponse(const char *requestId, bool success, uint8_t pin,
bool enabled, const char *error = nullptr,
bool duplicate = false) {
char topic[96];
const int written = snprintf(topic, sizeof(topic), "%s%s",
RPC_RESPONSE_PREFIX, requestId);
if (written <= 0 || static_cast<size_t>(written) >= sizeof(topic)) return;
JsonDocument response;
response["success"] = success;
response["duplicate"] = duplicate;
if (success) {
response["pin"] = pin;
response["enabled"] = enabled;
} else {
response["error"] = error ? error : "invalid_request";
}
publishJson(topic, response);
}
void handleRpcRequest(const char *topic, const byte *payload,
unsigned int length) {
if (strncmp(topic, RPC_REQUEST_PREFIX, strlen(RPC_REQUEST_PREFIX)) != 0) return;
const char *requestId = topic + strlen(RPC_REQUEST_PREFIX);
if (!validRequestId(requestId)) return; // No safe response topic can be formed.
CompletedRequest *previous = findCompletedRequest(requestId);
if (previous) {
// Re-send the cached result; do not actuate the GPIO a second time.
sendRpcResponse(requestId, true, previous->pin, previous->enabled,
nullptr, true);
publishRelayState("duplicate_rpc");
return;
}
if (length == 0 || length > MAX_RPC_PAYLOAD) {
sendRpcResponse(requestId, false, 0, false, "payload_too_large_or_empty");
return;
}
char input[MAX_RPC_PAYLOAD + 1];
memcpy(input, payload, length);
input[length] = '\0';
JsonDocument request;
DeserializationError jsonError = deserializeJson(request, input, length);
if (jsonError) {
sendRpcResponse(requestId, false, 0, false, "malformed_json");
return;
}
if (!request["method"].is<const char *>() ||
strcmp(request["method"].as<const char *>(), "setGpioStatus") != 0) {
sendRpcResponse(requestId, false, 0, false, "unknown_method");
return;
}
JsonVariant params = request["params"];
if (!params.is<JsonObject>() || !params["pin"].is<int>() ||
!params["enabled"].is<bool>()) {
sendRpcResponse(requestId, false, 0, false, "invalid_params");
return;
}
const int requestedPin = params["pin"].as<int>();
if (requestedPin < 0 || requestedPin >= static_cast<int>(GPIO_STATE_COUNT) ||
!isControlPin(static_cast<uint8_t>(requestedPin))) {
sendRpcResponse(requestId, false, 0, false, "pin_not_allowed");
return;
}
const uint8_t pin = static_cast<uint8_t>(requestedPin);
const bool requestedState = params["enabled"].as<bool>();
if (!setGpioStatus(pin, requestedState)) {
sendRpcResponse(requestId, false, pin, false, "gpio_write_failed");
return;
}
const bool resultingState = get_gpio_status(pin);
rememberCompletedRequest(requestId, pin, resultingState);
sendRpcResponse(requestId, true, pin, resultingState);
publishRelayState("rpc");
}
void mqttCallback(char *topic, byte *payload, unsigned int length) {
handleRpcRequest(topic, payload, length);
}
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
const uint32_t now = millis();
if (wifiAttempted && static_cast<uint32_t>(now - lastWiFiAttemptAt) < WIFI_RETRY_MS) return;
wifiAttempted = true;
lastWiFiAttemptAt = now;
WiFi.mode(WIFI_STA);
WiFi.persistent(false);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}
void connectMQTT() {
if (WiFi.status() != WL_CONNECTED || mqtt.connected()) return;
const uint32_t now = millis();
if (static_cast<uint32_t>(now - lastMqttAttemptAt) < mqttRetryMs) return;
lastMqttAttemptAt = now;
char clientId[32];
snprintf(clientId, sizeof(clientId), "relay-%06X", ESP.getChipId());
if (!mqtt.connect(clientId, THINGSBOARD_TOKEN, nullptr)) {
mqttRetryMs = min(mqttRetryMs * 2U, MQTT_RETRY_MAX_MS);
return;
}
mqttRetryMs = MQTT_RETRY_MIN_MS;
if (!mqtt.subscribe(RPC_SUB_TOPIC)) {
mqtt.disconnect(); // Force a later clean reconnect and re-subscribe.
return;
}
publishDeviceAttributes();
publishRelayState("mqtt_reconnect");
}
void setup() {
initializeSafeOutputs();
mqtt.setServer(THINGSBOARD_HOST, THINGSBOARD_PORT);
mqtt.setCallback(mqttCallback);
mqtt.setBufferSize(512);
mqtt.setKeepAlive(30);
connectWiFi();
}
void loop() {
connectWiFi();
if (WiFi.status() == WL_CONNECTED) connectMQTT();
if (mqtt.connected()) mqtt.loop();
yield();
}
Validation decisions in the callback
Bound the topic suffix and payload
The callback accepts a numeric request ID no longer than 31 characters and a payload no larger than 384 bytes. MQTT payload memory is not assumed to be null terminated; the sketch copies it into a bounded local buffer before parsing.
Validate method, object, and exact types
The code accepts only setGpioStatus. The params value must be an object, pin must be an integer, and enabled must be a JSON Boolean. Strings such as "true" are rejected instead of being coerced.
Allowlist GPIOs
CONTROL_PINS is the authorization boundary. Even a valid ThingsBoard token must not permit arbitrary remote writes to boot pins, UART pins, flash pins, or other actuators. Add a second output only after reviewing its electrical behavior and safety policy.
Return resulting state, not requested state
After setGpioStatus(), the sketch calls get_gpio_status(), which interprets the GPIO output level according to active-low or active-high polarity. This confirms the MCU output latch state. It does not prove that relay contacts moved or that a connected machine changed state; physical feedback requires a separate isolated input and application-specific interlocks.
Active-low and active-high relay modules
With an active-low module, LOW energizes the module input and HIGH disables it. With active-high hardware, HIGH enables it. Set RELAY_ACTIVE_LOW to match the actual module. All application and dashboard states remain logical: enabled=true means relay requested on regardless of the electrical level.
initializeSafeOutputs() writes the disabled electrical level before changing the GPIO to OUTPUT, then records false in gpioStates. Test the real module at boot with no hazardous load attached; some boards can still pulse because of pull resistors or their input-stage design.
Avoid duplicate relay actions
MQTT delivery, gateway retries, or an application retry can present a request more than once. A repeated request should not repeat a non-idempotent physical action. The sketch stores the last eight completed numeric request IDs and their results. When the same ID arrives again, it re-sends the cached response with duplicate=true without calling digitalWrite().
The cache is deliberately bounded and stored in RAM. It disappears after reboot and eventually evicts old IDs. Critical machine control needs durable command IDs, expiry timestamps, authorization, and feedback in a system designed for that risk. The small cache is a practical defense for a basic relay, not an exactly-once guarantee.
Reconnect, re-subscribe, and synchronize state
An MQTT subscription belongs to the broker session. The sketch subscribes after every successful connection. If subscription fails, it disconnects so a later retry can establish a clean session and subscribe again. MQTT connection retries use exponential backoff up to one minute, while Wi-Fi attempts are limited to once per 15 seconds.
After connection, the device publishes attributes describing pin and polarity plus current telemetry with stateSource=mqtt_reconnect. The safe output is established at boot; a network reconnect alone does not toggle it. This prevents a short broker outage from unnecessarily switching a load while still letting the dashboard learn the actual logical state.
Create a ThingsBoard RPC switch widget
- Confirm the device is online and
relayEnabledexists in Latest telemetry. - Create or open a dashboard and add an entity alias for this device.
- Add a control switch widget that supports two-way server-side RPC in your ThingsBoard CE version.
- Set the method to
setGpioStatus. - Configure the outgoing parameters as an object containing GPIO5 and the widget Boolean, equivalent to
{"pin":5,"enabled":VALUE}. - Configure displayed state from the device’s
relayEnabledtelemetry where the widget supports a state key or value subscription. - Set a realistic RPC timeout and display rejected responses instead of optimistically leaving the switch changed.
Widget fields and names vary by ThingsBoard release. Inspect the generated RPC body in your installed version and confirm it matches the required types.
Safe testing procedure
- Disconnect any motor, pump, contactor, mains load, or high-current circuit.
- Power the ESP8266 and relay input stage from suitable low-voltage supplies.
- Confirm boot publishes
relayEnabled=false. - Send the enable request and inspect both the RPC response and Latest telemetry.
- Send
pin=4. Expectpin_not_allowedand no GPIO5 change. - Send
enabled="true"as a string. Expectinvalid_params. - Send an unknown method. Expect
unknown_method. - Disconnect and restore Wi-Fi. Confirm the device reconnects, subscribes again, and republishes state.
- If possible in a controlled MQTT test, repeat the same request ID. Confirm the response marks it duplicate without a second actuation.
These are expected behaviors to verify, not claims that a physical relay was tested.
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
| No RPC reaches the device | Offline MQTT session or missing subscription | Confirm device connectivity and that every reconnect successfully subscribes to v1/devices/me/rpc/request/+. |
| RPC times out despite GPIO switching | Wrong response topic or lost connection before response | Use the exact incoming request ID in v1/devices/me/rpc/response/REQUEST_ID. |
malformed_json |
Invalid or truncated JSON | Send a complete JSON object within the payload bound and check widget serialization. |
invalid_params |
Wrong JSON types | Use an integer pin and a true Boolean, not quoted strings. |
pin_not_allowed |
Pin absent from CONTROL_PINS |
Use GPIO5 or deliberately review and add another safe output. |
| Relay works backwards | Incorrect polarity | Change RELAY_ACTIVE_LOW and retest without a hazardous load. |
| Dashboard switch disagrees | Optimistic widget state or missing telemetry subscription | Render from relayEnabled and republish state on RPC and reconnect. |
| Relay clicks twice | Different request IDs for application retries or cache eviction/reboot | Make the caller reuse its command ID where supported and design idempotent application semantics. |
| Reconnect storm | Bad token, broker unavailable, or blocking loop | Keep exponential backoff, verify credentials and listener reachability, and avoid tight retry loops. |
| Unexpected switch during boot | Wrong safe level, boot pin choice, module pull circuit, or unstable power | Verify GPIO5 level with no load, board schematic, module polarity, and regulated supply. |
Production hardening
- Use certificate-validated MQTT TLS and a unique device token.
- Restrict IoT network egress and protect ThingsBoard administration.
- Add command authorization, expiry, durable idempotency, audit trails, and physical state feedback for consequential loads.
- Rate-limit operator actions and alert on authentication failures, reconnect storms, and rejected commands.
- Use independent electrical interlocks; cloud software is not an emergency-stop circuit.
For complementary server-side controls, see secure API design with validation and rate limiting. If ThingsBoard runs in containers, use Docker Compose health checks and restart policies.
Verification checklist
- Boot always establishes the disabled electrical output before enabling GPIO output mode.
- Module polarity matches
RELAY_ACTIVE_LOW. - Only
CONTROL_PINScan be changed. - Malformed JSON, wrong types, unknown methods, and unauthorized pins return errors.
- Every successful request responds on the matching request ID topic.
- Response and telemetry contain the interpreted resulting GPIO state.
- Duplicate cached request IDs do not call the actuator again.
- MQTT reconnect re-subscribes and republishes current state.
- The dashboard renders device telemetry rather than only optimistic local state.
- Driver, flyback protection, isolation, power, and physical interlocks are verified on the real hardware.
Frequently asked questions
What topic receives ThingsBoard server-side RPC?
Subscribe to v1/devices/me/rpc/request/+. The wildcard captures requests whose final topic segment is the request ID.
Where should the ESP8266 send the response?
Publish to v1/devices/me/rpc/response/REQUEST_ID, replacing REQUEST_ID with the suffix extracted from the incoming request topic.
Why maintain gpioStates if digitalRead is available?
The array represents logical application state for permitted outputs. digitalRead() checks the output latch level, while the array provides explicit bounded state management. Neither proves physical relay-contact position.
Does MQTT guarantee the relay command runs exactly once?
No. The bounded request-ID cache reduces duplicate execution within one uptime window, but exactly-once physical control requires a wider durable protocol and feedback design.
Should the relay turn off after an MQTT reconnect?
This design keeps the established output through a network-only reconnect and republishes it. It always starts disabled after a reboot. Choose a different policy only after evaluating the controlled process and fail-safe requirements.
Can GPIO5 drive a relay coil directly?
No. Use a compatible relay module or transistor/MOSFET driver with suitable coil suppression and isolation. GPIO5 supplies only the logic signal.