Ein ESP32-PlatformIO-MQTT-Projekt kann weit mehr sein als ein einzelner Sketch mit fest eingetragenen Zugangsdaten. In dieser Anleitung richten Sie ein PlatformIO-Projekt mit einer Entwicklungsumgebung ein, die auf broker.connectmq.com zeigt, und mit einer Produktionsumgebung mit TLS und privaten Zugangsdaten – Secrets bleiben außerhalb von Git, und Bibliotheksversionen sind für reproduzierbare Builds fest gepinnt.
Warum PlatformIO für MQTT-Firmware
Für einen ersten Sketch genügt die Arduino IDE. Sobald ein Projekt mehr als eine Datei, mehr als ein Ziel oder mehr als einen Entwickler hat, lohnt sich PlatformIO:
- Reproduzierbare Builds. Plattform- und Bibliotheksversionen stehen in
platformio.ini, sodass ein Kollege oder ein CI-Server genau das baut, was Sie gebaut haben. - Mehrere Umgebungen. Ein Projekt kann aus demselben Quellcode Firmware für den öffentlichen MQTT-Broker in der Entwicklung und einen privaten Produktions-Broker oder für verschiedene Boards erzeugen.
- Echte Projektstruktur. Header in
include/, Code auf mehrere Dateien insrc/verteilt, wiederverwendbare Module inlib/und Tests intest/. - Builds auf der Kommandozeile.
pio runfunktioniert in VS Code, im Terminal und in einer CI-Pipeline gleich.
Projektaufbau
Legen Sie ein neues Projekt für ein ESP32 DevKit mit dem Arduino-Framework an und strukturieren Sie es so:
esp32-mqtt/
platformio.ini
secrets.ini (not committed)
secrets.example.ini (committed template)
.gitignore
include/
config.h
src/
main.cpp
mqtt_link.cpp
mqtt_link.h
.gitignore hält lokale Secrets und Build-Ausgaben aus dem Repository fern:
.pio/
secrets.ini
Die platformio.ini mit Dev- und Prod-Umgebung
[platformio]
default_envs = dev
extra_configs = secrets.ini
[env]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.0.0
build_flags =
-D WIFI_SSID=\"${secrets.wifi_ssid}\"
-D WIFI_PASS=\"${secrets.wifi_pass}\"
-D FW_VERSION=\"1.0.0\"
[env:dev]
build_flags =
${env.build_flags}
-D MQTT_HOST=\"broker.connectmq.com\"
-D MQTT_PORT=1883
-D MQTT_USE_TLS=0
-D TOPIC_PREFIX=\"connectmq/demo/6d1f3c\"
-D CORE_DEBUG_LEVEL=3
[env:prod]
build_flags =
${env.build_flags}
-D MQTT_HOST=\"broker.connectmq.com\"
-D MQTT_PORT=8883
-D MQTT_USE_TLS=1
-D MQTT_CLIENT_ID=\"${secrets.mqtt_client_id}\"
-D MQTT_USER=\"${secrets.mqtt_user}\"
-D MQTT_PASS=\"${secrets.mqtt_pass}\"
-D TOPIC_PREFIX=\"factory1/line1/sensor01\"
-D CORE_DEBUG_LEVEL=1
Der gemeinsame Abschnitt [env] enthält alles, was für alle gilt. Jede benannte Umgebung erweitert build_flags um ihre eigenen Broker-Einstellungen. extra_configs = secrets.ini führt eine zweite Datei in die Konfiguration ein, sodass ${secrets.wifi_ssid} zu einem Wert aufgelöst wird, der nie in die Versionsverwaltung gelangt.
Die Datei secrets.ini:
[secrets]
wifi_ssid = YOUR_WIFI
wifi_pass = YOUR_WIFI_PASSWORD
mqtt_client_id = YOUR_CLIENT_ID
mqtt_user = YOUR_USERNAME
mqtt_pass = YOUR_PASSWORD
Committen Sie secrets.example.ini mit denselben Schlüsseln und Platzhalterwerten, damit neue Mitwirkende wissen, was sie eintragen müssen. Die Produktionswerte stammen aus dem ConnectMQ-Portal, in dem jeder Broker eine generierte Client ID, einen Benutzernamen und ein Passwort erhält.
In der CI können Sie auf die Datei verzichten und stattdessen mit ${sysenv.MQTT_PASS} Umgebungsvariablen lesen. Bedenken Sie, dass Build-Flags als Strings im Binary landen, sodass jeder mit der Firmware-Datei sie extrahieren kann. Für Geräte, die Sie an Dritte weitergeben, sollten Sie gerätespezifische Zugangsdaten bevorzugen, die zur Laufzeit bereitgestellt werden, zum Beispiel mit dem ESP32-WiFiManager-MQTT-Portal.
config.h: ein zentraler Ort für Standardwerte
Der Header gibt jeder Einstellung einen sicheren Standardwert und lässt den Build fehlschlagen, wenn etwas Wesentliches fehlt:
#pragma once
#ifndef MQTT_HOST
#define MQTT_HOST "broker.connectmq.com"
#endif
#ifndef MQTT_PORT
#define MQTT_PORT 1883
#endif
#ifndef MQTT_USE_TLS
#define MQTT_USE_TLS 0
#endif
#ifndef TOPIC_PREFIX
#error "TOPIC_PREFIX must be set in platformio.ini"
#endif
#if MQTT_USE_TLS && !defined(MQTT_PASS)
#error "Production builds need MQTT credentials from secrets.ini"
#endif
#ifndef FW_VERSION
#define FW_VERSION "dev"
#endif
Ein #error ist weit besser als Firmware, die kompiliert, ausgeliefert wird und sich dann stillschweigend mit dem falschen Ziel verbindet.
Das MQTT-Modul
Wenn die MQTT-Logik in einer eigenen Datei liegt, bleibt main.cpp kurz, und Sie können das Modul in anderen Projekten wiederverwenden.
src/mqtt_link.h:
#pragma once
#include <Arduino.h>
namespace mqtt_link {
void begin();
void loop();
bool publish(const char* subtopic, const char* payload, bool retain = false);
bool connected();
}
src/mqtt_link.cpp:
#include "mqtt_link.h"
#include "config.h"
#include <WiFi.h>
#include <PubSubClient.h>
#if MQTT_USE_TLS
#include <WiFiClientSecure.h>
#include "isrg_root_x1.h" // const char ISRG_ROOT_X1[] = R"EOF(...PEM...)EOF";
#endif
namespace {
#if MQTT_USE_TLS
WiFiClientSecure net;
#else
WiFiClient net;
#endif
PubSubClient client(net);
String clientId;
String statusTopic;
unsigned long nextAttempt = 0;
unsigned long backoffMs = 1000;
void onMessage(char* topic, byte* payload, unsigned int length) {
Serial.printf("[mqtt] %s: %.*s\n", topic, (int)length, (const char*)payload);
}
void tryConnect() {
bool ok;
#if MQTT_USE_TLS
ok = client.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS,
statusTopic.c_str(), 1, true, "offline");
#else
ok = client.connect(clientId.c_str(), statusTopic.c_str(), 1, true, "offline");
#endif
if (ok) {
client.publish(statusTopic.c_str(), "online", true);
client.subscribe((String(TOPIC_PREFIX) + "/cmd").c_str());
backoffMs = 1000;
Serial.println("[mqtt] connected");
} else {
Serial.printf("[mqtt] failed, state=%d\n", client.state());
nextAttempt = millis() + backoffMs;
backoffMs = min(backoffMs * 2, 60000UL);
}
}
} // namespace
namespace mqtt_link {
void begin() {
#if MQTT_USE_TLS
net.setCACert(ISRG_ROOT_X1);
clientId = MQTT_CLIENT_ID;
#else
clientId = "esp32-pio-" + String((uint32_t)ESP.getEfuseMac(), HEX);
#endif
statusTopic = String(TOPIC_PREFIX) + "/status";
client.setServer(MQTT_HOST, MQTT_PORT);
client.setBufferSize(512);
client.setSocketTimeout(5);
client.setCallback(onMessage);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) return;
if (client.connected()) {
client.loop();
} else if (millis() >= nextAttempt) {
tryConnect();
}
}
bool publish(const char* subtopic, const char* payload, bool retain) {
String topic = String(TOPIC_PREFIX) + "/" + subtopic;
return client.publish(topic.c_str(), payload, retain);
}
bool connected() { return client.connected(); }
} // namespace mqtt_link
Legen Sie include/isrg_root_x1.h mit dem Zertifikat ISRG Root X1 von Let's Encrypt als PEM-String an. Es wird nur in Produktions-Builds einkompiliert.
src/main.cpp:
#include <Arduino.h>
#include <WiFi.h>
#include <ArduinoJson.h>
#include "config.h"
#include "mqtt_link.h"
unsigned long lastPublish = 0;
void setup() {
Serial.begin(115200);
Serial.printf("Firmware %s, broker %s:%d\n", FW_VERSION, MQTT_HOST, MQTT_PORT);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
mqtt_link::begin();
}
void loop() {
mqtt_link::loop();
if (mqtt_link::connected() && millis() - lastPublish > 10000) {
lastPublish = millis();
JsonDocument doc;
doc["fw"] = FW_VERSION;
doc["uptime"] = millis() / 1000;
doc["rssi"] = WiFi.RSSI();
char buf[128];
serializeJson(doc, buf, sizeof(buf));
mqtt_link::publish("telemetry", buf);
}
}
Bauen, Flashen und Überwachen
# Entwicklungs-Build gegen den öffentlichen Testbereich
pio run -e dev -t upload
pio device monitor -e dev
# Produktions-Build mit TLS und Zugangsdaten aus dem Portal
pio run -e prod -t upload
# alle Umgebungen bauen, wie es die CI tun würde
pio run
Während der Dev-Build läuft, verfolgen Sie seine Topics von Ihrem Rechner aus:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-6d1f3c -t 'connectmq/demo/6d1f3c/#' -v
Die Umgebungseinstellungen im Überblick
| Einstellung | dev | prod |
|---|---|---|
| Broker | Öffentlicher MQTT-Broker broker.connectmq.com | Ihr privater ConnectMQ-Broker |
| Port und Transport | 1883, einfaches TCP | 8883, TLS mit ISRG Root X1 |
| Client ID | Aus der MAC-Adresse abgeleitet | Im Portal ausgegebene Client ID |
| Zugangsdaten | Keine | Benutzername und Passwort aus secrets.ini |
| Topic-Präfix | connectmq/demo/<random> |
Ihre eigene Hierarchie, etwa ein ISA-95-Pfad |
| Log-Level | Info | Nur Fehler |
Für Geräte in der Fertigung lohnt es sich, das Prod-Präfix von Anfang an am Unified Namespace (UNS) auszurichten: Die ISA-95-Hierarchie (Enterprise → Site → Area → Line → Cell) wird zur Topic-Struktur auf dem Broker, etwa acme/werk-muenchen/montage/linie-1/zelle-3. Das factory1/line1/sensor01 aus der Beispielkonfiguration ist eine verkürzte Form davon; wie Sie die Ebenen sauber benennen, zeigt der Beitrag zu Sparkplug B und Unified Namespace.
Warum sich diese Struktur auszahlt
Bei dieser Aufteilung geht es nicht um Ordnung um ihrer selbst willen. Jedes Teil erfüllt eine Aufgabe, die einen echten Unterschied macht, sobald das Projekt Ihren Schreibtisch verlässt.
Die Umgebungen bilden die Absicht ab. Niemand muss vor einem Release daran denken, einen Port zu ändern, WiFiClient gegen WiFiClientSecure zu tauschen und Zugangsdaten einzufügen. Die Wahl von prod erledigt all das konsistent, und die #error-Prüfungen in config.h stoppen einen halb konfigurierten Build, bevor er ein Gerät erreicht. Die Idee lässt sich leicht erweitern: Fügen Sie eine staging-Umgebung hinzu, die auf einen zweiten privaten Broker zeigt, oder eine Umgebung pro Board-Typ mit einer anderen Zeile board =.
Das MQTT-Modul kapselt den Transport. main.cpp weiß nie, ob es über einfaches TCP oder TLS kommuniziert oder welche Client ID verwendet wird. Wenn Sie später PubSubClient durch einen asynchronen Client ersetzen oder Topics in eine neue Hierarchie verschieben, bleibt die Änderung auf eine Datei beschränkt. Das Modul verwaltet auch das Reconnect-Backoff, sodass jedes Projekt, das es wiederverwendet, nicht blockierende Wiederverbindung gratis bekommt.
Status-Topic und Last Will sind bereits eingebaut. Da tryConnect() immer ein Retained-Will offline registriert und nach dem Verbinden online veröffentlicht, meldet jedes Gerät, das aus dieser Vorlage gebaut wird, seine Verfügbarkeit ohne zusätzlichen Code.
Gepinnte Abhängigkeiten machen die CI aussagekräftig. Eine Pipeline, die bei jedem Commit pio run ausführt, beweist nur dann etwas, wenn sie mit denselben Bibliotheksversionen baut wie die Entwickler. Mit Caret-Bereichen wie ^2.8 erhalten Sie Bugfix-Updates, aber keine inkompatiblen Major-Versionen.
Wenn Sie über das Kompilieren hinaus automatisierte Prüfungen wünschen, kann der Unit-Test-Runner von PlatformIO Tests direkt auf dem Board oder mit einer native-Umgebung auf dem Host ausführen. Reine Logik wie das Zusammensetzen von Topics oder das Formatieren von Payloads lässt sich nativ leicht testen, sobald sie in einem eigenen Modul liegt.
Typische Stolperfallen in PlatformIO
- String-Makros ohne maskierte Anführungszeichen.
-D MQTT_HOST=broker.connectmq.comdefiniert ein nacktes Token statt eines Strings und lässt sich nicht kompilieren. Verwenden Sie wie gezeigt\"...\". - Flags überschreiben statt erweitern. Wer in einer Umgebung
build_flags =ohne${env.build_flags}schreibt, verliert die gemeinsamen Flags einschließlich der WLAN-Zugangsdaten. - Nicht gepinnte Bibliotheken. Ein bloßer Bibliotheksname zieht die jeweils neueste Version, und ein Major-Update wie ArduinoJson 6 auf 7 kann Ihren Build über Nacht brechen. Pinnen Sie mit
@ ^x.y.z. - Fehlende
secrets.ini. Verweise auf einen nicht definierten Abschnitt scheitern auf verwirrende Weise. Die committete Beispieldatei verhindert das. - Veraltete Builds nach Flag-Änderungen. PlatformIO baut bei geänderten Flags in der Regel neu, doch wenn sich etwas falsch verhält, führen Sie
pio run -t cleanaus. - Abstürze dekodieren. Behalten Sie
monitor_filters = esp32_exception_decoderbei, damit Backtraces Datei und Zeile anzeigen – unbezahlbar bei Problemen wie Resets durch den ESP32 Task Watchdog.
Nächste Schritte
Mit einer sauberen Struktur lassen sich neue Funktionen unkompliziert ergänzen. Lagern Sie die JSON-Verarbeitung gemäß der Anleitung zu ESP32, ArduinoJson und MQTT in ein eigenes Modul aus, ergänzen Sie die Firmware-Verteilung aus der Ferne mit ESP32-OTA-Updates über MQTT und sehen Sie sich im ESP32-IoT-Tutorial das vollständige Projekt vom Gerät bis zum Dashboard an. Warum sich Dev- und Prod-Broker unterschiedlich verhalten, erfahren Sie im Artikel über den kostenlosen öffentlichen MQTT-Broker und seine Grenzen.
Liefern Sie Ihre PlatformIO-Firmware mit ConnectMQ aus
Die Dev-Umgebung funktioniert sofort mit dem öffentlichen MQTT-Broker, der für Tests und Prototypen gedacht ist. Für die Prod-Umgebung erstellen Sie einen privaten Broker bei ConnectMQ, einem verwalteten MQTT-Broker mit MQTT 3.1.1 und 5.0, TLS auf Port 8883 und einem Let's-Encrypt-Zertifikat. Registrieren Sie sich für den kostenlosen Serverless-Plan unter app.connectmq.com – keine Kreditkarte erforderlich –, kopieren Sie die generierten Zugangsdaten in secrets.ini und führen Sie pio run -e prod -t upload aus.
Häufig gestellte Fragen
Wie halte ich MQTT-Passwörter in PlatformIO aus Git heraus?
Legen Sie sie in einer Datei secrets.ini ab, die in .gitignore eingetragen ist und über extra_configs eingebunden wird, oder lesen Sie sie in der CI mit ${sysenv.NAME} aus Umgebungsvariablen.
Kann ein PlatformIO-Projekt für mehrere Broker bauen?
Ja. Definieren Sie pro Broker oder Deployment-Stufe eine Umgebung, die die gemeinsamen build_flags um eigenen Host, Port und eigene Zugangsdaten erweitert, und wählen Sie sie mit pio run -e <name> aus.
Sind Zugangsdaten in Build-Flags innerhalb der Firmware sicher?
Sie werden als Klartext-Strings in das Binary kompiliert, sodass jeder mit der Datei sie extrahieren kann. Für Geräte, die Sie an andere weitergeben, stellen Sie die Zugangsdaten zur Laufzeit bereit und speichern sie stattdessen im NVS.
Welche Bibliotheken sollte ich für MQTT in PlatformIO verwenden?
PubSubClient ist der am weitesten verbreitete synchrone Client, AsyncMqttClient eine ereignisgesteuerte Alternative. Fügen Sie einen der beiden mit gepinnter Version zu lib_deps hinzu.



