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

  1. 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.
  1. 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.
  1. 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.
  1. clean: true. MQTT.js sollte mit clean: true verbunden werden. Persistent sessions führen zu Zustellung alter Nachrichten bei Reconnect und verschleiern fehlerhafte Subscriptions.
  1. 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.

  1. 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.

  1. Broker-States nicht zyklisch per /get pollen. 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/Power

Das 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:

  1. connect feuert → subscribeAllTopics() wird aufgerufen.
  2. Für jedes Topic: if (subscribedTopics.has(topic)) return; → früher Return.
  3. Kein einziges SUBSCRIBE-Paket geht an den Broker.
  4. Der Broker liefert keine Werte mehr.
  5. 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 Zustand

Sinnlose 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

ProblemUrsacheLösung
Nach Reconnect keine Live-Werte mehrsubscribedTopics-Set nicht geleertsubscribedTopics = new Set() im connect-Event
Modbus/Victron Topics nie aktuellState-ID mit eingebettetem Slash, exaktes Abo scheitert am Broker#-Wildcard an letzter Punkt-Grenze vor erstem Slash
Dot-Topic liefert leere WerteDer Broker retained leere Payloads unter Punkt-NamenLeere Payloads auf Dot-Topics ignorieren wenn Slash-Variante existiert
Wert bleibt nach State-Änderung altsubscribedTopics enthält noch alten Topic-PfadNach Topic-Änderung altes Topic aus Set entfernen
mqtt.0.X-States liefern nichtsFalsche Kandidaten-Berechnung (Adapter-Prefix nicht abgezogen)mqttAdapterStateToBrokerTopic vor Kandidatenberechnung
Schalter toggelt, der Broker reagiert nichtNur /set oder nur JSON-Body gesendetImmer beide senden: /set (Rohwert) + Haupt-Topic (JSON {val, ack:false})
Command-Topic (_SET) sendet JSON-BodyisCommandTopic nicht geprüftCommand-Topics: nur Rohwert, kein /set, kein JSON
Alte Events nach Client-Neuaufbauclose/error/message Events des alten ClientsGeneration-Counter oder Client-Objekt-Vergleich
Topics die stunden-/tagealt bleibenKeine Watchdog-Logik für stille SubscriptionscheckSilentSubscriptions periodisch aufrufen
Readback meldet fälschlich „bestätigt"Eigenes ack:false-Echo auf dem Haupt-Topic wird als Zustand gecachtack:false-Nachrichten beim Readback verwerfen; nur ack:true/Rohwerte cachen
Schaltbefehl ändert Slash-State nieSchreiben 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.

WordPress Appliance - Powered by TurnKey Linux