Monitor Raspberry Pi and ESP32 Devices with MQTT

Ganesh Velrajan Ganesh Velrajan • 12 Min Read • Published on Nov 12, 2026
Monitor Raspberry Pi and ESP32 Devices with MQTT

Short answer: Publish a small JSON health message from each device every 30 seconds, with a retained Last Will on a status topic. On a Raspberry Pi, add Pi-specific signals that generic agents miss: under-voltage and throttling from vcgencmd get_throttled, Wi-Fi RSSI, and a read-only root filesystem (a dying SD card). On an ESP32, publish free heap, minimum free heap, RSSI, uptime and the reset reason. ESP32s can’t run Linux tools, so at most sites a Raspberry Pi acts as the gateway. It runs Mosquitto locally and bridges the site’s telemetry to your central broker.

In this guide

  • The health metrics that predict Raspberry Pi and ESP32 failures in the field
  • Python collectors for Pi throttling, under-voltage, Wi-Fi signal and SD card health
  • A hardened systemd service for the Pi agent
  • A Raspberry Pi gateway with a Mosquitto bridge, and gateway-offline detection
  • A complete ESP32 sketch: Last Will, boot reason and heap telemetry
  • An alert table tuned for Pi and ESP32 hardware

Get the code: the Pi collectors (device/pi_metrics.py), the gateway bridge config and a simulated ESP32 are in the mqtt-iot-monitoring folder of ampaslabs/socketxp-blog-tutorials on GitHub. docker compose --profile gateway up -d runs the whole Pattern B setup on your laptop.

This guide is part of the MQTT IoT Monitoring series. It assumes the central Mosquitto, Telegraf, InfluxDB and Grafana stack from that guide is running.

Two Deployment Patterns

 Pattern A: Pi talks to the central broker directly
 ─────────────────────────────────────────────────
   Raspberry Pi ──MQTT──► Central Mosquitto ──► Telegraf ──► InfluxDB ──► Grafana

 Pattern B: Pi gateway per site, ESP32s behind it
 ─────────────────────────────────────────────────
   ESP32 ─┐
   ESP32 ─┼─MQTT─► Pi gateway (local Mosquitto) ══bridge══► Central Mosquitto ──► ...
   ESP32 ─┘        + its own health agent
                   + SocketXP agent (remote access, OTA updates)

Pattern A suits Pis with their own internet connection. Pattern B suits sites with many microcontrollers. Local devices keep working if the internet link drops, the site needs only one outbound connection, and the gateway gives you a Linux box on site that you can manage, update and reach remotely. The same layout is used in Remotely Access ESP32 Microcontroller over the Internet.

What to Monitor

SignalRaspberry PiESP32What it predicts
CPU / memory / disk✅ psutilFree heap onlyOverload, memory leaks, full disk
Temperature✅ cpu_thermalRarely useful (internal sensor is inaccurate)Thermal throttling, enclosure problems
Under-voltage✅ vcgencmd get_throttledShows up as brownout resetsBad PSU or cable: the #1 cause of random Pi faults
Throttling✅ vcgencmd get_throttledn/aSlow app, missed deadlines
Read-only root FS✅ /proc/mountsn/aFailing SD card
Wi-Fi RSSI✅ /proc/net/wireless✅ WiFi.RSSI()Dropouts, reconnect storms
Minimum free heapn/a✅ ESP.getMinFreeHeap()Memory leak, before it crashes
Reset reasonvia uptime reset✅ esp_reset_reason()Brownouts, watchdog timeouts, panics
Uptime✅✅ millis()Silent reboots
Firmware version✅✅Which build is misbehaving

Raspberry Pi: Pi-Specific Collectors

Add these functions to the Python agent from the main guide and merge their output into the telemetry payload.

import subprocess


def pi_throttled():
    """Decode `vcgencmd get_throttled` into flags (1 = set)."""
    try:
        out = subprocess.run(
            ["vcgencmd", "get_throttled"], capture_output=True, text=True, timeout=2
        ).stdout                                   # e.g. "throttled=0x50005"
        value = int(out.strip().split("=")[1], 16)
    except (OSError, IndexError, ValueError, subprocess.TimeoutExpired):
        return {}
    return {
        "undervolt_now":   int(bool(value & 0x1)),
        "throttled_now":   int(bool(value & 0x4)),
        "undervolt_boot":  int(bool(value & 0x10000)),   # occurred since boot
        "throttled_boot":  int(bool(value & 0x40000)),
    }


