Connecting an ESP8266 to ThingsBoard is straightforward only until the first Wi-Fi interruption, malformed RPC request, or active-low relay reverses the expected state. This guide builds a bounded, fail-safe MQTT client that publishes DHT22 telemetry, receives a setGpioStatus command, validates the requested GPIO, returns the RPC result, and reconnects without a blocking retry loop.
Architecture and MQTT data flow
- The ESP8266 joins Wi-Fi and authenticates to the ThingsBoard MQTT endpoint with its device access token as the MQTT username.
- It publishes temperature, humidity, relay state, RSSI, and free heap as JSON telemetry.
- It publishes stable device facts such as board type and relay polarity as client attributes.
- ThingsBoard sends a server-side RPC request. The device accepts only
setGpioStatusand only pins inCONTROL_PINS. - The device applies the logical state, publishes the observed software state, and responds using the same request ID.
If ThingsBoard runs on a single Ubuntu server, use a repeatable deployment process such as the LAPVN guide to deploy Docker Compose in production. For Kubernetes hosting, define conservative resource limits and health probes.
Required hardware and software
- ESP8266 development board or ESP-12F on a correctly designed carrier
- DHT22 sensor and the pull-up required by your sensor module or bare sensor
- 3.3 V-compatible relay module or a correctly sized transistor/MOSFET driver
- Arduino IDE or PlatformIO on Ubuntu, macOS, or Windows
- ESP8266 Arduino core, PubSubClient, ArduinoJson 7, Adafruit DHT Sensor Library, and Adafruit Unified Sensor
- Reachable ThingsBoard CE instance
Electrical safety before wiring
An ESP8266 GPIO cannot drive a contactor, motor, pump, relay coil, or other high-current load directly. Use a 3.3 V-compatible relay module or a transistor/MOSFET driver. A DC inductive coil needs a flyback diode unless the module already includes one. Consider an optocoupler and a separate load supply where appropriate. A non-isolated driver normally needs a common signal ground; an actually isolated interface must follow its manufacturer’s isolation design.
Keep mains voltage physically and electrically separated from the ESP8266. Use certified enclosures and qualified electrical installation. This tutorial intentionally provides no mains-wiring instructions.
Configure the ThingsBoard device
- Sign in to ThingsBoard as a tenant administrator.
- Open Entities → Devices, add a device, and give it a descriptive name such as
esp8266-room-01. - Open the device credentials and copy its access token into a local
secrets.hfile. - Do not commit the token or paste it into screenshots, tickets, or public logs. Rotate it if it is exposed.
The standard ThingsBoard MQTT device API uses the access token as the username. The code leaves the password empty. Confirm the broker host and listener with your ThingsBoard administrator rather than assuming that the web-console port is also the MQTT port.
MQTT topics used by ThingsBoard
| Purpose | Topic | Direction |
|---|---|---|
| Telemetry | v1/devices/me/telemetry |
Device → server |
| Client attributes | v1/devices/me/attributes |
Device → server |
| RPC requests | v1/devices/me/rpc/request/+ |
Server → device |
| RPC response | v1/devices/me/rpc/response/{requestId} |
Device → server |
For this sketch, the RPC envelope expected from ThingsBoard is:
{
"method": "setGpioStatus",
"params": { "pin": 5, "enabled": true }
}
The requestId is part of the incoming topic, not the JSON body. The response must use that exact ID.
Wiring overview and safe GPIO selection
| Signal | ESP8266 GPIO | Common dev-board label |
|---|---|---|
| DHT22 data | GPIO4 | D2 |
| Relay input | GPIO5 | D1 |
Board labels are not GPIO numbers: verify your exact board schematic. GPIO0, GPIO2, and GPIO15 are boot-strap pins; an external circuit that forces the wrong level can cause a boot loop. GPIO6–GPIO11 are normally connected to flash and should not be used. GPIO4 and GPIO5 are commonly practical choices, but the attached module must still leave them in a valid state during reset.
The example assumes an active-low relay, where a LOW output enables the module. Set RELAY_ACTIVE_LOW to false for active-high hardware. The code writes the disabled electrical level before setting the pin to OUTPUT, reducing unintended pulses during initialization.
Project structure and secret handling
esp8266-thingsboard/
├── esp8266_thingsboard.ino
├── secrets.h
├── secrets.example.h
└── .gitignore
Create secrets.example.h with placeholders, copy it to secrets.h, and keep the real file out of Git:
#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"
.gitignore:
secrets.h
.pio/
Port 1883 is unencrypted MQTT and is suitable only on a trusted, isolated network for initial validation. Do not expose it directly to the Internet. The production hardening section explains the move to TLS.
Complete ESP8266 Arduino code
The following sketch targets ArduinoJson 7. It avoids dynamic String building, bounds RPC input, applies exponential MQTT retry backoff, and uses millis() rather than a long delay().
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include "secrets.h"
constexpr uint8_t DHT_PIN = 4; // GPIO4, often labelled D2
constexpr uint8_t RELAY_PIN = 5; // GPIO5, often labelled D1
constexpr uint8_t DHT_TYPE = DHT22;
constexpr bool RELAY_ACTIVE_LOW = true;
constexpr uint8_t CONTROL_PINS[] = {RELAY_PIN};
constexpr size_t GPIO_STATE_COUNT = 17;
bool gpioStates[GPIO_STATE_COUNT] = {};
constexpr char TELEMETRY_TOPIC[] = "v1/devices/me/telemetry";
constexpr char ATTRIBUTES_TOPIC[] = "v1/devices/me/attributes";
constexpr char RPC_REQUEST_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 uint32_t TELEMETRY_INTERVAL_MS = 10000;
constexpr uint32_t WIFI_RETRY_MS = 15000;
constexpr uint32_t MQTT_BACKOFF_MIN_MS = 2000;
constexpr uint32_t MQTT_BACKOFF_MAX_MS = 60000;
constexpr size_t MAX_RPC_PAYLOAD = 384;
WiFiClient networkClient;
PubSubClient mqtt(networkClient);
DHT dht(DHT_PIN, DHT_TYPE);
uint32_t lastTelemetryAt = 0;
uint32_t lastWiFiAttemptAt = 0;
uint32_t lastMqttAttemptAt = 0;
uint32_t mqttBackoffMs = MQTT_BACKOFF_MIN_MS;
bool wifiAttempted = false;
bool isControlPin(uint8_t pin) {
for (uint8_t allowedPin : CONTROL_PINS) {
if (allowedPin == pin) return true;
}
return false;
}
uint8_t outputLevel(bool enabled) {
return RELAY_ACTIVE_LOW ? (enabled ? LOW : HIGH) : (enabled ? HIGH : LOW);
}
void setOutputsSafe() {
for (uint8_t pin : CONTROL_PINS) {
digitalWrite(pin, outputLevel(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, outputLevel(enabled));
gpioStates[pin] = enabled;
return true;
}
bool get_gpio_status(uint8_t pin) {
return pin < GPIO_STATE_COUNT && isControlPin(pin) && 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)) return false;
return mqtt.connected() && mqtt.publish(topic, payload, retained);
}
void publishLog(const char *level, const char *code, const char *detail) {
if (!mqtt.connected()) return;
JsonDocument doc;
doc["event"] = "device_log";
doc["logLevel"] = level;
doc["logCode"] = code;
doc["logDetail"] = detail;
doc["freeHeap"] = ESP.getFreeHeap();
doc["rssi"] = WiFi.RSSI();
publishJson(TELEMETRY_TOPIC, doc);
}
void publishGpioState(uint8_t pin) {
JsonDocument doc;
char key[16];
snprintf(key, sizeof(key), "gpio%u", pin);
doc[key] = get_gpio_status(pin);
publishJson(TELEMETRY_TOPIC, doc);
}
void publishClientAttributes() {
JsonDocument doc;
doc["board"] = "ESP8266";
doc["sensor"] = "DHT22";
doc["firmware"] = "thingsboard-relay-1";
doc["relayPin"] = RELAY_PIN;
doc["relayActiveLow"] = RELAY_ACTIVE_LOW;
publishJson(ATTRIBUTES_TOPIC, doc);
}
void sendRpcResponse(const char *requestId, bool success, uint8_t pin,
bool enabled, const char *error = nullptr) {
char responseTopic[96];
const int written = snprintf(responseTopic, sizeof(responseTopic), "%s%s",
RPC_RESPONSE_PREFIX, requestId);
if (written <= 0 || static_cast<size_t>(written) >= sizeof(responseTopic)) return;
JsonDocument response;
response["success"] = success;
if (success) {
response["pin"] = pin;
response["enabled"] = enabled;
} else {
response["error"] = error == nullptr ? "invalid_request" : error;
}
publishJson(responseTopic, 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 (*requestId == '\0' || strchr(requestId, '/') != nullptr) 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 error = deserializeJson(request, input, length);
if (error) {
sendRpcResponse(requestId, false, 0, false, "malformed_json");
publishLog("warn", "rpc_json", error.c_str());
return;
}
if (!request["method"].is<const char *>() ||
strcmp(request["method"].as<const char *>(), "setGpioStatus") != 0) {
sendRpcResponse(requestId, false, 0, false, "unsupported_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 enabled = params["enabled"].as<bool>();
if (!setGpioStatus(pin, enabled)) {
sendRpcResponse(requestId, false, pin, enabled, "gpio_write_failed");
return;
}
sendRpcResponse(requestId, true, pin, get_gpio_status(pin));
publishGpioState(pin);
publishLog("info", "rpc_applied", enabled ? "relay_enabled" : "relay_disabled");
}
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;
lastWiFiAttemptAt = now;
wifiAttempted = true;
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) < mqttBackoffMs) return;
lastMqttAttemptAt = now;
char clientId[32];
snprintf(clientId, sizeof(clientId), "esp8266-%06X", ESP.getChipId());
if (!mqtt.connect(clientId, THINGSBOARD_TOKEN, nullptr)) {
mqttBackoffMs = min(mqttBackoffMs * 2U, MQTT_BACKOFF_MAX_MS);
return;
}
mqttBackoffMs = MQTT_BACKOFF_MIN_MS;
if (!mqtt.subscribe(RPC_REQUEST_TOPIC)) {
publishLog("error", "rpc_subscribe", "subscription_failed");
}
// Fail-safe policy: every broker session starts with the actuator disabled.
setOutputsSafe();
publishClientAttributes();
publishGpioState(RELAY_PIN);
publishLog("info", "mqtt_connected", "safe_output_restored");
}
void publishTelemetry() {
if (!mqtt.connected()) return;
const float humidity = dht.readHumidity();
const float temperature = dht.readTemperature();
if (isnan(humidity) || isnan(temperature)) {
publishLog("warn", "dht_read", "temperature_or_humidity_is_nan");
return;
}
JsonDocument doc;
doc["temperature"] = temperature;
doc["humidity"] = humidity;
doc["relayEnabled"] = get_gpio_status(RELAY_PIN);
doc["rssi"] = WiFi.RSSI();
doc["freeHeap"] = ESP.getFreeHeap();
if (!publishJson(TELEMETRY_TOPIC, doc)) {
// Do not recursively call publishLog() when the publish itself failed.
}
}
void setup() {
setOutputsSafe();
dht.begin();
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();
const uint32_t now = millis();
if (static_cast<uint32_t>(now - lastTelemetryAt) >= TELEMETRY_INTERVAL_MS) {
lastTelemetryAt = now;
publishTelemetry();
}
}
yield();
}
How the implementation works
Non-blocking Wi-Fi and MQTT reconnect
connectWiFi() starts at most one Wi-Fi attempt per 15 seconds. connectMQTT() makes one broker attempt when its timer expires and doubles the interval to a maximum of 60 seconds after a failure. Neither function contains a retry loop. This leaves time for the Wi-Fi stack and watchdog.
PubSubClient’s individual TCP connection attempt can still wait for the underlying socket timeout. Production firmware can additionally set appropriate client timeouts for its network conditions, but it should not wrap connection calls in an unbounded loop.
Safe relay state
gpioStates stores logical enabled/disabled state. outputLevel() translates it to the electrical level for active-low or active-high hardware. On boot and after every new MQTT session, setOutputsSafe() disables the relay. That deliberate fail-safe policy means an MQTT reconnect can stop the controlled load.
If the application must resume a previous state, do not blindly retain a command. Design an authenticated desired-state mechanism with machine interlocks, freshness checks, and a local safety policy.
RPC validation and response
handleRpcRequest() rejects oversized input, malformed JSON, unknown methods, incorrectly typed parameters, and any GPIO outside CONTROL_PINS. The callback copies the payload into a bounded buffer because MQTT payloads are not guaranteed to be null terminated. A successful response contains the logical state read from get_gpio_status().
Structured diagnostics
publishLog() sends bounded JSON with an event name, severity, code, detail, RSSI, and free heap. This is easier to filter than uncontrolled serial strings. Keep diagnostic volume low because every telemetry key can add storage and network load. The same principle applies on the server side; see structured logging with request IDs.
Build and upload
- Install the ESP8266 board package using the maintained ESP8266 Arduino core instructions.
- Install PubSubClient, ArduinoJson, DHT Sensor Library, and Adafruit Unified Sensor through Library Manager.
- Copy
secrets.example.htosecrets.hand enter local credentials. - Select the exact board and flash size. Compile before connecting a relay load.
- Upload with only the low-voltage sensor and safely powered relay module connected.
Library APIs change. If using a future major ArduinoJson release, check its migration notes before changing the code.
Create a ThingsBoard dashboard
Add temperature and humidity widgets
- Open the device and confirm
temperatureandhumidityunder Latest telemetry. - Create a dashboard and add the ESP8266 device as an entity alias.
- Add time-series widgets for
temperatureandhumidity. - Add cards for
rssi,freeHeap, andrelayEnabledif operational visibility is useful.
Add an RPC relay switch
Configure an RPC control widget to call setGpioStatus. Its parameters must be an object with GPIO5 and a Boolean state, for example {"pin":5,"enabled":true}. Widget configuration differs across ThingsBoard releases, so select a server-side RPC widget available in your installed CE version and inspect its generated request before connecting a physical load.
Test telemetry safely
- Power the ESP8266 with the controlled load disconnected.
- In ThingsBoard, verify that the device changes to the online state after MQTT connects.
- Open Latest telemetry. Expected keys are
temperature,humidity,relayEnabled,rssi, andfreeHeap. - Wait longer than the 10-second sampling interval before concluding telemetry has failed.
These are expected observations, not claimed results from your hardware. Sensor accuracy and timing must be verified on the real assembly.
Test RPC control and response
- Keep the high-current or mains load disconnected. Use an LED on an appropriate module or measure the safe low-voltage control signal.
- Send
setGpioStatuswith{"pin":5,"enabled":true}. - Confirm the widget receives a JSON response with
success,pin, andenabled. - Confirm
gpio5andrelayEnabledtelemetry reflect the logical state. - Try a disallowed pin. The expected response is
pin_not_allowed, with no GPIO change.
Verify reconnection and fail-safe behavior
- Enable the relay only with a safe test load.
- Temporarily disable the Wi-Fi access point. The main loop should continue rather than remain inside a retry loop.
- Restore Wi-Fi. MQTT attempts should spread out according to the backoff and reconnect automatically.
- After a new MQTT session, verify the relay returns to disabled and ThingsBoard receives
gpio5=false. - Restart the ESP8266 and verify the output is disabled before reconnecting.
Observe this on physical hardware before deployment. Different relay boards can pulse during ESP8266 reset regardless of application logic, which is a hardware design problem requiring a pull-up/pull-down, driver redesign, or different GPIO.
Common errors and fixes
| Symptom | Likely cause | What to check |
|---|---|---|
| Wi-Fi never connects | Wrong SSID/password, weak 2.4 GHz signal, unsupported network mode | Verify credentials locally, RSSI, DHCP capacity, and that the AP offers a compatible 2.4 GHz network. |
| MQTT connection refused | Wrong host/port, listener disabled, firewall, or broker rejection | Confirm the ThingsBoard MQTT listener and network path from the device VLAN. Do not use the web UI port as MQTT. |
| Incorrect access token | Token was copied incorrectly, rotated, or belongs to another device | Reopen device credentials, update only secrets.h, and rotate any exposed token. |
| Device appears offline | MQTT session is not established or immediately drops | Check listener reachability, client ID uniqueness, token, broker logs, power stability, and Wi-Fi quality. |
| Telemetry does not appear | Wrong topic, failed publish, DHT NaN, or dashboard alias mismatch | Check Latest telemetry before the dashboard, the exact topic, DHT wiring, and the 10-second interval. |
| RPC arrives but relay does not switch | Pin blocked, polarity wrong, insufficient driver power, or GPIO label confusion | Use GPIO number 5, inspect the RPC response, test the low-voltage input, and verify active-low/high configuration. |
| RPC response missing | Wrong request ID or response topic | Extract the ID from .../request/{requestId} and publish to .../response/{requestId}. |
malformed_json |
Invalid JSON or truncated/oversized payload | Send a JSON object with quoted keys, integer pin, and Boolean enabled; stay below the configured bound. |
| Repeated reconnect loop | Blocking retry logic, invalid credentials, DNS, or unstable power | Keep one timed attempt per pass, preserve the backoff, inspect broker rejects, and verify the 3.3 V supply under Wi-Fi current peaks. |
| Watchdog reset | Long callback, delay loop, blocking I/O, or power issue | Keep callbacks short, call mqtt.loop() frequently, avoid unbounded loops, retain yield(), and validate the regulator. |
| Boot loop after adding relay | Boot-strap GPIO held at an invalid level or supply sag | Move the signal away from GPIO0/2/15, verify pull resistors and the relay supply, and test with the module disconnected. |
| DHT returns NaN | Wiring, pull-up, insufficient sample spacing, sensor power, or wrong sensor type | Verify DATA/VCC/GND, DHT22 selection, pull-up, and at least a two-second interval. |
| Relay operates in reverse | Active-low module | Set RELAY_ACTIVE_LOW=true; verify disabled level before attaching a load. |
Security and production hardening
- Use TLS: move from plain MQTT on 1883 to the TLS listener, commonly 8883, configured by your ThingsBoard deployment. Use
BearSSL::WiFiClientSecure, validate the broker certificate with a trusted CA, and keep ESP8266 time synchronized for certificate validation. Never use an insecure “accept any certificate” mode in production. - Isolate devices: place IoT clients in a restricted VLAN and allow only necessary DNS, NTP, and ThingsBoard endpoints.
- Protect credentials: use a distinct token per device, rotate on exposure, and do not bake production secrets into a public firmware repository.
- Constrain commands: retain the method check, strict types, payload bound, and
CONTROL_PINSallowlist. Do not expose arbitrary GPIO writes. - Rate and audit: alert on excessive RPC, reconnect, and authentication failures. For adjacent server-side controls, follow a layered secure API design.
- Design fail-safe hardware: application code cannot replace electrical interlocks, over-current protection, emergency stops, or qualified control design.
Final verification checklist
- ThingsBoard device exists and its token is stored only in ignored
secrets.h. - Broker hostname and listener are correct; production traffic uses certificate-validated TLS.
- DHT22 reports plausible values at the controlled interval.
- Relay pin is in
CONTROL_PINS; arbitrary pins are rejected. - Active-low/high behavior is verified with the real module and no hazardous load.
- RPC response uses the incoming request ID and reflects logical state.
- Wi-Fi and MQTT recovery work without an unbounded loop.
- Boot and MQTT reconnect put the output in the defined disabled state.
- Power, isolation, flyback protection, grounding, and boot-strap behavior are reviewed for the actual PCB.
Frequently asked questions
Which MQTT username and password does ThingsBoard use for an access-token device?
The access token is the MQTT username and the password is empty for the standard access-token credential flow. If your deployment uses another credential type, follow that ThingsBoard credential flow instead.
Why does the RPC response need the request ID?
ThingsBoard correlates the response with the server-side request by its topic suffix. A response on a fixed or mismatched topic cannot be correlated correctly.
Can ThingsBoard control any ESP8266 pin with this code?
No. Only GPIOs explicitly listed in CONTROL_PINS are accepted. This prevents a remote request from changing boot pins, flash pins, or unrelated outputs.
Why is the relay disabled after MQTT reconnect?
It is a deliberate fail-safe policy: every new broker session starts from a known disabled state. Change this only after a safety review and implementation of a trustworthy desired-state mechanism.
Can I connect a pump or mains relay directly to GPIO5?
No. The GPIO is a logic signal only. Use an appropriate driver or isolated relay module and have hazardous-voltage work designed and installed by a qualified person.
Why not use long delay calls for reconnection?
Long blocking delays prevent timely MQTT processing, sensor scheduling, and watchdog servicing. Timed attempts with backoff keep the main loop responsive and reduce load on the broker.
Official references and next guides
- ThingsBoard MQTT device API
- ThingsBoard MQTT RPC API
- PubSubClient source and documentation
- ArduinoJson 7 documentation
- Adafruit DHT sensor library
No existing LAPVN article currently covers ThingsBoard MQTT setup directly. Useful future companion guides would be Install ThingsBoard CE on Ubuntu with Docker Compose and Secure ThingsBoard MQTT with TLS and Device Certificates.