MQTT2_Server Retained Bug

Begonnen von Guybrush, 26 August 2026, 19:00:37

Vorheriges Thema - Nächstes Thema

Guybrush

Beim Entwickeln vom MQTT2_Discovery Modul bin ich über einen Bug im MQTT2_Server gestolpert, der dazu führte, dass bei mir kein Aufruf des mqtt2 server devices mehr möglich war. Alles anderen devices gingen, nur dieses hing. Deswegen hab ich gezielt nach dem Fehler gesucht und tatsächlich wohl einen Bug im MQTT2_Server Modul gefunden.

Bei einem MQTT2_SERVER mit aktivem respectRetain und nicht gesetztem global unicodeEncoding werden retained UTF-8-Payloads beim Fhem Start / Wiederherstellen aus dem RETAIN-/.RETAIN-Reading erneut codiert. Aus "Küche" wird nach dem ersten betroffenen Neustart "Küche", danach "Küche" usw. Nach mehreren Restarts war das entsprechend auf enorme längen gestiegen, wodurch sich symptomatisch zeigte, dass der Aufruf des Devices nicht mehr ging.

Die Wiederherstellung erfolgt soweit ich das recherchiert habe beim FHEM-Start: Beim Einlesen des Statefiles ruft FHEM für das gespeicherte Retain-Reading "MQTT2_SERVER_State()" auf. toJSON() hat die ursprünglichen Bytes zu diesem Zeitpunkt noch korrekt gespeichert. json2nameValue erkennt jedoch die von toJSON erzeugten "\u0022"-Escapes und führt bei deaktiviertem unicodeEncoding anschließend Encode::encode("UTF-8", $val) auf dem gesamten, bereits als UTF-8 vorliegenden Payload aus.

Reproduktion:
respectRetain 1, global unicodeEncoding nicht gesetzt und ein externer MQTT-Client.

1. mosquitto_pub -h <fhem-host> -t test/retain-utf8 -r -m '{"name":"Küche"}'
2. FHEM regulär neu starten, sodass das Statefile geschrieben und anschließend wieder eingelesen wird.
3. Das Topic mit einem neuen Subscriber abrufen. Der Name ist nun `Küche`:
   mosquitto_sub -h <fhem-host> -t test/retain-utf8 -C 1
4. Nach dem Neustart ein anderes retained Topic veröffentlichen. Dadurch wird der gesamte bereits wiederhergestellte Retain-Cache erneut ins Reading geschrieben:
   mosquitto_pub -h <fhem-host> -t test/retain-trigger -r -m 1
5. FHEM erneut starten und "test/retain-utf8" wieder abrufen. Der Name ist jetzt "Küche". Weitere solche Zyklen vergrößern die Verfälschung exponentiell.

Das Problem fiel hier erst spät auf, weil ASCII-Payloads unverändert bleiben und regelmäßig publizierte Status-Topics nach einem Neustart wieder mit korrekten Werten überschrieben werden. Statische Discovery-Topics werden dagegen häufig nur beim Start ihres Publishers gesendet und können deshalb viele FHEM-Neustarts durchlaufen. Für den beobachteten Namen mit 4.100 Zeichen waren ungefähr zwölf Wiederherstellungszyklen erforderlich.