def wifi_rssi(iface="wlan0"):
    """Signal level in dBm, or None when not on Wi-Fi."""
    try:
        with open("/proc/net/wireless") as f:
            for line in f:
                if line.strip().startswith(iface + ":"):
                    return float(line.split()[3].rstrip("."))
    except OSError:
        pass
    return None


def root_readonly():
    """1 if / is mounted read-only (often a failing SD card)."""
    readonly = 0
    with open("/proc/mounts") as f:
        for line in f:
            _dev, mountpoint, _fstype, opts = line.split()[:4]
            if mountpoint == "/":
                readonly = int("ro" in opts.split(","))
    return readonly

Then, in the agent’s main loop:

payload.update(pi_throttled())
payload["root_ro"] = root_readonly()
rssi = wifi_rssi()
if rssi is not None:
    payload["rssi"] = rssi
payload["fw"] = os.getenv("FW_VERSION", "unknown")

How to read the throttled bits:

BitHexMeaning
00x1Under-voltage detected now
10x2ARM frequency capped now
20x4Currently throttled
30x8Soft temperature limit active
160x10000Under-voltage has occurred since boot
170x20000Frequency capping has occurred
180x40000Throttling has occurred
190x80000Soft temperature limit has occurred

throttled=0x50005 therefore means “under-voltage and throttled now, and both have happened since boot”. That’s a power supply problem, not a software one. The “since boot” bits matter because under-voltage often lasts only milliseconds, under a load spike, and would be missed by a 30-second sample of the “now” bits.

Run the Pi Agent as a systemd Service

Raspberry Pi OS (Bookworm and later) blocks pip install into the system Python, so use a virtual environment:

sudo useradd --system --no-create-home --groups video mqttagent
sudo mkdir -p /opt/mqtt-agent
sudo python3 -m venv /opt/mqtt-agent/venv
sudo /opt/mqtt-agent/venv/bin/pip install paho-mqtt psutil
sudo cp device_agent.py /opt/mqtt-agent/

The video group grants access to /dev/vcio, which vcgencmd needs. Without it, the throttled fields are silently missing.

Put the per-device settings in /etc/mqtt-agent.env, readable only by root (systemd reads it before dropping privileges):

MQTT_HOST=127.0.0.1
MQTT_PORT=1883
SITE=site1
DEVICE_ID=pi-gw-01
MQTT_PASSWORD=change-me
FW_VERSION=2026.11.1
sudo chmod 600 /etc/mqtt-agent.env

/etc/systemd/system/mqtt-agent.service

[Unit]
Description=MQTT device health agent
After=network-online.target
Wants=network-online.target

[Service]
User=mqttagent
Group=mqttagent
SupplementaryGroups=video
EnvironmentFile=/etc/mqtt-agent.env
ExecStart=/opt/mqtt-agent/venv/bin/python /opt/mqtt-agent/device_agent.py
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mqtt-agent
journalctl -u mqtt-agent -f

ProtectSystem=strict makes the filesystem read-only for the agent. That’s fine, because it only reads /proc and talks to the network.

The Raspberry Pi as a Site Gateway (Mosquitto Bridge)

In Pattern B, the Pi runs its own Mosquitto broker for the site’s ESP32s and forwards everything to the central broker over one bridge connection.

sudo apt install mosquitto mosquitto-clients

/etc/mosquitto/conf.d/gateway.conf

# Local listener for devices on the site network
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd

# Bridge to the central monitoring broker
connection central
address mqtt.example.com:8883     # central broker, TLS
bridge_cafile /etc/mosquitto/certs/ca.crt
remote_clientid site1-gateway
remote_username site1-gateway
remote_password change-me-gateway
topic fleet/site1/# out 1
cleansession false

# Publish 1/0 on this topic when the bridge connects/disconnects.
# Mosquitto also registers "0" as the bridge's Last Will on the central broker.
notifications true
notification_topic fleet/site1/pi-gw-01/bridge

