MQTT-LCD Display mit ESP8266 (Wemos D1 mini)
Dieses Projekt zeigt, wie man mit einem ESP8266 (D1 mini) und einem I²C-LCD-Display (2004A, 20×4 Zeichen) beliebige Informationen aus dem Smart Home (z. B. ioBroker) per MQTT darstellen kann.
Die Firmware wurde so entwickelt, dass sie komplett ohne feste Vorkonfiguration funktioniert und bequem über ein Webinterface eingestellt werden kann.
Funktionen
Automatische WLAN-Einrichtung
Beim ersten Start öffnet das Modul einen eigenen Access Point („LCD-Setup-xxxxxx“).
Über eine Weboberfläche können WLAN-Zugangsdaten und MQTT-Server eingestellt werden.MQTT-Integration
Verbindung zu einem beliebigen MQTT-Broker (Host, Port, Benutzer, Passwort frei konfigurierbar).
Alle LCD-Zeilen lassen sich über Topics beschreiben.
Zwei Modi pro Zeile:
Links/Rechts-Aufteilung → links ein fester Bereich (z. B. „Netz:“), rechts Werte rechtsbündig (z. B. „+1500W“).
Vollzeile → ganzer Text ohne Aufteilung.
Steuerung über JSON möglich: alle vier Zeilen in einem Befehl.
Besonderheiten
Rechtsbündige Zahlenwerte: Zahlen (z. B. Wattangaben) stehen immer exakt am rechten Rand, unabhängig von der Länge.
Flexibler „Left Width“: Breite des linken Bereichs ist frei einstellbar (Standard: 12 Zeichen).
Backlight steuerbar über MQTT oder Webinterface.
Display löschen über MQTT-Topic oder Button im Webinterface.
Mehrseitige Anzeigen möglich (z. B. zyklisch wechselnde Seiten via ioBroker Blockly).
Webinterface
Statusseite mit IP-Adresse und aktuellem MQTT-Broker.
Testeingabe: Links/Rechts je Zeile können manuell ausprobiert werden.
Konfigurationsseite für MQTT-Server, Topic-Prefix, LCD-Größe und Left-Width.
Zurücksetzen der WLAN-Einstellungen per Button.
Persistente Speicherung
Alle Konfigurationen (MQTT-Server, Displayeinstellungen usw.) werden im Flash des ESP8266 (LittleFS) gespeichert und bleiben nach einem Neustart erhalten.Robustheit
Automatischer Reconnect zu WLAN und MQTT.
Statusmeldung („alive“) wird regelmäßig an den Broker gesendet.
Verwendete Libraries
ESP8266WiFi – WLAN-Funktionalität
PubSubClient – MQTT-Client
WiFiManager – komfortable Einrichtung über Captive Portal
ArduinoJson – JSON-Verarbeitung
hd44780 / hd44780_I2Cexp – Display-Ansteuerung für I²C-LCDs
LittleFS – Speichern der Konfiguration im Flash
Hardware
ESP8266 Wemos D1 mini
LCD 2004A (20×4, grün) mit I²C-Adapter
I²C-Anschlüsse:
SDA → D2 (GPIO4)
SCL → D1 (GPIO5)
Stromversorgung über Micro-USB oder 5 V-Pin
#include
#include
#include
#include
#include
#include
#include
#include
#include // tzapu
// hd44780 (statt LiquidCrystal_I2C)
#include
#include
// ---------- I2C Pins (D1 mini) ----------
const int I2C_SDA_PIN = D2; // GPIO4
const int I2C_SCL_PIN = D1; // GPIO5
// ---------- Defaults ----------
struct AppConfig {
char mqtt_host[64] = "IP.Adresse";
uint16_t mqtt_port = 1885;
char mqtt_user[32] = "Benutzername";
char mqtt_pass[64] = "Passwort";
char topic_prefix[48] = "LCD/lcd"; // z. B. "LCD/lcd"
uint8_t lcd_cols = 20; // 16 oder 20
uint8_t lcd_rows = 4; // meist 4
uint8_t left_width = 12; // feste Breite links
} cfg;
const char* CONFIG_PATH = "/config.json";
// ---------- Topics (aus Prefix) ----------
String T_L1L, T_L1R, T_L2L, T_L2R, T_L3L, T_L3R, T_L4L, T_L4R;
String T_LINE1, T_LINE2, T_LINE3, T_LINE4; // Full-Line
String T_ALL, T_CLEAR, T_BACKLIGHT, T_STATUS;
WiFiClient espClient;
PubSubClient mqtt(espClient);
ESP8266WebServer http(80);
// hd44780: Auto-Scan der I2C-Adresse
hd44780_I2Cexp lcd;
// Zeilen-Darstellung
enum RowMode { MODE_SPLIT, MODE_FULL };
RowMode rowMode[4] = { MODE_SPLIT, MODE_SPLIT, MODE_SPLIT, MODE_SPLIT };
String leftPart[4] = {"","","",""};
String rightPart[4] = {"","","",""};
String fullLine[4] = {"","","",""};
bool shouldSaveConfig = false;
bool firstRenderDone = false;
// ---------- Utils ----------
String translit(String s){
s.replace("ä","ae"); s.replace("Ä","Ae");
s.replace("ö","oe"); s.replace("Ö","Oe");
s.replace("ü","ue"); s.replace("Ü","Ue");
s.replace("ß","ss");
return s;
}
String padRightFixed(String s, uint8_t width){
s = translit(s);
if ((int)s.length() > width) return s.substring(0, width);
while ((int)s.length() < width) s += ' ';
return s;
}
String padLeftToWidth(String s, uint8_t width){
s = translit(s);
if ((int)s.length() > width) s = s.substring(0, width);
int spaces = width - s.length();
String pad = "";
for (int i=0;i= cfg.lcd_rows) return;
lcd.setCursor(0, row);
if (rowMode[row] == MODE_FULL) {
// komplette Zeile wie gesendet anzeigen
String t = translit(fullLine[row]);
if ((int)t.length() > cfg.lcd_cols) t = t.substring(0,cfg.lcd_cols);
while ((int)t.length() < cfg.lcd_cols) t += ' ';
lcd.print(t);
} else {
// Links/Rechts-Layout
uint8_t rightWidth = (cfg.lcd_cols > cfg.left_width) ? (cfg.lcd_cols - cfg.left_width) : 0;
String leftFixed = padRightFixed(leftPart[row], cfg.left_width);
String rightFixed = (rightWidth > 0) ? padLeftToWidth(rightPart[row], rightWidth) : "";
lcd.print(leftFixed + rightFixed);
}
}
void renderAll(){
for (uint8_t r=0; r doc;
auto err = deserializeJson(doc, f);
f.close();
if (err) return false;
strlcpy(cfg.mqtt_host, doc["mqtt_host"] | cfg.mqtt_host, sizeof(cfg.mqtt_host));
cfg.mqtt_port = doc["mqtt_port"] | cfg.mqtt_port;
strlcpy(cfg.mqtt_user, doc["mqtt_user"] | cfg.mqtt_user, sizeof(cfg.mqtt_user));
strlcpy(cfg.mqtt_pass, doc["mqtt_pass"] | cfg.mqtt_pass, sizeof(cfg.mqtt_pass));
strlcpy(cfg.topic_prefix,doc["topic_prefix"]| cfg.topic_prefix,sizeof(cfg.topic_prefix));
cfg.lcd_cols = doc["lcd_cols"] | cfg.lcd_cols;
cfg.lcd_rows = doc["lcd_rows"] | cfg.lcd_rows;
cfg.left_width = doc["left_width"] | cfg.left_width;
return true;
}
bool saveConfig(){
StaticJsonDocument<512> doc;
doc["mqtt_host"] = cfg.mqtt_host;
doc["mqtt_port"] = cfg.mqtt_port;
doc["mqtt_user"] = cfg.mqtt_user;
doc["mqtt_pass"] = cfg.mqtt_pass;
doc["topic_prefix"] = cfg.topic_prefix;
doc["lcd_cols"] = cfg.lcd_cols;
doc["lcd_rows"] = cfg.lcd_rows;
doc["left_width"] = cfg.left_width;
File f = LittleFS.open(CONFIG_PATH, "w");
if (!f) return false;
serializeJson(doc, f);
f.close();
return true;
}
void buildTopics(){
String p = String(cfg.topic_prefix);
if (p.endsWith("/")) p.remove(p.length()-1);
// Left/Right
T_L1L = p + "/line1_left"; T_L1R = p + "/line1_right";
T_L2L = p + "/line2_left"; T_L2R = p + "/line2_right";
T_L3L = p + "/line3_left"; T_L3R = p + "/line3_right";
T_L4L = p + "/line4_left"; T_L4R = p + "/line4_right";
// Full-Line (schaltet MODE_FULL)
T_LINE1 = p + "/line1";
T_LINE2 = p + "/line2";
T_LINE3 = p + "/line3";
T_LINE4 = p + "/line4";
T_ALL = p + "/all";
T_CLEAR = p + "/clear";
T_BACKLIGHT = p + "/backlight";
T_STATUS = p + "/status";
}
// ---------- MQTT ----------
String jsonValue(const String& json, const String& key){
String k = "\"" + key + "\"";
int p = json.indexOf(k); if (p < 0) return "";
p = json.indexOf(':', p); if (p < 0) return "";
p++;
while (p < (int)json.length() && json[p]==' ') p++;
String v="";
if (p < (int)json.length() && json[p]=='\"'){
p++;
while (p < (int)json.length() && json[p] != '\"') v += json[p++];
} else {
while (p < (int)json.length()){
char c = json[p];
if (c==',' || c=='}' || c==' ' || c=='\n' || c=='\r' || c=='\t') break;
v += c; p++;
}
}
v.trim(); return v;
}
void handleAllJson(const String& payload){
// left_width optional
String v;
if ((v = jsonValue(payload,"left_width")).length()){
int lw = v.toInt();
if (lw > 0 && lw < cfg.lcd_cols) { cfg.left_width = (uint8_t)lw; saveConfig(); }
}
// Links/Rechts je Zeile – aktiviert MODE_SPLIT
String l1L = jsonValue(payload,"l1L"); if (l1L.length()) { leftPart[0] = l1L; rowMode[0] = MODE_SPLIT; }
String l1R = jsonValue(payload,"l1R"); if (l1R.length()) { rightPart[0]= l1R; rowMode[0] = MODE_SPLIT; }
String l2L = jsonValue(payload,"l2L"); if (l2L.length()) { leftPart[1] = l2L; rowMode[1] = MODE_SPLIT; }
String l2R = jsonValue(payload,"l2R"); if (l2R.length()) { rightPart[1]= l2R; rowMode[1] = MODE_SPLIT; }
String l3L = jsonValue(payload,"l3L"); if (l3L.length()) { leftPart[2] = l3L; rowMode[2] = MODE_SPLIT; }
String l3R = jsonValue(payload,"l3R"); if (l3R.length()) { rightPart[2]= l3R; rowMode[2] = MODE_SPLIT; }
String l4L = jsonValue(payload,"l4L"); if (l4L.length()) { leftPart[3] = l4L; rowMode[3] = MODE_SPLIT; }
String l4R = jsonValue(payload,"l4R"); if (l4R.length()) { rightPart[3]= l4R; rowMode[3] = MODE_SPLIT; }
// Optional komplette Zeilen im JSON: line1..line4 → MODE_FULL
String f1 = jsonValue(payload,"line1"); if (f1.length()) { fullLine[0]=f1; rowMode[0]=MODE_FULL; }
String f2 = jsonValue(payload,"line2"); if (f2.length()) { fullLine[1]=f2; rowMode[1]=MODE_FULL; }
String f3 = jsonValue(payload,"line3"); if (f3.length()) { fullLine[2]=f3; rowMode[2]=MODE_FULL; }
String f4 = jsonValue(payload,"line4"); if (f4.length()) { fullLine[3]=f4; rowMode[3]=MODE_FULL; }
renderAll();
}
void mqttCallback(char* topic, byte* payload, unsigned int length){
String t = String(topic);
String msg; msg.reserve(length+1);
for (unsigned int i=0;i"))
+ ""+title+" "
+ F("");
}
String htmlFooter(){ return F("Micha's LCD-Firmware 1.0
"); }
void handleRoot(){
String ip = WiFi.localIP().toString();
String s = htmlHeader("LCD – Test & Status");
s += "LCD – Test & Status
";
s += "IP: "+ip+" MQTT: "+String(cfg.mqtt_host)+":"+String(cfg.mqtt_port)+"
";
s += "Topics: Full "+T_LINE1+" / Split "+T_L1L+", "+T_L1R+" …
";
s += "
Test: Full-Line
";
s += F("");
s += "
Test: Links/Rechts je Zeile
";
s += F("");
s += "";
s += "";
s += "";
s += "
Konfiguration
";
s += "WLAN-Einstellungen zurücksetzen (neustarten)
";
s += htmlFooter();
http.send(200, "text/html; charset=utf-8", s);
}
void handleTestFull(){
bool changed=false;
if (http.hasArg("f1")) { fullLine[0]=http.arg("f1"); rowMode[0]=MODE_FULL; changed=true; }
if (http.hasArg("f2")) { fullLine[1]=http.arg("f2"); rowMode[1]=MODE_FULL; changed=true; }
if (http.hasArg("f3")) { fullLine[2]=http.arg("f3"); rowMode[2]=MODE_FULL; changed=true; }
if (http.hasArg("f4")) { fullLine[3]=http.arg("f4"); rowMode[3]=MODE_FULL; changed=true; }
if (changed) renderAll();
http.sendHeader("Location","/");
http.send(303);
}
void handleTestPairs(){
if (http.hasArg("left_width")){
String lw = http.arg("left_width"); lw.trim();
if (lw.length()){
int v = lw.toInt();
if (v > 0 && v < cfg.lcd_cols){ cfg.left_width = (uint8_t)v; saveConfig(); }
}
}
if (http.hasArg("l1l")) { leftPart[0] = http.arg("l1l"); rowMode[0]=MODE_SPLIT; }
if (http.hasArg("l1r")) { rightPart[0] = http.arg("l1r"); rowMode[0]=MODE_SPLIT; }
if (http.hasArg("l2l")) { leftPart[1] = http.arg("l2l"); rowMode[1]=MODE_SPLIT; }
if (http.hasArg("l2r")) { rightPart[1] = http.arg("l2r"); rowMode[1]=MODE_SPLIT; }
if (http.hasArg("l3l")) { leftPart[2] = http.arg("l3l"); rowMode[2]=MODE_SPLIT; }
if (http.hasArg("l3r")) { rightPart[2] = http.arg("l3r"); rowMode[2]=MODE_SPLIT; }
if (http.hasArg("l4l")) { leftPart[3] = http.arg("l4l"); rowMode[3]=MODE_SPLIT; }
if (http.hasArg("l4r")) { rightPart[3] = http.arg("l4r"); rowMode[3]=MODE_SPLIT; }
renderAll();
http.sendHeader("Location","/");
http.send(303);
}
void handleClear(){
lcdClear();
http.sendHeader("Location","/");
http.send(303);
}
void handleBacklight(){
bool on = http.arg("on") == "1";
setBacklight(on);
http.sendHeader("Location","/");
http.send(303);
}
void handleConfigGet(){
String s = htmlHeader("Konfiguration");
s += "Konfiguration
";
s += F("";
s += htmlFooter();
http.send(200, "text/html; charset=utf-8", s);
}
void handleConfigPost(){
if (http.hasArg("mqtt_host")) strlcpy(cfg.mqtt_host, http.arg("mqtt_host").c_str(), sizeof(cfg.mqtt_host));
if (http.hasArg("mqtt_port")) cfg.mqtt_port = (uint16_t) http.arg("mqtt_port").toInt();
if (http.hasArg("mqtt_user")) strlcpy(cfg.mqtt_user, http.arg("mqtt_user").c_str(), sizeof(cfg.mqtt_user));
if (http.hasArg("mqtt_pass")) strlcpy(cfg.mqtt_pass, http.arg("mqtt_pass").c_str(), sizeof(cfg.mqtt_pass));
if (http.hasArg("topic_prefix")) strlcpy(cfg.topic_prefix, http.arg("topic_prefix").c_str(), sizeof(cfg.topic_prefix));
if (http.hasArg("lcd_cols")) cfg.lcd_cols = (uint8_t) http.arg("lcd_cols").toInt();
if (http.hasArg("lcd_rows")) cfg.lcd_rows = (uint8_t) http.arg("lcd_rows").toInt();
if (http.hasArg("left_width")) {
int v = http.arg("left_width").toInt();
if (v > 0 && v < cfg.lcd_cols) cfg.left_width = (uint8_t)v;
}
saveConfig();
String s = htmlHeader("Gespeichert");
s += "Gespeichert
Neustart in 2 Sekunden…
";
s += htmlFooter();
http.send(200,"text/html; charset=utf-8",s);
delay(2000);
ESP.restart();
}
void handleWiFiReset(){
String s = htmlHeader("WLAN Reset");
s += "WLAN-Einstellungen gelöscht
Gerät startet neu und öffnet das Setup-WLAN.
";
s += htmlFooter();
http.send(200,"text/html; charset=utf-8",s);
delay(500);
WiFiManager wm;
wm.resetSettings(); // löscht nur WLAN-Creds
delay(500);
ESP.restart();
}
// ---------- WiFiManager ----------
void saveConfigCallback() { shouldSaveConfig = true; }
void startConfigPortalIfNeeded(){
WiFiManager wm;
wm.setSaveConfigCallback(saveConfigCallback);
WiFiManagerParameter p_mhost("mqtt_host", "MQTT Host", cfg.mqtt_host, 64);
char portBuf[8]; snprintf(portBuf, sizeof(portBuf), "%u", cfg.mqtt_port);
WiFiManagerParameter p_mport("mqtt_port", "MQTT Port", portBuf, 6);
WiFiManagerParameter p_muser("mqtt_user", "MQTT User", cfg.mqtt_user, 32);
WiFiManagerParameter p_mpass("mqtt_pass", "MQTT Passwort", cfg.mqtt_pass, 64);
WiFiManagerParameter p_prefix("topic_prefix", "Topic-Präfix", cfg.topic_prefix, 48);
char colsBuf[4]; snprintf(colsBuf, sizeof(colsBuf), "%u", cfg.lcd_cols);
WiFiManagerParameter p_lcols("lcd_cols", "LCD Spalten (16/20)", colsBuf, 3);
char rowsBuf[4]; snprintf(rowsBuf, sizeof(rowsBuf), "%u", cfg.lcd_rows);
WiFiManagerParameter p_lrows("lcd_rows", "LCD Zeilen (4)", rowsBuf, 3);
char lwBuf[4]; snprintf(lwBuf, sizeof(lwBuf), "%u", cfg.left_width);
WiFiManagerParameter p_lw("left_width", "Left-Width (links, Zeichen)", lwBuf, 3);
wm.addParameter(&p_mhost); wm.addParameter(&p_mport);
wm.addParameter(&p_muser); wm.addParameter(&p_mpass);
wm.addParameter(&p_prefix);
wm.addParameter(&p_lcols); wm.addParameter(&p_lrows);
wm.addParameter(&p_lw);
String apName = "LCD-Setup-" + String(ESP.getChipId(), HEX);
wm.setConfigPortalBlocking(true);
wm.setBreakAfterConfig(true);
wm.setMinimumSignalQuality(8);
wm.setConnectTimeout(20);
if (!wm.autoConnect(apName.c_str())) { ESP.restart(); }
// Übernahme
strlcpy(cfg.mqtt_host, p_mhost.getValue(), sizeof(cfg.mqtt_host));
cfg.mqtt_port = (uint16_t) String(p_mport.getValue()).toInt();
strlcpy(cfg.mqtt_user, p_muser.getValue(), sizeof(cfg.mqtt_user));
strlcpy(cfg.mqtt_pass, p_mpass.getValue(), sizeof(cfg.mqtt_pass));
strlcpy(cfg.topic_prefix, p_prefix.getValue(), sizeof(cfg.topic_prefix));
cfg.lcd_cols = (uint8_t) String(p_lcols.getValue()).toInt();
cfg.lcd_rows = (uint8_t) String(p_lrows.getValue()).toInt();
cfg.left_width = (uint8_t) String(p_lw.getValue()).toInt();
if (cfg.left_width == 0 || cfg.left_width >= cfg.lcd_cols) cfg.left_width = 12;
if (shouldSaveConfig || !LittleFS.exists(CONFIG_PATH)) saveConfig();
}
// ---------- Setup/Loop ----------
unsigned long lastPing=0;
void setup(){
Serial.begin(115200);
delay(100);
LittleFS.begin();
Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
loadConfig();
// LCD initialisieren
lcd.begin(cfg.lcd_cols, cfg.lcd_rows);
setBacklight(true);
lcd.clear();
// Bootanzeige (full mode)
fullLine[0] = "MQTT LCD bereit"; rowMode[0]=MODE_FULL;
fullLine[1] = WiFi.localIP().toString(); rowMode[1]=MODE_FULL; // wird nach connect neu gesetzt
fullLine[2] = "MQTT-Broker"; rowMode[2]=MODE_FULL;
fullLine[3] = String(cfg.mqtt_host) + ":" + String(cfg.mqtt_port); rowMode[3]=MODE_FULL;
renderAll();
// WiFiManager-Portal (bei Erststart / wenn no connect)
startConfigPortalIfNeeded();
// IP erneuern
fullLine[1] = WiFi.localIP().toString(); rowMode[1]=MODE_FULL;
renderRow(1);
buildTopics();
mqtt.setCallback(mqttCallback);
connectMQTT();
// Webserver
http.on("/", HTTP_GET, handleRoot);
http.on("/testfull", HTTP_POST, handleTestFull);
http.on("/testpairs", HTTP_POST, handleTestPairs);
http.on("/clear", HTTP_POST, handleClear);
http.on("/backlight", HTTP_POST, handleBacklight);
http.on("/config", HTTP_GET, handleConfigGet);
http.on("/config", HTTP_POST, handleConfigPost);
http.on("/wifireset", HTTP_GET, handleWiFiReset);
http.begin();
}
void loop(){
if (!mqtt.connected()) connectMQTT();
mqtt.loop();
http.handleClient();
unsigned long now = millis();
if (now - lastPing > 60000UL){
lastPing = now;
mqtt.publish(T_STATUS.c_str(), "alive");
if (!firstRenderDone) renderAll();
}
}