MQTT – Regelwerk für fehlerfreie Kommunikation
Dieses Dokument beschreibt alle bekannten Eigenheiten des MQTT-Brokers und die bewährten Muster für eine stabile, fehlerfreie MQTT-Kommunikation. Es ist projektneutral und kann als direkte Referenz für neue Projekte verwendet werden.
Grundprinzipien
- Kein automatisches Prefix. Der Broker veröffentlicht State-Werte ohne zusätzliches Prefix. Ein broker-eigenes Prefix niemals automatisch voranstellen – es ist ausschließlich für interne Protokollnachrichten reserviert. Wenn ein Broker solche Topics blockiert, ist das erwünscht.
- State-ID ≠ MQTT-Topic. Der Broker speichert States mit Punktnotation (
adapter.instanz.pfad) und veröffentlicht sie wahlweise als Punkt-Topic (adapter.instanz.pfad) oder Slash-Topic (adapter/instanz/pfad). Beide kommen vor; welches Format zuverlässig geliefert wird, hängt vom Broker und dessen Version ab.
- QoS 0 ist Standard. MQTT-Broker verwenden in der Regel QoS 0. Höhere QoS-Level sind selten konfiguriert und schaffen oft mehr Probleme als sie lösen.
clean: true. MQTT.js sollte mitclean: trueverbunden werden. Persistent sessions führen zu Zustellung alter Nachrichten bei Reconnect und verschleiern fehlerhafte Subscriptions.
- Bursts intern bündeln, Werte nicht verwerfen. Mehrere zusammengehörige
Adapterwerte werden im homeESS-Wertebus als Batch übernommen. Jeder State behält Wert und Frischezeitpunkt; abhängige Regelungen erhalten für den Batch ein gemeinsames Ereignis. MQTT-Topics, Retained-Verhalten und Pollintervalle bleiben davon unverändert.
- Gekoppelte Schaltzustände gemeinsam bestätigen. Bei einem optionalen
Remote-Topic eines „Messen + Schalten“-Geräts werden Remote- und Schaltzustand bidirektional synchronisiert. Jeder akzeptierte Schaltbefehl wird auf beide Topics publiziert. Wird EIN durch Betriebslevel oder Lastabwurf abgewiesen, muss auch das Remote-Topic auf AUS zurückgesetzt werden; andernfalls würde die Fernbedienung einen Zustand anzeigen, den das Gerät nicht annehmen darf.
- Broker-States nicht zyklisch per
/getpollen. Insbesondere bei
Homematic kann eine Wertanfrage über den Broker eine echte Funkabfrage auslösen. Viele Geräte-Topics oder kurze Intervalle treiben dadurch den Duty-Cycle hoch. Normale MQTT-States deshalb ereignisgetrieben abonnieren; aktive Reads sind nur für lokale Adapter-Schema-Topics (prefix://instanz/adresse) zulässig.
Topic-Formate
Normalform / Normalisierung
Jedes Topic vor der Verarbeitung bereinigen:
function normalizeMqttTopic(topic) {
return String(topic || "").trim()
.replace(/^\/+/, "") // kein führender Slash
.replace(/\/+/g, "/"); // keine doppelten Slashes
}State-ID → MQTT-Topic (Punkt → Slash)
State-IDs werden durch Ersetzen aller Punkte durch Slashes zu einem MQTT-Topic:
function stateIdToMqttTopic(stateId) {
return String(stateId || "")
.replace(/^\/+/, "")
.replace(/\./g, "/")
.replace(/\/+/g, "/");
}
// "0_userdata.0.SoC" → "0_userdata/0/SoC"MQTT-Adapter-States erkennen
Wenn ein Nutzer eine State-ID in der Form mqtt.0.Heizung.Vorlauf konfiguriert (ein State der MQTT-Anbindung selbst), ist das eigentliche Broker-Topic Heizung/Vorlauf:
function mqttAdapterStateToBrokerTopic(topic) {
const clean = normalizeMqttTopic(topic);
const dotMatch = clean.match(/^mqtt\.\d+\.(.+)$/i);
if (dotMatch) return stateIdToMqttTopic(dotMatch[1]);
const slashMatch = clean.match(/^mqtt\/\d+\/(.+)$/i);
if (slashMatch) return normalizeMqttTopic(slashMatch[1]);
return "";
}
// "mqtt.0.Heizung.Vorlauf.Vorlauf" → "Heizung/Vorlauf/Vorlauf"Lese-Kandidaten pro konfiguriertem Topic
Für jedes konfigurierte Topic werden alle realistischen MQTT-Pfade erzeugt, auf denen Werte ankommen können:
function mqttReadCandidates(configuredTopic) {
const clean = normalizeMqttTopic(configuredTopic);
if (!clean) return [];
const slashVariant = stateIdToMqttTopic(clean);
const adapterVariant = mqttAdapterStateToBrokerTopic(clean);
const result = new Set([clean]);
if (slashVariant !== clean) result.add(slashVariant);
if (adapterVariant && adapterVariant !== clean) result.add(adapterVariant);
return Array.from(result);
}
// "0_userdata.0.SoC" → ["0_userdata.0.SoC", "0_userdata/0/SoC"]
// "mqtt.0.Heizung.Vorlauf" → ["mqtt.0.Heizung.Vorlauf", "mqtt/0/Heizung/Vorlauf", "Heizung/Vorlauf"]Kritischer Broker-Bug: State-IDs mit eingebetteten Slashes
Problemstellung
Manche Datenquellen (z. B. Modbus, Victron Energy) erzeugen State-IDs, in denen der State-Name selbst Slashes enthält:
modbus.0.holdingRegisters.100.817_/Ac/Consumption/L1/PowerDas Dot-Prefix lautet hier modbus.0.holdingRegisters.100.817_, der State-Name beginnt direkt mit einem Slash: /Ac/Consumption/L1/Power.
Warum ein exaktes Abo nicht funktioniert
Der MQTT-Broker konvertiert beim Matching einer Subscription alle Slashes zurück zu Punkten, um die State-ID zu finden. Bei obigem Beispiel würde er aus dem Slash-Topic modbus/0/holdingRegisters/100/817_/Ac/Consumption/L1/Power die State-ID modbus.0.holdingRegisters.100.817_.Ac.Consumption.L1.Power rekonstruieren – diese existiert nicht. Das Abo wird zwar bestätigt (SUBACK), aber:
- Retained-Wert: Kommt an (einmalig, oft leer), weil der Broker ihn direkt unter dem Topic gespeichert hat.
- Live-Updates: Kommen nie an, weil der Broker das Topic bei jeder Veröffentlichung nicht auf die Subscription matcht.
Symptom: Werte sind initial korrekt (aus Retained), werden aber nie aktualisiert. Im Cache sieht man Timestamps, die Stunden oder Tage alt sind, obwohl der Broker sekündlich publiziert.
Nachweis: Ein Wildcard-Abo modbus/0/holdingRegisters/100/# auf denselben Broker liefert die Live-Werte einwandfrei.
Lösung: automatisches Wildcard-Abo
An der letzten reinen Punkt-Grenze vor dem ersten eingebetteten Slash ein #-Wildcard generieren:
function mqttSlashStateWildcard(configuredTopic) {
const clean = normalizeMqttTopic(configuredTopic);
const firstSlash = clean.indexOf("/");
if (firstSlash === -1) return ""; // kein eingebetteter Slash → kein Problem
const dotPrefix = clean.slice(0, firstSlash);
const lastDot = dotPrefix.lastIndexOf(".");
if (lastDot === -1) return ""; // kein Punkt-Praefix → natives Slash-Topic, kein Problem
const base = dotPrefix.slice(0, lastDot);
const slashBase = stateIdToMqttTopic(base);
return slashBase ? `${slashBase}/#` : "";
}
// "modbus.0.holdingRegisters.100.817_/Ac/Consumption/L1/Power"
// → "modbus/0/holdingRegisters/100/#"Dieses Wildcard zusätzlich zu den normalen Kandidaten abonnieren:
function mqttSubscribeCandidates(configuredTopic) {
const clean = normalizeMqttTopic(configuredTopic);
if (!clean) return [];
const candidates = new Set(mqttReadCandidates(clean));
const wildcard = mqttSlashStateWildcard(clean);
if (wildcard) candidates.add(wildcard);
return Array.from(candidates);
}Wichtig: Das Routing (welche eingehende Nachricht welchem Cache-Eintrag zugeordnet wird) weiterhin auf den exakten mqttReadCandidates aufbauen. Das Wildcard dient nur dem Empfang; die Zuordnung läuft über das genaue Topic der eingehenden Nachricht.
Schreiben auf Slash-State-IDs ist nicht möglich
Das Wildcard löst ausschließlich das Lesen. Schreiben auf eine State-ID mit eingebettetem Slash funktioniert prinzipiell nicht:
- Auf ein Wildcard (
#/+) kann nicht publiziert werden — ein Publish geht
immer an genau ein konkretes Topic.
- Der Broker bildet ein eingehendes konkretes Topic per
/→.auf die State-ID
zurück. Aus modbus/0/holdingRegisters/100/3500_/ManualStart wird so modbus.0.holdingRegisters.100.3500_.ManualStart — die echte ID enthält an dieser Stelle aber einen Slash. Der Schreibbefehl trifft nichts und versickert (selbst wenn man an alle Punkt-/Slash-Kandidaten schreibt).
Symptom: Der Zustand wird korrekt angezeigt/gelesen, ein Schaltbefehl ändert den realen State auf dem Broker aber nie.
Lösung: Für Schalt-Ziel-Topics slash-freie State-IDs verwenden (z. B. einen schreibbaren Hilfs-State unter 0_userdata.0.… anlegen und per Skript auf das slash-behaftete Register spiegeln). Nur Lese-Topics dürfen den eingebetteten Slash behalten.
Reconnect – die häufigste Fehlerquelle
Problem
MQTT.js reconnectet nach einem Verbindungsabbruch automatisch und feuert erneut das connect-Event. Wenn Subscribe-Tracking in einem Set gehalten wird (Deduplizierung), und dieses Set beim Reconnect nicht geleert wird, passiert folgendes:
connectfeuert →subscribeAllTopics()wird aufgerufen.- Für jedes Topic:
if (subscribedTopics.has(topic)) return;→ früher Return. - Kein einziges SUBSCRIBE-Paket geht an den Broker.
- Der Broker liefert keine Werte mehr.
- Nur noch gecachte (veraltete) Werte sind sichtbar – oft stunden- oder tagelang.
Das Set muss bei jedem connect-Event geleert werden:
client.on("connect", () => {
subscribedTopics = new Set(); // KRITISCH: immer leeren, auch beim Auto-Reconnect
subscribeAllTopics();
requestStaleValues();
});Warum nicht im reconnect-Event?
Das reconnect-Event feuert vor dem Verbindungsaufbau. Zum Zeitpunkt des reconnect-Events ist der Client noch nicht verbunden, SUBSCRIBE-Pakete können nicht gesendet werden. Das Leeren dort ist folgenlos. Nur im connect-Event ist die Verbindung tatsächlich hergestellt.
Client-Generationen bei asynchronen Events
MQTT.js-Events (close, error, message) können noch nach dem Schließen eines Clients eintreffen. Bei Reconnect-Logik, die einen neuen Client erzeugt, alten Client-Events anhand einer Generation-ID filtern:
let clientGeneration = 0;
let mqttClient = null;
function reconnect() {
const generation = ++clientGeneration;
const client = mqtt.connect(url, options);
client.on("connect", () => {
if (generation !== clientGeneration) return; // veraltetes Event ignorieren
// ...
});
client.on("message", (topic, buf) => {
if (client !== mqttClient) return; // altes Client-Objekt ignorieren
// ...
});
mqttClient = client;
}Payload-Formate
JSON-Wrapping
Der Broker kann Werte in einem JSON-Objekt veröffentlichen:
{ "val": 42, "ack": true, "ts": 1710000000000, "lc": 1710000000000 }Auspacken vor der Verarbeitung:
function unwrapMqttPayload(raw) {
const text = String(raw);
if (!text || (text[0] !== "{" && text[0] !== "[")) return text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && "val" in parsed) {
return parsed.val; // JSON-Format: { val, ack, ... }
}
} catch (_) {}
return text; // anderes JSON oder Parse-Fehler → Rohstring zurückgeben
}Nur val auspacken. Andere JSON-Objekte (z. B. {"power": 123}) bleiben als Rohstring.
ack-Flag: bestätigter Zustand vs. Schreibwunsch
Auf dem Broker bedeutet ack:true den bestätigten Ist-Zustand eines States, ack:false einen Schreibwunsch/Kommando. Schreibt ein Client einen Wert auf das Haupt-Topic ({val, ack:false}, siehe unten), so empfängt er dieses eigene Echo über seine Subscription wieder zurück. Wird das ungeprüft gecacht, spiegelt der „Broker-Stand" nur den eigenen Befehl wider — eine Rückmeldungs- Verifikation (Soll == Broker?) meldet dann fälschlich „bestätigt", obwohl der Adapter/das Gerät den Wert gar nicht übernommen hat.
Regel: Beim Readback das ack-Flag auswerten und Nachrichten mit ack:false nicht als Zustand cachen. Nur ack:true (bzw. JSON-lose Rohwerte von Adaptern, die Zustände ohne Wrapper publizieren) gelten als bestätigter Wert.
function unwrapMqttMessage(raw) {
const text = String(raw);
if (!text || (text[0] !== "{" && text[0] !== "[")) return { value: text, ack: null };
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && "val" in parsed) {
return { value: parsed.val, ack: typeof parsed.ack === "boolean" ? parsed.ack : null };
}
} catch (_) {}
return { value: text, ack: null };
}
// Im Message-Handler:
const { value, ack } = unwrapMqttMessage(payload);
if (ack === false) return; // Schreibwunsch/Echo – kein bestätigter ZustandSinnlose Werte ignorieren
Leere Payloads, null und NaN sind keine gültigen Werte:
function isMeaningfulValue(value) {
const text = String(value == null ? "" : value).trim();
return text !== "" && text.toLowerCase() !== "null" && text.toLowerCase() !== "nan";
}Diese Werte nicht cachen und nicht an UI-Clients weitergeben.
Leere Retained-Payloads bei Dot-Topics
Wenn für ein Topic sowohl ein Dot-Topic als auch ein Slash-Topic abonniert wird, kann das Dot-Topic einen leeren Retained-Wert liefern (weil der Broker beim State-Löschen oder -Initialisieren einen leeren Payload retained). Das Slash-Topic liefert dagegen den echten Wert.
Regel: Leere Payloads auf dem Dot-Topic ignorieren, wenn ein Slash-Topic existiert:
function shouldIgnoreEmptyPayload(configuredTopic, incomingTopic, rawPayload) {
if (isMeaningfulValue(rawPayload)) return false;
const slashVariant = stateIdToMqttTopic(normalizeMqttTopic(configuredTopic));
if (slashVariant === normalizeMqttTopic(configuredTopic)) return false; // kein Unterschied
return normalizeMqttTopic(incomingTopic) !== slashVariant; // ignorieren, wenn nicht Slash-Variante
}Schreiben / Publizieren
Normaler State (kein Command-Topic)
Der Broker erwartet Schreiboperationen auf zwei Wegen gleichzeitig:
// 1. Direkt auf dem Topic, mit JSON-Body und ack: false
mqttClient.publish(topic, JSON.stringify({ val: value, ack: false }));
// 2. Auf dem /set-Subtopic, als Rohwert
mqttClient.publish(`${topic}/set`, String(value));Funk-Topics (hm-rpc.*): genau EIN Publish
Bei Homematic landet jede Publish-Variante (Punkt-/Slash-Notation, /set-Subtopic und Haupt-Topic) auf derselben State-ID – und jeder dieser setState-Aufrufe löst einen eigenen Funkbefehl aus. Die übliche Auffächerung würde also vier Funk-Frames pro logischem Schaltvorgang senden und den Duty-Cycle hochtreiben.
Funk-Topics (isRadioTopic, aktuell hm-rpc.*) erhalten deshalb genau ein Publish: das Haupt-Topic in Punktnotation als JSON mit ack: false. Die Punktnotation wird vom Broker unabhängig von dessen Slash-Konvertierung korrekt auf die State-ID abgebildet. mqttWriteCandidates kapselt diese Entscheidung:
function mqttWriteCandidates(configuredTopic) {
const clean = normalizeMqttTopic(configuredTopic);
if (!clean) return [];
if (isRadioTopic(clean)) return [clean]; // Funk: nur Punktnotation, kein /set
return mqttReadCandidates(clean);
}Command-Topics (_SET, .SET, /SET)
Topics die auf _SET, .SET oder /SET enden, sind reine Schreib-Topics. Kein /set-Suffix anfügen, keinen JSON-Body senden – direkt den Rohwert publizieren:
function isCommandTopic(topic) {
const upper = normalizeMqttTopic(topic).toUpperCase();
return upper.endsWith(".SET") || upper.endsWith("/SET") || upper.endsWith("_SET");
}
function publish(configuredTopic, value) {
const baseTopic = resolveBaseTopic(configuredTopic); // Slash-Variante
if (isCommandTopic(configuredTopic)) {
mqttClient.publish(baseTopic, String(value));
return;
}
mqttClient.publish(`${baseTopic}/set`, String(value));
mqttClient.publish(baseTopic, JSON.stringify({ val: parseValue(value), ack: false }));
}Wert-Typen beim JSON-Publish
Der Broker erwartet den richtigen JavaScript-Typ im val-Feld:
function parseValue(value) {
const text = String(value);
if (text === "true") return true;
if (text === "false") return false;
if (text !== "" && Number.isFinite(Number(text))) return Number(text);
return text;
}Aktive Wertabfrage (/get-Pattern)
States können per /get-Subtopic aktiv angefragt werden. Dabei antwortet der Broker mit dem aktuellen Wert auf dem Haupt-Topic (nicht auf /get):
// Anfrage senden:
mqttClient.publish(`${topic}/get`, "");
// Antwort kommt auf: topic (nicht auf topic/get)Für alle Lese-Kandidaten eines konfigurierten Topics /get-Requests senden:
function requestTopicValue(configuredTopic) {
for (const candidate of mqttReadCandidates(configuredTopic)) {
mqttClient.publish(`${candidate}/get`, "");
}
}Wichtig: Wildcard-Topics (#, +) niemals für /get-Requests verwenden. Nur exakte Kandidaten.
Funk-Topics (hm-rpc.*) niemals per /get anfragen – auch nicht beim Connect/Reconnect, beim Aktualisieren der State-Definitionen oder beim Registrieren von Ad-hoc-Abos (Regel 7). Jede Anfrage kann eine echte Funkabfrage auslösen. Ihre Werte kommen ausschließlich ereignisgetrieben über das Abo (Retained-Werte bzw. Zustandsänderungen). Außerdem fragt setStateDefinitions nur noch neue bzw. umkonfigurierte Definitionen aktiv an – unveränderte Topics waren durchgehend abonniert und haben ihren Cache-Wert behalten.
Verifizierte HomeESS-Outputs
Die allgemeine Output-Engine behandelt ein erfolgreiches publish() nicht als Erfolg. Jeder Ziel-State wird zusätzlich abonniert und in einem 30-Sekunden-Fenster aktiv per /get abgefragt — jeder Output jedoch zu einem zufälligen Zeitpunkt innerhalb des Fensters, damit nicht alle gleichzeitig den Broker belasten. Ein bereits bestätigter State wird erst wieder aktiv abgefragt, wenn sein zuletzt empfangener Ist-Wert älter als ein Prüffenster ist. Nur eine nach der Anfrage empfangene ack:true- oder Rohwert-Rückmeldung mit typgleich übereinstimmendem Wert bestätigt den Output. Fehlt sie oder weicht sie ab, wird der Sollwert mit mindestens zehn Sekunden Abstand erneut geschrieben. Eigene ack:false-Echos bleiben ausgeschlossen.
Command-Topics (_SET, .SET, /SET) liefern üblicherweise keinen belastbaren Istwert und sind deshalb keine zulässigen Ziele für verifizierte Outputs.
Lokale System-Topics
Berechnete homeESS-Systemwerte sind als lesbare Quellen unter system://homeess/<wert-id> adressierbar, beispielsweise system://homeess/pv.current. Sie werden lokal aus dem zentralen States- Repository weitergeleitet und weder beim MQTT-Broker abonniert noch dorthin geschrieben.
Subscription-Routing
Vorberechnete Topic-Routen
Für jede eingehende MQTT-Nachricht gegen alle konfigurierten States zu prüfen ist O(n) und bei vielen States spürbar. Stattdessen beim Start (und bei Konfigurationsänderungen) eine Map aufbauen:
// Map: incomingTopic → [{ cacheKey, configuredTopic, ... }]
const topicRoutes = new Map();
function buildTopicRoutes(stateDefinitions) {
topicRoutes.clear();
for (const state of stateDefinitions) {
for (const candidate of mqttReadCandidates(state.topic)) {
const routes = topicRoutes.get(candidate) || [];
routes.push({ cacheKey: String(state.id), configuredTopic: state.topic });
topicRoutes.set(candidate, routes);
}
}
}
function handleMqttMessage(topic, buffer) {
const incomingTopic = normalizeMqttTopic(topic);
const payload = unwrapMqttPayload(buffer.toString("utf8"));
for (const route of topicRoutes.get(incomingTopic) || []) {
if (!isMeaningfulValue(payload)) continue;
cache.set(route.cacheKey, { value: payload, receivedAt: Date.now() });
}
}Routing-Schlüssel sind immer exakte Topics – niemals Wildcards in topicRoutes aufnehmen.
Deduplizierung von Subscriptions
Gleiche Topics, die an mehreren Stellen konfiguriert sind (z. B. zwei States auf demselben MQTT-Topic), nur einmal abonnieren:
let subscribedTopics = new Set();
function subscribeTopic(topic) {
const clean = normalizeMqttTopic(topic);
if (!clean || subscribedTopics.has(clean)) return;
mqttClient.subscribe(clean, { qos: 0 }, (err) => {
if (!err) subscribedTopics.add(clean);
});
}Beim connect-Event: subscribedTopics = new Set() vor dem ersten Subscribe-Aufruf.
Topic-Watchdog (Stille Subscriptions erkennen)
Subscriptions können technisch aktiv sein, aber trotzdem keine Werte mehr liefern – z. B. wenn der Broker neu gestartet wurde und die Session verloren ging, oder bei der Slash-State-Sonderbehandlung. Ein Watchdog erkennt solche „stillen" Topics und erzwingt einen Re-Subscribe.
const SILENT_THRESHOLD_MS = 3 * 60 * 1000; // 3 Minuten
const topicLastMessageAt = new Map(); // incomingTopic → timestamp
// In handleMqttMessage:
topicLastMessageAt.set(incomingTopic, Date.now());
// Watchdog (z. B. alle 3 Minuten):
function checkSilentSubscriptions(configuredTopics) {
if (!mqttClient || !mqttConnected) return;
const now = Date.now();
for (const configuredTopic of configuredTopics) {
const subCandidates = mqttSubscribeCandidates(configuredTopic);
// Nur überwachen, wenn überhaupt aktiv abonniert
if (!subCandidates.some((c) => subscribedTopics.has(c))) continue;
// Letzten Empfangszeitpunkt über alle exakten Kandidaten ermitteln
const lastMsg = mqttReadCandidates(configuredTopic)
.reduce((max, c) => Math.max(max, topicLastMessageAt.get(c) || 0), 0);
if (lastMsg && (now - lastMsg) < SILENT_THRESHOLD_MS) continue;
// Topic ist still → Re-Subscribe erzwingen
for (const c of subCandidates) subscribedTopics.delete(c);
for (const c of subCandidates) subscribeTopic(c);
requestTopicValue(configuredTopic); // /get anfragen
}
}Achtung: Stille Topics werden nicht sofort gemeldet. Der erste Watchdog-Lauf findet frühestens nach SILENT_THRESHOLD_MS statt. Deshalb ergänzend beim connect-Event alle Topics aktiv anfragen (requestStaleValues()).
Force-Resubscribe für manuelle Wert-Abfragen
Wenn der Nutzer explizit einen frischen Wert anfordert (z. B. über einen Diagnose-Button), reicht es nicht, nur einen /get-Request zu senden. Der Broker liefert Retained-Werte erst nach einem (neuen) SUBSCRIBE. Deshalb das Topic temporär aus dem Deduplizierungs-Set entfernen, dann neu abonnieren:
async function fetchLiveValue(configuredTopic, timeoutMs = 3500) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Timeout")), timeoutMs);
const candidates = mqttReadCandidates(configuredTopic);
// Waiter registrieren
for (const candidate of candidates) {
const waiters = valueWaiters.get(candidate) || [];
waiters.push({ resolve: (v) => { clearTimeout(timer); resolve(v); }, reject });
valueWaiters.set(candidate, waiters);
}
// Force-Resubscribe: aus Set löschen → next subscribeTopic sendet echtes SUBSCRIBE
for (const sub of mqttSubscribeCandidates(configuredTopic)) {
subscribedTopics.delete(sub);
subscribeTopic(sub);
}
requestTopicValue(configuredTopic); // /get ebenfalls anfragen
});
}Wichtig: Das Backend darf für Diagnose-Abfragen keinen gecachten Wert zurückgeben. Nur der tatsächlich empfangene Broker-Wert ist für Diagnosen nützlich.
Zusammenfassung: Häufige Fallstricke
| Problem | Ursache | Lösung |
|---|---|---|
| Nach Reconnect keine Live-Werte mehr | subscribedTopics-Set nicht geleert | subscribedTopics = new Set() im connect-Event |
| Modbus/Victron Topics nie aktuell | State-ID mit eingebettetem Slash, exaktes Abo scheitert am Broker | #-Wildcard an letzter Punkt-Grenze vor erstem Slash |
| Dot-Topic liefert leere Werte | Der Broker retained leere Payloads unter Punkt-Namen | Leere Payloads auf Dot-Topics ignorieren wenn Slash-Variante existiert |
| Wert bleibt nach State-Änderung alt | subscribedTopics enthält noch alten Topic-Pfad | Nach Topic-Änderung altes Topic aus Set entfernen |
mqtt.0.X-States liefern nichts | Falsche Kandidaten-Berechnung (Adapter-Prefix nicht abgezogen) | mqttAdapterStateToBrokerTopic vor Kandidatenberechnung |
| Schalter toggelt, der Broker reagiert nicht | Nur /set oder nur JSON-Body gesendet | Immer beide senden: /set (Rohwert) + Haupt-Topic (JSON {val, ack:false}) |
Command-Topic (_SET) sendet JSON-Body | isCommandTopic nicht geprüft | Command-Topics: nur Rohwert, kein /set, kein JSON |
| Alte Events nach Client-Neuaufbau | close/error/message Events des alten Clients | Generation-Counter oder Client-Objekt-Vergleich |
| Topics die stunden-/tagealt bleiben | Keine Watchdog-Logik für stille Subscriptions | checkSilentSubscriptions periodisch aufrufen |
| Readback meldet fälschlich „bestätigt" | Eigenes ack:false-Echo auf dem Haupt-Topic wird als Zustand gecacht | ack:false-Nachrichten beim Readback verwerfen; nur ack:true/Rohwerte cachen |
| Schaltbefehl ändert Slash-State nie | Schreiben auf State-ID mit eingebettetem Slash unmöglich (/→. trifft falsch) | Slash-freie Ziel-Topics verwenden; Wildcard hilft nur beim Lesen |
Empfohlene Verbindungsoptionen (MQTT.js)
const client = mqtt.connect("mqtt://host:port", {
username: "...",
password: "...",
clientId: "dashboard_" + Math.random().toString(16).slice(2),
clean: true, // keine persistente Session
reconnectPeriod: 5000, // Auto-Reconnect alle 5 Sekunden
connectTimeout: 10000, // Verbindungs-Timeout 10 Sekunden
keepalive: 60 // Heartbeat alle 60 Sekunden
});Mit clean: true muss nach jedem Reconnect neu abonniert werden – das ist gewollt und der Grund, warum das connect-Event das Set leeren muss.