Create local credentials for each ESP32 and for the Pi’s own agent:

sudo mosquitto_passwd -c /etc/mosquitto/passwd pi-gw-01
sudo mosquitto_passwd /etc/mosquitto/passwd esp32-node-01
sudo systemctl restart mosquitto

On the central broker, let the gateway publish only its own site’s topics:

user site1-gateway
topic write fleet/site1/#

Things to know about bridges:

  • Detecting gateway outages. When the whole site loses internet, the ESP32s stay connected to the local broker, so their Last Wills never fire. The central broker sees nothing from that site. The bridge notification topic covers this: it changes to 0 on the central broker when the gateway drops off. Alert on it, and treat “bridge down” as “site unreachable”, not “40 devices offline”.
  • Buffering during outages. With cleansession false, the local broker queues QoS 1 messages for the bridge (up to max_queued_messages, 1,000 by default) and delivers them on reconnect. We tested this by stopping the central broker: QoS 1 messages arrived as soon as the bridge reconnected. QoS 0 telemetry published during the outage is dropped unless you set queue_qos0_messages true.
  • Securing the bridge. The gateway dials out to the central broker over TLS on port 8883, so the site needs no inbound firewall rules. For per-gateway identity, add bridge_certfile and bridge_keyfile so the bridge authenticates with a client certificate (mTLS). If your central broker isn’t reachable from the internet at all, the SocketXP agent already on the gateway can forward the bridge to it instead (see Remote Access MQTT Broker Behind NAT or Firewall).

ESP32: Health Telemetry Sketch

This Arduino-core sketch uses PubSubClient (no JSON library needed). It:

  • Registers a retained offline Last Will
  • Publishes a boot event with the reset reason, once per boot
  • Sends heap, minimum heap, RSSI, uptime and firmware version every 30 seconds
#include <WiFi.h>
#include <PubSubClient.h>
#include "esp_system.h"

#define FW_VERSION "1.0.3"

const char* WIFI_SSID = "site-wifi";
const char* WIFI_PASS = "change-me";
const char* MQTT_HOST = "192.168.1.10";      // the site gateway's Mosquitto
const char* MQTT_PASS = "change-me-esp32";
const char* SITE      = "site1";
const char* DEVICE_ID = "esp32-node-01";
const unsigned long INTERVAL_MS = 30000;

char topicStatus[64], topicTelemetry[64], topicEvent[64];
WiFiClient net;
PubSubClient mqtt(net);
unsigned long lastSend = 0;
bool bootReported = false;

const char* resetReason() {
  switch (esp_reset_reason()) {
    case ESP_RST_POWERON:   return "power_on";
    case ESP_RST_SW:        return "software";
    case ESP_RST_PANIC:     return "panic";
    case ESP_RST_INT_WDT:
    case ESP_RST_TASK_WDT:
    case ESP_RST_WDT:       return "watchdog";
    case ESP_RST_BROWNOUT:  return "brownout";
    case ESP_RST_DEEPSLEEP: return "deep_sleep";
    default:                return "other";
  }
}

void ensureWifi() {
  if (WiFi.status() == WL_CONNECTED) return;
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500);
}

bool ensureMqtt() {
  if (mqtt.connected()) return true;
  // connect(id, user, pass, willTopic, willQos, willRetain, willMessage)
  if (!mqtt.connect(DEVICE_ID, DEVICE_ID, MQTT_PASS, topicStatus, 1, true, "offline")) {
    return false;
  }
  mqtt.publish(topicStatus, "online", true);   // retained
  return true;
}

void setup() {
  snprintf(topicStatus, sizeof topicStatus, "fleet/%s/%s/status", SITE, DEVICE_ID);
  snprintf(topicTelemetry, sizeof topicTelemetry, "fleet/%s/%s/telemetry", SITE, DEVICE_ID);
  snprintf(topicEvent, sizeof topicEvent, "fleet/%s/%s/event", SITE, DEVICE_ID);

  WiFi.mode(WIFI_STA);
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setKeepAlive(60);
  mqtt.setBufferSize(512);   // default 256 bytes is too small for most JSON payloads
}