Beim Wiederherstellen des internen Retain-Readings sollte ausschließlich die Legacy-Nachcodierung von json2nameValue unterdrückt werden:
diff --git a/FHEM/00_MQTT2_SERVER.pm b/FHEM/00_MQTT2_SERVER.pm
--- a/FHEM/00_MQTT2_SERVER.pm
+++ b/FHEM/00_MQTT2_SERVER.pm
@@ -267,7 +267,14 @@ MQTT2_SERVER_State()
 
   if($name eq "RETAIN" || $name eq ".RETAIN") {
     my $now = gettimeofday;
-    my $ret = json2nameValue($val);
+    my $ret;
+    {
+      # Retained MQTT-Payloads sind bereits Bytefolgen. Deshalb darf die
+      # Legacy-UTF-8-Konvertierung von json2nameValue hier nicht greifen.
+      local $unicodeEncoding = 1;
+      $ret = json2nameValue($val);
+    }
     for my $k (keys %{$ret}) {
       my %h = ( ts=>$now, val=>$ret->{$k} );
       $hash->{retain}{$k} = \%h;


Die globale Einstellung bleibt dabei unverändert. Im konkreten Test ergaben sich für den Namen folgende Bytefolgen:

vorher: 4b c3 bc 63 68 65
bisherige Wiederherstellung: 4b c3 83 c2 bc 63 68 65
mit dem Patch: 4b c3 bc 63 68 65

rudolfkoenig


Guybrush

Danke Rudi,

ich hab bei mir leider immer noch Zeichenfehler:

mosquitto_sub -h 127.0.0.1 -t '#' -v|more
homeassistant/music_player/RINCON_<snip>/sonos/config {"available_commands":["adv-command","clearqueue","command","joingroup","leavegroup","mute","next
","notify","notifytwo","pause","play","playmode","previous","queue","seek","selecttrack","setbass","setbuttonlockstate","setledstate","setnightmode","setavtranspo
rturi","settreble","sleep","speak","speaktwo","stop","switchtoline","switchtoqueue","switchtotv","toggle","unmute","volume","volumedown","volumeup"],"command_topi
c":"sonos/RINCON_<snip>/control","device":{"identifiers":["RINCON_<snip>"],"manufacturer":"Sonos","name":"KÃ�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã
�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�▒
�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�▒
�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�▒
�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�▒
�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â�Ã�Â
usw...

In der MQTT2_SERVER_State dürfte auch die Handhabung von hideRetain=0|1 verdreht sein:

Stelle    hideRetain=0    hideRetain=1
Dokumentation    RETAIN    .RETAIN
MQTT2_SERVER_Attr()    RETAIN    .RETAIN
MQTT2_SERVER_doPublish()    RETAIN    .RETAIN
MQTT2_SERVER_State()    .RETAIN    RETAIN

Das erklärt zwar nicht woher die Falschkodierung bei mir herkommt, aber der Fix aus dem Vorpost war nicht ausreichend.

diff --git a/FHEM/00_MQTT2_SERVER.pm b/FHEM/00_MQTT2_SERVER.pm
--- a/FHEM/00_MQTT2_SERVER.pm
+++ b/FHEM/00_MQTT2_SERVER.pm
@@ -279,7 +279,7 @@ MQTT2_SERVER_State()
      $hash->{retain}{$k} = \%h;
    }
 
-    my $rname = AttrVal($hash->{NAME}, "hideRetain", 0) ? "RETAIN" : ".RETAIN";
+    my $rname = AttrVal($hash->{NAME}, "hideRetain", 0) ? ".RETAIN" : "RETAIN";
    if($name ne $rname) {
      InternalTimer(0, sub {
        $hash->{READINGS}{$rname} = $hash->{READINGS}{$name};

rudolfkoenig

Zitatich hab bei mir leider immer noch Zeichenfehler:
Viele Neustarts...
Womoelich hat ein clearRetain nach dem Fix gefaehlt?

ZitatIn der MQTT2_SERVER_State dürfte auch die Handhabung von hideRetain=0|1 verdreht sein:
Habs eingecheckt.


Guybrush

danke :) Ich vermute mal, dass nach dem ersten fix die fehlerhafte Codierung noch im Speicher war. Es gibt 3 Stellen, wo die Retainlist gespeichert ist. Ich wollte jedenfalls wissen, warum der erste fix nicht gegriffen hat und bin dabei drüber gestolpert.

50watt

MQTT2_SERVER: RETAIN-Restore verändert gültige MQTT-Topicnamen mit ":" nach "_"

ich bin auf einen weiteren reproduzierbaren Fehler im RETAIN-Restore des MQTT2_SERVER gestoßen.

Ausgangssituation:
  • MQTT2_SERVER mit respectRetain=1
  • ESPresense verwendet retained Topics für IRK-Konfigurationen, z. B.

espresense/settings/irk:<IRK>/config

Direkt nach dem Enrollment funktioniert die Anwesenheitserkennung korrekt und das Topic steht mit : (,,Doppelpunkt") im RETAIN.

Nach einem FHEM-Neustart wird daraus jedoch:

espresense/settings/irk_<IRK>/config

Damit wird ein gültiger MQTT-Topicname semantisch verändert und ESPresense liest die IRK-Konfiguration anschließend nicht mehr korrekt ein.

Der Fehler ist bei mir reproduzierbar:

  • IRK neu enrollen
    espresense/settings/irk:<IRK>/config
    Anwesenheitserkennung funktioniert.
  • FHEM neu starten.
  • Danach steht im RETAIN:
    espresense/settings/irk_<IRK>/config
    Anwesenheitserkennung funktioniert nicht mehr.

Die Ursache scheint weiterhin MQTT2_SERVER_State() zu sein.

Der aktuelle Code nach dem kürzlich eingecheckten UTF-8-Fix verwendet weiterhin:

my $ret;
{
  local $unicodeEncoding = 1;
  $ret = json2nameValue($val);
}

Der UTF-8-Fix löst die Payload-Problematik, aber json2nameValue() normalisiert weiterhin die JSON-Keys.

Das RETAIN-Reading enthält jedoch ein JSON-Objekt, dessen Keys rohe MQTT-Topicnamen sind. Diese sollten beim Restore unverändert bleiben.

Der normale Publish-Pfad speichert das Topic korrekt:

$server->{retain}{$tp} = { ts=>$now, val=>$val, props=>$props };

Die spätere topicConversion ist hier nicht die Ursache, da der Retain-Store bereits vorher geschrieben wird.

Ich habe testweise den RETAIN-Restore auf einen normalen JSON-Decoder umgestellt:

use JSON qw(decode_json);

und in MQTT2_SERVER_State():

my $ret = eval { decode_json($val) };
if($@ || ref($ret) ne "HASH") {
  Log3 $hash, 1,
    "$hash->{NAME}: unable to restore retained MQTT messages: $@";
  return undef;
}

Mit diesem Patch bleiben die Topicnamen nach einem FHEM-Neustart unverändert:

espresense/settings/irk:<IRK>/config

Die Anwesenheitserkennung funktioniert damit auch nach dem Neustart wieder korrekt.

Getestet mit:


$Id: 00_MQTT2_SERVER.pm 31604 2026-08-28 06:45:25Z rudolfkoenig $


Diese Version enthält bereits den Fix für #145363 mit:


local $unicodeEncoding = 1;


Das verhindert zwar die erneute UTF-8-Konvertierung der Payloads,

json2nameValue() normalisiert jedoch weiterhin die JSON-Keys.

Bei RETAIN sind diese Keys MQTT-Topicnamen und dürfen nicht verändert werden.

diff --git a/FHEM/00_MQTT2_SERVER.pm b/FHEM/00_MQTT2_SERVER.pm
--- a/FHEM/00_MQTT2_SERVER.pm
+++ b/FHEM/00_MQTT2_SERVER.pm
@@ -5,6 +5,7 @@ package main;
 use strict;
 use warnings;
 use TcpServerUtils;
 use MIME::Base64;
+use JSON qw(decode_json);

 sub MQTT2_SERVER_Read($@);
 sub MQTT2_SERVER_Write($$$);
@@ -268,13 +269,17 @@ MQTT2_SERVER_State()
 
   if($name eq "RETAIN" || $name eq ".RETAIN") {
     my $now = gettimeofday;
 
-    my $ret;
-    {
-      # do not convert retained MQTT-Payloads again, #145363
-      local $unicodeEncoding = 1;
-      $ret = json2nameValue($val);
+    # RETAIN contains raw MQTT topic names as JSON object keys.
+    # json2nameValue() normalizes characters which are valid in MQTT
+    # topic names, e.g. ":" to "_".
+    my $ret = eval { decode_json($val) };
+    if($@ || ref($ret) ne "HASH") {
+      Log3 $hash, 1,
+        "$hash->{NAME}: unable to restore retained MQTT messages: $@";
+      return undef;
     }
+
     for my $k (keys %{$ret}) {
       my %h = ( ts=>$now, val=>$ret->{$k} );
       $hash->{retain}{$k} = \%h;

Nach Einspielen dieses Patches habe ich die zuvor veränderten retained Topics einmalig wieder von

irk_<IRK>

auf

irk:<IRK>

korrigiert.

Anschließend mehrfach FHEM neu gestartet.

Ergebnis:
  • irk:<IRK> bleibt im RETAIN erhalten
  • kein erneutes : -> _
  • ESPresense erkennt die Geräte auch nach dem FHEM-Neustart weiterhin korrekt

Das scheint mir derselbe RETAIN-Restore-Pfad zu sein wie beim aktuellen UTF-8-Problem, nur dass hier zusätzlich die JSON-Keys bzw. MQTT-Topicnamen selbst betroffen sind.

Viele Grüße
50watt
RaspberryPi, EnOcean PI
Sonos Play1, Connect
Eltako FT55, FSB61, FAM12, FSR12-4x

Guybrush

#6
Den fix würde ich so nicht 1:1 umsetzen ohne das untersucht zu haben, was für Auswirkungen das noch hätte. Damit bekämen aber wohl alle MQTT Devices bereits dekodiertes UTF-8 für payloads, die nach einem neustart aus dem retained speicher wiederhergestellt werden, später live empfangene messages aber unverändert. damit gäbs dann zwei unterschiedliche bitströme, je nachdem wann das empfangen wurde.

Ich denke, dass man da das äußere RETAIN-JSON strukturell dekodieren müsste, ohne die darin eingebetteten MQTT-Payloads automatisch als UTF-8 zu dekodieren. Noch besser wäre es dann natürlich im retained speicher direkt topic und payload in base64 kodiert zu speichern