void loop() {
  ensureWifi();
  if (!ensureMqtt()) {
    delay(5000);
    return;
  }
  mqtt.loop();

  if (!bootReported) {
    char boot[128];
    snprintf(boot, sizeof boot,
             "{\"event\":\"boot\",\"count\":1,\"reset_reason\":\"%s\",\"fw\":\"%s\"}",
             resetReason(), FW_VERSION);
    bootReported = mqtt.publish(topicEvent, boot);
  }

  if (millis() - lastSend >= INTERVAL_MS) {
    lastSend = millis();
    char payload[192];
    snprintf(payload, sizeof payload,
             "{\"heap\":%lu,\"heap_min\":%lu,\"rssi\":%d,\"uptime\":%lu,\"interval\":%lu,\"fw\":\"%s\"}",
             (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
             (int)WiFi.RSSI(), (unsigned long)(millis() / 1000),
             (unsigned long)(INTERVAL_MS / 1000), FW_VERSION);
    mqtt.publish(topicTelemetry, payload);
  }
}

Notes:

  • setBufferSize(512) matters. PubSubClient’s default 256-byte buffer silently drops larger messages, and publish() returns false.
  • uptime and interval let the heartbeat watchdog in Detect Offline IoT Devices with MQTT Last Will and Heartbeats handle ESP32s and Pis on different schedules.
  • The reset reason is sent once per boot, not in every message. A reset counter in InfluxDB is more useful than repeating the same string every 30 seconds.
  • The boot event can be lost on a cold start. PubSubClient only publishes at QoS 0. When a whole site powers up together, the ESP32 can send its boot event before the gateway’s bridge has connected, and the gateway drops QoS 0 messages for a disconnected bridge. We saw exactly this in testing. If boot events matter to you, use a client that supports QoS 1 publishes (ESP-IDF’s esp-mqtt or the arduino-mqtt library). The gateway then queues the event and delivers it when the bridge connects, which we also verified. Either way, the watchdog’s uptime check still catches the reboot.
  • For TLS or mTLS, replace WiFiClient with WiFiClientSecure and load the CA, certificate and key. See ESP32 mTLS Client Certificate Tutorial. On a local site network behind the gateway, plain MQTT plus passwords is a common trade-off, as long as the bridge to the central broker is encrypted.

Store boot events by adding one more input to Telegraf (and allowing fleet/+/+/event in its ACL):

[[inputs.mqtt_consumer]]
  servers = ["tcp://mosquitto:1883"]
  topics = ["fleet/+/+/event"]
  qos = 0
  client_id = "telegraf-events"
  username = "telegraf"
  password = "${MQTT_TELEGRAF_PASSWORD}"
  data_format = "json"
  tag_keys = ["event", "reset_reason", "fw"]

  [[inputs.mqtt_consumer.topic_parsing]]
    topic = "fleet/+/+/event"
    measurement = "_/_/_/measurement"
    tags = "_/site/device_id/_"

The text keys become tags, and the numeric count field gives each point a value. Chart sum(count) grouped by reset_reason to see brownouts and watchdog resets across the fleet.

Alerts Tuned for Pi and ESP32 Hardware

AlertConditionLikely causeFirst fix
Pi under-voltageundervolt_boot = 1Weak PSU, long or thin USB cableOfficial PSU, shorter cable
Pi throttlingthrottled_now = 1 for 10 minHeat, no heatsink or fanCooling, enclosure airflow
Pi SD card failingroot_ro = 1Worn or corrupt SD cardReplace the card; consider an SSD or read-only overlay FS
Pi disk fulldisk > 90 %Logs or containers growingLog rotation, journalctl --vacuum-size
Weak Wi-Firssi < −75 dBm for 15 minDistance, interference, enclosureAntenna, access point placement
ESP32 memory leakheap_min falling day over dayLeak in firmwareFix the leak; OTA update
ESP32 brownoutboot event reset_reason = brownoutPower dips under Wi-Fi TX loadBigger capacitor, better regulator
ESP32 crash loop≥ 3 boot events in 1 hourPanic or watchdog in firmwareRoll back firmware
Site gateway downbridge topic = 0 for 5 minSite internet or power outageContact site; check modem

The heap_min trend is the most valuable ESP32 signal. Free heap jumps around with every Wi-Fi operation, but the low-water mark only ever goes down. If it keeps falling across days, the device will eventually crash, and you can see it coming a week ahead.

Grafana chart of ESP32 minimum free heap steadily decreasing, an early warning of a memory leak

A falling minimum free heap on an ESP32 (simulated here). The trend is the warning sign; on a real device this slope plays out over days.

How SocketXP Complements Your Pi and ESP32 Monitoring

An alert table is only useful if you can act on what it tells you. MQTT tells you a Pi is under-volted or an ESP32 is leaking memory; SocketXP, an IoT device management and remote access platform, is how you fix it across the fleet:

  • Raspberry Pi: SSH into the Pi remotely even behind NAT or CGNAT, to inspect dmesg for under-voltage events, run fsck, or rotate logs. Roll the updated agent or app out to every Pi with an OTA update: a pilot group first, then the fleet, with per-device status.
  • ESP32 behind a Pi gateway: SSH into the gateway, then reflash or OTA the ESP32 from there with esptool.py or the ESP32’s HTTP OTA. Details in Remotely Access ESP32 over the Internet.
  • Independent device alerts: the SocketXP agent on each Pi also sends its own online/offline, CPU, memory and log-pattern alerts by webhook. They still work when the MQTT pipeline itself is the thing that’s broken.
  • Pi 5 and Jetson AIoT nodes: add inference_ms and model_version to the payload, and push new models with OTA updates for AI/ML models.

For the full side-by-side comparison, see How SocketXP complements your MQTT monitoring setup.

Next Steps

Frequently Asked Questions

  1. How do I monitor a Raspberry Pi with MQTT?

    Run a small Python agent with paho-mqtt and psutil on the Pi that publishes CPU, memory, disk, temperature and Pi-specific health (vcgencmd get_throttled flags, Wi-Fi RSSI, read-only filesystem) as JSON to a topic like fleet/site1/pi-01/telemetry every 30 seconds. Run it as a systemd service and collect the data with Telegraf, InfluxDB and Grafana.

  2. How do I detect Raspberry Pi under-voltage remotely?

    Run 'vcgencmd get_throttled'. Bit 0 (0x1) means under-voltage right now and bit 16 (0x10000) means under-voltage has occurred since boot. Publish both flags over MQTT and alert when either is set. The usual fix is a better power supply or a shorter, thicker USB cable.

  3. What should I monitor on an ESP32?

    Free heap and minimum free heap (to catch memory leaks), Wi-Fi RSSI, uptime (to spot reboots), the reset reason at boot (brownout, watchdog or panic) and the firmware version. Publish them as a small JSON message every 30 seconds with a retained Last Will on a status topic.

  4. Why is my Raspberry Pi SD card going read-only?

    When the SD card or its filesystem develops errors, Linux often remounts the root filesystem read-only to prevent further damage. Logs stop being written and apps fail in odd ways. Check /proc/mounts for an 'ro' flag on / and alert on it, then plan to replace the card.

  5. Can an ESP32 run the SocketXP agent?

    No. The ESP32 runs FreeRTOS, not Linux, so it can't run the SocketXP agent. The common pattern is a Linux gateway such as a Raspberry Pi on the same network: the ESP32 publishes MQTT to the gateway's Mosquitto, and the gateway runs SocketXP for remote access and OTA updates (including reflashing the ESP32 from the gateway) while Mosquitto bridges the telemetry to the central broker.

  6. What is a Mosquitto bridge?

    A Mosquitto bridge is a broker-to-broker connection. A local broker (for example on a site gateway) subscribes to or forwards selected topics to a remote broker. With 'topic fleet/site1/# out 1' every message from local devices under fleet/site1/ is forwarded to the central broker over one connection.

SocketXP IoT Remote Access and Device Management Platform

Remotely access, manage, and update your IoT & AIoT edge fleet with SocketXP's secure and scalable platform.

Start Your Free Trial Now!

Join thousands of satisfied users who trust SocketXP for a secure, reliable, and scalable IoT Edge device management solution. Start your free trial now.