From d79252b824ecac56c9778d725e0f8e7277367767 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Fri, 9 Jan 2026 11:47:29 +0000 Subject: [PATCH 1/5] feat: Implementiere MqttSignalduino_DispatchFromJSON zur Verarbeitung von JSON-Nachrichten und aktualisiere die FHEM-Konfiguration --- .devcontainer/fhem-data/FHEM/99_MyUtils.pm | 82 +++++++++++++ .../fhem-data/fhem_signalduino_example.cfg | 4 +- .gitignore | 4 + .../proposals/fhem_mqtt_integration.adoc | 110 ++++++++++-------- signalduino/mqtt.py | 27 ++++- tests/test_mqtt.py | 12 +- 6 files changed, 181 insertions(+), 58 deletions(-) create mode 100644 .devcontainer/fhem-data/FHEM/99_MyUtils.pm diff --git a/.devcontainer/fhem-data/FHEM/99_MyUtils.pm b/.devcontainer/fhem-data/FHEM/99_MyUtils.pm new file mode 100644 index 0000000..a1dfca2 --- /dev/null +++ b/.devcontainer/fhem-data/FHEM/99_MyUtils.pm @@ -0,0 +1,82 @@ +############################################## +# $Id: 99_MyUtils.pm 1932 2012-01-28 18:15:28Z martinp876 $ +package main; + +use strict; +use warnings; +use JSON; +use Data::Dumper; + +sub MyUtils_Initialize { + my ($hash) = @_; +} + +# Enter you functions below _this_ line. + +sub MqttSignalduino_DispatchFromJSON { + my ($json_str, $name) = @_; + + if (!defined($json_str) || !defined($name)) { + Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: Missing arguments (JSON or Name)"; + return; + } + + my $hash = $defs{$name}; + if (!defined($hash)) { + Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: Device $name not found"; + return; + } + + my $data; + eval { + $data = decode_json($json_str); + }; + if ($@) { + Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: JSON decode error: $@"; + return; + } + #print Dumper($data); + + $hash->{Clients} = 'SD_WS:'; + $hash->{MatchList} = { '12:SD_WS' => '^W\d+x{0,1}#.*' }; + + # Extract fields based on expected JSON structure from MQTT + # The full dispatch message is now constructed by combining 'preamble' (e.g., W126#) and 'state' (e.g., HexData). + + my $rmsg = $data->{rawmsg} // undef; + my $dmsg = $data->{payload} // undef; + my $rssi = $data->{metadata}->{rssi} // undef; + my $id = $data->{protocol}->{id} // undef; + my $freqafc = $data->{metadata}->{freqafc} // undef; + + if (!defined($dmsg)) { + Log3 $name, 4, "MqttSignalduino_DispatchFromJSON: No dmsg found in JSON"; + return; + } + + # Update hash with latest values + #$hash->{RAWMSG} = $rmsg if (defined($rmsg)); + #$hash->{RSSI} = $rssi if (defined($rssi)); + + # Prepare addvals similar to SIGNALduno_Dispatch + my %addvals = ( + Protocol_ID => $id + ); + + if (defined($rmsg)) { + $addvals{RAWMSG} = $rmsg; + } + if (defined($rssi)) { + $addvals{RSSI} = $rssi; + } + if (defined($freqafc)) { + $addvals{FREQAFC} = $freqafc; + } + + Log3 $name, 5, "MqttSignalduino_DispatchFromJSON: Dispatching $dmsg"; + + # Call FHEM Dispatch function + Dispatch($hash, $dmsg, \%addvals); +} + +1; diff --git a/.devcontainer/fhem-data/fhem_signalduino_example.cfg b/.devcontainer/fhem-data/fhem_signalduino_example.cfg index 5e93c36..dd82fcb 100755 --- a/.devcontainer/fhem-data/fhem_signalduino_example.cfg +++ b/.devcontainer/fhem-data/fhem_signalduino_example.cfg @@ -11,6 +11,7 @@ attr global verbose 3 # # This file is loaded by the FHEM container via CONFIGTYPE environment variable. +define autocreate autocreate # 1. Define FHEMWEB instance to access FHEM via Browser (Port 8083) define WEB FHEMWEB 8083 global @@ -39,9 +40,10 @@ attr mqtt_broker autocreate simple define PySignalDuino MQTT2_DEVICE setuuid PySignalDuino 695e9c21-f33f-c986-4f81-a9f0ab37b6bcedf8 attr PySignalDuino IODev mqtt_broker -attr PySignalDuino readingList signalduino/v1/state/messages:.* { json2nameValue($EVENT, 'MSG_',$JSONMAP) }\ +attr PySignalDuino readingList signalduino/v1/state/messages:.* { MqttSignalduino_DispatchFromJSON($EVENT, $NAME);; json2nameValue($EVENT,'MSG_');; }\ signalduino/v1/responses:.* { json2nameValue($EVENT, 'RESP_') }\ signalduino/v1/errors:.* { json2nameValue($EVENT, 'ERR_') } +# attr PySignalDuino Clients :CUL_EM:CUL_FHTTK:CUL_TCM97001:CUL_TX:CUL_WS:Dooya:FHT:FLAMINGO:FS10:FS20:Fernotron:Hideki:IT:KOPP_FC:LaCrosse:OREGON:PCA301:RFXX10REC:Revolt:SD_AS:SD_Rojaflex:SD_BELL:SD_GT:SD_Keeloq:SD_RSL:SD_UT:SD_WS07:SD_WS09:SD_WS:SD_WS_Maverick:SOMFY:Siro:SIGNALduino_un: attr PySignalDuino setList raw:textField signalduino/v1/commands/set/raw $EVTPART1 \ cc1101_reg:textField signalduino/v1/commands/set/cc1101_reg $EVTPART1 \ # System GET commands (noArg) \ diff --git a/.gitignore b/.gitignore index 7e572d4..1f492ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,11 @@ SIGNALDuino-Firmware/ .devcontainer/.devcontainer.env .devcontainer/mosquitto/data/ .devcontainer/mosquitto/log/ + .devcontainer/fhem-data/* +!.devcontainer/fhem-data/FHEM +.devcontainer/fhem-data/FHEM/* !.devcontainer/fhem-data/fhem_signalduino_example.cfg +!.devcontainer/fhem-data/FHEM/99_MyUtils.pm .roo/mcp.json diff --git a/docs/architecture/proposals/fhem_mqtt_integration.adoc b/docs/architecture/proposals/fhem_mqtt_integration.adoc index f90bd0e..f2a6139 100644 --- a/docs/architecture/proposals/fhem_mqtt_integration.adoc +++ b/docs/architecture/proposals/fhem_mqtt_integration.adoc @@ -2,21 +2,34 @@ Dieses Dokument skizziert 3 Optionen zur Integration der über MQTT publizierten JSON-Nachrichten von PySignalDuino in ein FHEM-System, das traditionell String-basierte Nachrichten via `Dispatch()` erwartet. -Das Quell-Topic ist: `signalduino/v1/state/messages` (oder ähnlich, basierend auf der Konfiguration des Basis-Topics in PySignalDuino), mit einem JSON-Payload, der mindestens `id` (Protokoll-ID) und `data` (dekodierte Hex-Payload) enthält. +Das Quell-Topic ist: `signalduino/v1/state/messages` (oder ähnlich, basierend auf der Konfiguration des Basis-Topics in PySignalDuino), mit einem JSON-Payload, der `protocol_id`, `preamble` (Protokoll-Präambel, z.B. `W126#`) und `payload` (Daten-Payload) enthält. == Lösungsoptionen === Option 1: MQTT2_DEVICE + Perl-Mapping (`json2nameValue` in `attr`) -Diese Option nutzt die Standardfunktionalität des FHEM-Moduls `MQTT2_DEVICE` in Verbindung mit einem `attr` (Attribut), um das JSON-Payload zu parsen und spezifische Readings zu erstellen. +Diese Option nutzt die Standardfunktionalität des FHEM-Moduls `MQTT2_DEVICE` in Verbindung mit einer Perl-Hilfsfunktion, um das JSON-Payload zu parsen und über den zentralen `Dispatch`-Mechanismus an die logischen Module weiterzuleiten. + +**Implementierungs-Detail (POC):** +Es wurde eine Hilfsfunktion `MqttSignalduino_DispatchFromJSON($$)` in `99_MyUtils.pm` erstellt. + +* **Funktion:** Nimmt den JSON-String (vom MQTT-Event) und den Gerätenamen entgegen. +* **Logik:** Dekodiert JSON, kombiniert `preamble` (z.B. `W126#`) und `payload` (z.B. `4001...`) und ruft `Dispatch()` mit dem Geräte-Hash auf. +* **FHEM-Integration:** Aufruf erfolgt z.B. über `userReadings` oder `notify`. + +[source,perl] +---- +# Beispielaufruf in FHEM (z.B. userReadings oder notify) +{ MqttSignalduino_DispatchFromJSON($EVENT, $NAME) } +---- [cols="1,3"] |=== |Kriterium | Beschreibung -|**Vorteile** | Keine neue Modul-Entwicklung in FHEM nötig. Reine Konfiguration. Nutzt FHEM-Bordmittel. Geringste Abhängigkeiten. -|**Nachteile** | FHEM-interne `Dispatch()`-Logik wird umgangen. Es muss für jeden Sensortyp ein eigenes FHEM-Device angelegt werden, das die Readings direkt von MQTT liest. Die existierenden Module wie link:../../../.devcontainer/fhem-data/FHEM/14_SD_WS.pm[`14_SD_WS.pm`] (die auf `Dispatch()` warten) können nicht direkt verwendet werden. -|**Implementierungsaufwand (grob)** | **Niedrig**. Konfiguration von einem `MQTT2_DEVICE` und Erstellung der `attr` mit Perl-Code zum Parsen/Dispatch. -|**Notwendige Änderungen** | **FHEM:** Ein `MQTT2_DEVICE` muss abonniert und konfiguriert werden. Wichtig: Das Attribut `Clients` muss manuell gesetzt werden (z.B. `:SD_WS:SD_...`), damit `Dispatch` die Module findet. Es ist ein Perl-Code-Snippet in den Attributen erforderlich, um den `data`-String aus dem JSON-Objekt in das erwartete Format zu transformieren und dann die Readings zu setzen. +|**Vorteile** | Keine volle Modul-Entwicklung nötig. Nutzt FHEM-Bordmittel (`99_MyUtils.pm`). Ermöglicht die Wiederverwendung von `Dispatch()` und bestehenden logischen Modulen. +|**Nachteile** | Erfordert manuelle Installation der Funktion in `99_MyUtils.pm`. Das `MQTT2_DEVICE` muss korrekt konfiguriert sein (z.B. `Clients` Attribut). +|**Implementierungsaufwand (grob)** | **Niedrig**. Erstellung der Perl-Funktion und einmalige Einrichtung. +|**Notwendige Änderungen** | **FHEM:** Ein `MQTT2_DEVICE` muss konfiguriert werden. Die Funktion `MqttSignalduino_DispatchFromJSON` muss in `99_MyUtils.pm` verfügbar sein. |=== @@ -30,7 +43,7 @@ Diese Option nutzt `MQTT2_DEVICE` zum Empfang des JSONs und lagert die Parsing- |**Vorteile** | Sauberer Code, aber manuelle Integration nötig. Das `MQTT2_DEVICE` wird minimalistisch gehalten. Erlaubt die Wiederverwendung von `Dispatch()`. |**Nachteile** | Erfordert Installation einer `.pm`-Datei (ähnlich wie Option 2, aber kein volles Modul). Erfordert einen `notify` oder einen `userReadings`-Aufruf, um die Logik auszuführen. |**Implementierungsaufwand (grob)** | **Niedrig bis Mittel**. Erstellung einer `.pm`-Utility-Datei mit einer Pars- und Dispatch-Funktion. Konfiguration eines `MQTT2_DEVICE`. -|**Notwendige Änderungen** | **FHEM:** Erstellung einer `.pm`-Datei mit einer Utility-Funktion (z.B. `SDU_DispatchJSON($$)`). Diese Funktion wird direkt in der `readingList` des `MQTT2_DEVICE` aufgerufen. Wichtig: Das Attribut `Clients` muss am `MQTT2_DEVICE` manuell gepflegt werden. **PySignalDuino:** Keine Änderungen. +|**Notwendige Änderungen** | **FHEM:** Erstellung einer `.pm`-Datei mit einer Utility-Funktion (z.B. `SDU_DispatchJSON($$)`). Diese Funktion wird direkt in der `readingList` des `MQTT2_DEVICE` aufgerufen. Wichtig: Das Attribut `Clients` muss am `MQTT2_DEVICE` manuell gepflegt werden. **PySignalDuino:** Die Payload-Generierung liefert `preamble` und `payload` separat im JSON. |=== === Option 2: Eigenes FHEM-Modul (PySignalDuino-Bridge) @@ -41,22 +54,22 @@ Ein neues FHEM-Modul, das die MQTT-Nachrichten von PySignalDuino abonniert und i |=== |Kriterium | Beschreibung |**Vorteile** | **Beste Kompatibilität**. Ermöglicht die Wiederverwendung aller bestehenden FHEM-Module (z.B. link:../../../.devcontainer/fhem-data/FHEM/14_SD_WS.pm[`14_SD_WS.pm`]), da die Bridge das ursprüngliche `Dispatch()`-Verhalten emuliert. Trennung von PySignalDuino-Logik (JSON) und FHEM-Logik (String-Dispatch). -|**Nachteile** | Erfordert die Entwicklung, Wartung und Installation eines neuen Perl-Moduls in FHEM. Komplexität in der JSON-zu-String-Konvertierung (Mapping der Protokoll-ID auf den String-Präfix, z.B. ID 49 auf "W49#..."). +|**Nachteile** | Erfordert die Entwicklung, Wartung und Installation eines neuen Perl-Moduls in FHEM. |**Implementierungsaufwand (grob)** | **Mittel**. Entwicklung des Bridge-Moduls in Perl, das die MQTT-Subscription und die JSON-Parsing/Dispatch-Logik implementiert. -|**Notwendige Änderungen** | **FHEM:** Neues Perl-Modul (z.B. `98_PySignalDuinoBridge.pm` oder `00_PySignalDuinoBridge.pm`) muss erstellt werden, das den JSON-Payload parst und basierend auf der Protokoll-ID den FHEM-kompatiblen String generiert (z.B. `P#` oder `W#`). **PySignalDuino:** Keine Änderungen. +|**Notwendige Änderungen** | **FHEM:** Neues Perl-Modul (z.B. `98_PySignalDuinoBridge.pm` oder `00_PySignalDuinoBridge.pm`) muss erstellt werden, das den JSON-Payload empfängt, `preamble` und `payload` verknüpft. |=== === Option 3: Anpassung in PySignalDuino (FHEM-Mode) -PySignalDuino würde eine neue Konfigurationsoption erhalten, die es ihm erlaubt, *zusätzlich* zu oder *anstelle* des Standard-JSON-Formats die traditionellen, von FHEM erwarteten Strings zu publizieren. +PySignalDuino wurde bereits angepasst, um die Felder `preamble` und `payload` zu senden, die zusammen dem FHEM-Dispatch-Format entsprechen. [cols="1,3"] |=== |Kriterium | Beschreibung -|**Vorteile** | Höchste Performance (keine Parsing/Konvertierung in FHEM). Direkte Wiederverwendung der `00_SIGNALduino.pm` (oder `MQTT2_DEVICE` mit einfacher Regex-Subscription) zur Übergabe der Strings an `Dispatch()`. -|**Nachteile** | **Verletzung des Architekturprinzips** (AGENTS.md: Architecture-First Development Process). PySignalDuino sollte eine reine Bridge sein und das standardisierte JSON-Format beibehalten. Ein FHEM-spezifisches Ausgabeformat erhöht die Wartungslast und die Kopplung. -|**Implementierungsaufwand (grob)** | **Mittel**. Änderung der Python-Logik (in z.B. link:../../../signalduino/mqtt.py[`signalduino/mqtt.py`]) zur String-Formatierung basierend auf der Protokoll-ID. -|**Notwendige Änderungen** | **PySignalDuino:** Implementierung der FHEM-String-Konvertierungslogik. Neue Umgebungsvariable (z.B. `MQTT_FHEM_MODE=true`). **FHEM:** Es kann der vorhandene `MQTT2_DEVICE` oder eine geringfügig angepasste Version von link:../../../.devcontainer/fhem-data/FHEM/00_SIGNALduino.pm[`00_SIGNALduino.pm`] verwendet werden, um den String direkt zu abonnieren und zu dispatchen. +|**Vorteile** | Flexibles Format (JSON), das sowohl FHEM als auch andere Systeme unterstützt. +|**Nachteile** | **Teilweise realisiert.** PySignalDuino sendet jetzt standardmäßig das Format mit `preamble` und `payload` im JSON. +|**Implementierungsaufwand (grob)** | **Erledigt**. Änderung in `signalduino/mqtt.py` ist bereits erfolgt. +|**Notwendige Änderungen** | **PySignalDuino:** `signalduino/mqtt.py` sendet `preamble` (z.B. `W126#`) und `payload` (Daten) separat. **FHEM:** Kombination von `preamble` und `payload` vor dem Dispatch. |=== === Option 4: Portierung der Dekodier-Logik (Client-Module) nach PySignalDuino @@ -101,7 +114,7 @@ Um die verschiedenen Optionen besser visualisieren zu können, folgen hier Code- [source,perl] ---- signalduino/v1/state/messages:.* { json2nameValue($EVENT, 'sd_') } -sd_data:.* { my ($id, $data) = ($EVTPART0 =~ /sd_id_(\d+) /g, $EVTPART0 =~ /sd_data_([0-9A-F]+)/g);; if($data) { Dispatch('signalduino', "P$id\#$data") } } +sd_payload:.* { my $pre = ReadingsVal($NAME, "sd_preamble", ""); my $pay = ReadingsVal($NAME, "sd_payload", ""); Dispatch('signalduino', $pre . $pay) } ---- **Flussdiagramm:** @@ -113,11 +126,12 @@ sequenceDiagram participant F as FHEM (MQTT2_DEVICE) participant D as FHEM (Sensor Device) - P ->> M: Publish JSON Payload + P ->> M: Publish JSON Payload (mit "preamble": "W126#", "payload": "40...") M ->> F: MQTT Message F ->> F: readingList: json2nameValue() -> Readings F ->> F: userReadings (optional): Trigger Dispatch() - F ->> F: Dispatch("P#") + F ->> F: Combine preamble + payload + F ->> F: Dispatch("W126#40...") F ->> D: Message an Dispatch() D ->> D: Handle Message .... @@ -132,11 +146,9 @@ sub SDU_DispatchJSON($$) { my ($hash, $json_payload) = @_; use JSON; my $data_hash = JSON::decode_json($json_payload); - my $id = $data_hash->{id}; - my $data = $data_hash->{data}; + my $msg = $data_hash->{preamble} . $data_hash->{payload}; - # Konvertierung und Dispatch - my $msg = "P" . $id . "\#" . $data; + # Dispatch Dispatch($hash, $msg); return 1; } @@ -162,8 +174,7 @@ sequenceDiagram P ->> M: Publish JSON Payload M ->> F: MQTT Message F ->> U: readingList: SDU_DispatchJSON($hash, $EVENT) - U ->> U: Parse JSON - U ->> U: Convert to "P#" + U ->> U: Parse JSON (combine "preamble" + "payload") U ->> F: Dispatch() F ->> D: Message an Dispatch() D ->> D: Handle Message @@ -180,10 +191,7 @@ sub Bridge_Parse($) { # ... JSON-Payload abrufen ... use JSON; my $data_hash = JSON::decode_json($json_payload); - my $id = $data_hash->{id}; - my $data = $data_hash->{data}; - - my $msg = "P" . $id . "\#" . $data; + my $msg = $data_hash->{preamble} . $data_hash->{payload}; # Aufruf des Standard-Dispatch return Dispatch($hash, $msg); @@ -201,8 +209,8 @@ sequenceDiagram P ->> M: Publish JSON Payload M ->> B: MQTT Message (Subscription) - B ->> B: Internal Parse/Convert Logic - B ->> D: Dispatch("P#") + B ->> B: Internal Parse (combine "preamble" + "payload") + B ->> D: Dispatch("W126#40...") D ->> D: Handle Message .... @@ -211,15 +219,17 @@ sequenceDiagram **PySignalDuino-Code (in link:../../../signalduino/mqtt.py[`signalduino/mqtt.py`]):** [source,python] ---- -# Wenn FHEM-Mode aktiv: -fhem_string = f"P{protocol_id}#{hex_data}" -mqtt_client.publish(fhem_topic, fhem_string) +# Implementiert: Preamble und Payload separat +# if self._protocol_handler: +# preamble = self._protocol_handler.check_property(message.protocol_id, 'preamble', '') +# message_dict["preamble"] = preamble +# message_dict["payload"] = message.payload.upper() ---- **FHEM-Konfiguration (MQTT2_DEVICE `readingList`):** [source,perl] ---- -signalduino/v1/state/messages:.* { Dispatch('signalduino', $EVENT) } +signalduino/v1/state/messages:.* { my $h=JSON::decode_json($EVENT); Dispatch('signalduino', $h->{preamble} . $h->{payload}) } ---- **Flussdiagramm:** @@ -231,10 +241,10 @@ sequenceDiagram participant F as FHEM (MQTT2_DEVICE) participant D as FHEM (Sensor Device) - P ->> P: Convert to "P#" String - P ->> M: Publish FHEM String Payload + P ->> P: Separate Preamble and Payload + P ->> M: Publish JSON Payload M ->> F: MQTT Message - F ->> D: readingList: Dispatch('signalduino', $EVENT) + F ->> D: readingList: Dispatch('signalduino', preamble + payload) D ->> D: Handle Message .... @@ -256,8 +266,9 @@ def decode_protocol_49(hex_data): # Im MQTT-Publisher: decoded_values = decode_protocol_49(data) payload = { - "id": 49, - "data": data, + "protocol_id": "49", + "preamble": "P49#", + "payload": data, "values": decoded_values # Neues Feld mit interpretierten Werten } mqtt_client.publish(topic, json.dumps(payload)) @@ -273,7 +284,7 @@ Dies erzeugt Readings wie `values_temperature` und `values_battery` direkt am De **Flussdiagramm:** [mermaid] -.... +---- sequenceDiagram participant P as PySignalDuino participant M as MQTT Broker @@ -290,7 +301,7 @@ sequenceDiagram M ->> H: MQTT Message H ->> H: Auto Discovery / Sensor Update end -.... +---- == Detaillierte Bewertung @@ -318,23 +329,22 @@ sequenceDiagram * Dies sollte das strategische Ziel sein, um PySignalDuino zu einem echten, universellen IoT-Gateway zu machen. * Es wird empfohlen, dies **schrittweise** für die populärsten Protokolle (z.B. IT, WS) umzusetzen, während die anderen Protokolle weiterhin über Option 2 (Bridge) an FHEM übergeben werden. -* Dies ermöglicht einen sanften Übergang: PySignalDuino liefert `data` (für die Bridge) UND `values` (wenn der Decoder schon portiert ist). +* Dies ermöglicht einen sanften Übergang: PySignalDuino liefert `payload` (für die Bridge) UND `values` (wenn der Decoder schon portiert ist). === Implementierungs-ToDos für Option 2 (Bridge-Modul) -1. **Erstellung des Bridge-Moduls:** Erstellen des FHEM Perl-Moduls (z.B. `98_PySignalDuinoBridge.pm` oder `00_PySignalDuinoBridge.pm`). -2. **MQTT-Abonnement:** Implementierung der Logik zur Subscription des Topics `signalduino/v1/state/messages` (oder konfiguriertem Topic). -3. **JSON-Parsing:** Implementierung der Perl-Logik zum Parsen des JSON-Payloads (z.g. mit `JSON::decode_json`). -4. **String-Konvertierung:** Implementierung der Logik, die `protocol_id` und `data` aus dem JSON-Objekt nimmt und den traditionellen String (z.B. `P#` oder `W#`) generiert. -5. **Dispatching:** Aufruf von `Dispatch()` innerhalb des Moduls, um die Nachricht an das FHEM-System weiterzugeben. -6. **Konfiguration und Tests:** Dokumentation und Test der FHEM-Konfiguration (Definieren der Bridge). +. **Erstellung des Bridge-Moduls:** Erstellen des FHEM Perl-Moduls (z.B. `98_PySignalDuinoBridge.pm` oder `00_PySignalDuinoBridge.pm`). +. **MQTT-Abonnement:** Implementierung der Logik zur Subscription des Topics `signalduino/v1/state/messages` (oder konfiguriertem Topic). +. **JSON-Parsing:** Implementierung der Perl-Logik zum Parsen des JSON-Payloads (z.g. mit `JSON::decode_json`). +. **Dispatching:** Extraktion von `payload` aus dem JSON und Aufruf von `Dispatch()`, um die Nachricht an das FHEM-System weiterzugeben. +. **Konfiguration und Tests:** Dokumentation und Test der FHEM-Konfiguration (Definieren der Bridge). == Geplanter Arbeitsablauf -1. **Phase 1 (Design/Planung):** Abschluss der Architekturanalyse und Erstellung des Plans (Abgeschlossen mit diesem Dokument). -2. **Phase 2 (Implementierung):** +. **Phase 1 (Design/Planung):** Abschluss der Architekturanalyse und Erstellung des Plans (Abgeschlossen mit diesem Dokument). +. **Phase 2 (Implementierung):** * Erstellung des FHEM-Bridge-Moduls. * Einrichtung der FHEM-Konfiguration für das Modul. -3. **Phase 3 (Validierung):** Testen der End-to-End-Kette (PySignalDuino publiziert JSON -> Bridge parst -> Bridge dismatched String -> FHEM Sensor-Device reagiert). +. **Phase 3 (Validierung):** Testen der End-to-End-Kette (PySignalDuino publiziert JSON -> Bridge parst -> Bridge dismatched String -> FHEM Sensor-Device reagiert). **Nächster Schritt:** Wechsel in den Code-Modus, um das FHEM-Bridge-Modul zu implementieren (oder mit der Implementierung zu beginnen). diff --git a/signalduino/mqtt.py b/signalduino/mqtt.py index 0b55a98..f9f520b 100644 --- a/signalduino/mqtt.py +++ b/signalduino/mqtt.py @@ -11,6 +11,12 @@ from .types import DecodedMessage, RawFrame from .persistence import get_or_create_client_id +# Import protocol loader helper to access preamble data +try: + from sd_protocols.loader import _protocol_handler +except ImportError: + _protocol_handler = None + class MqttPublisher: """Publishes DecodedMessage objects to an MQTT server and listens for commands.""" @@ -30,6 +36,7 @@ def __init__( self.client_id = get_or_create_client_id() self.client: Optional[mqtt.Client] = None # Will be set in __aenter__ self._listener_task: Optional[asyncio.Task[None]] = None # NEU: Task für den Command Listener + self._protocol_handler = _protocol_handler # Konfiguration: CLI/Args > ENV > Default self.mqtt_host = host or os.environ.get("MQTT_HOST", "localhost") @@ -224,8 +231,7 @@ async def _handle_command(self, command_name: str, payload: str) -> None: ) - @staticmethod - def _message_to_json(message: DecodedMessage) -> str: + def _message_to_json(self, message: DecodedMessage) -> str: """Serializes a DecodedMessage to a JSON string.""" # DecodedMessage uses dataclasses, but RawFrame inside it also uses a dataclass. @@ -242,6 +248,21 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: # Remove empty or non-useful fields for publication message_dict.pop("raw", None) # Do not publish raw frame data by default + # Append preamble to payload for FHEM compatibility (PreambleProtocolID#HexData) + preamble = "" + if self._protocol_handler: + try: + # check_property returns the value or default + preamble = self._protocol_handler.check_property(message.protocol_id, 'preamble', '') + except Exception as e: + self.logger.warning("Failed to get preamble: %s", e) + + # Add new 'preamble' field + message_dict["preamble"] = preamble + + # Ensure payload is uppercase, but DO NOT prepend preamble anymore + message_dict["payload"] = message.payload.upper() + return json.dumps(message_dict, indent=4) async def publish_simple(self, subtopic: str, payload: str, retain: bool = False) -> None: @@ -270,5 +291,3 @@ async def publish(self, message: DecodedMessage) -> None: self.logger.debug("Published message for protocol %s to %s", message.protocol_id, topic) except Exception: self.logger.error("Failed to publish message", exc_info=True) - - \ No newline at end of file diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index d894014..1d0d51b 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -27,12 +27,12 @@ def mock_controller(): def mock_decoded_message() -> DecodedMessage: return DecodedMessage( protocol_id="1", - payload="RSL: ID=01, SWITCH=01, CMD=OFF", + payload="9374A400", raw=RawFrame( - line="+MU;...", + line="MS;P1=1154;P2=-697;P3=559;P4=-1303;P5=-7173;D=351234341234341212341212123412343412341234341234343434343434343434;CP=3;SP=5;R=247;O;", rssi=-80, freq_afc=433.92, - message_type="MU", + message_type="MS", ), metadata={ "protocol_name": "Conrad RSL v1", @@ -135,6 +135,12 @@ async def test_mqtt_publisher_publish_success(MockClient, mock_decoded_message, payload_dict = json.loads(published_payload) assert payload_dict["protocol_id"] == "1" + + # Payload sollte KEINE Preamble mehr enthalten, aber das neue Feld "preamble" schon + # Protocol 1 (Conrad RSL v1) hat Preamble "P1#" + assert payload_dict["payload"] == "9374A400" + assert payload_dict["preamble"] == "P1#" + assert "raw" not in payload_dict # raw sollte entfernt werden assert call_kwargs == {} # assert {} da keine kwargs im Code von MqttPublisher.publish übergeben werden From cd8e09c88ea8c75dd9af2192761d4ea0ea6db4f0 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Sun, 11 Jan 2026 22:14:39 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Aktualisiere=20JSON-Ausgabe-Schema?= =?UTF-8?q?=20zur=20Verbesserung=20der=20Datenstruktur=20und=20-klarheit;?= =?UTF-8?q?=20entferne=20Preamble=20aus=20Payload=20und=20f=C3=BCge=20neue?= =?UTF-8?q?s=20Feld=20'raw'=20hinzu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 - README.adoc | 26 ++++++ docs/01_user_guide/mqtt_api.adoc | 43 +++++++++ .../protocol_details.adoc | 21 +++++ .../decisions/ADR-006-json-output-schema.adoc | 70 +++++++++++++++ .../ADR-007-data-and-raw-fields.adoc | 64 ++++++++++++++ .../proposals/json_schema_refinement.adoc | 88 +++++++++++++++++++ signalduino/mqtt.py | 9 +- signalduino/parser/mc.py | 28 +++++- signalduino/parser/mn.py | 4 +- signalduino/parser/ms.py | 4 +- signalduino/parser/mu.py | 26 +++++- signalduino/types.py | 5 +- tests/test_controller.py | 2 +- tests/test_mc_parser.py | 29 +++++- tests/test_mn_bresser_lightning.py | 3 +- tests/test_mqtt.py | 14 ++- tests/test_mu_parser.py | 30 ++++++- 18 files changed, 435 insertions(+), 33 deletions(-) create mode 100644 docs/architecture/decisions/ADR-006-json-output-schema.adoc create mode 100644 docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc create mode 100644 docs/architecture/proposals/json_schema_refinement.adoc diff --git a/.gitignore b/.gitignore index 1f492ea..18ddff3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,11 @@ pycache/ *.pyc .venv/ .env/ -temp_repo/ SIGNALDuino-Firmware/ .devcontainer/devcontainer.env .devcontainer/.devcontainer.env .devcontainer/mosquitto/data/ .devcontainer/mosquitto/log/ - .devcontainer/fhem-data/* !.devcontainer/fhem-data/FHEM .devcontainer/fhem-data/FHEM/* diff --git a/README.adoc b/README.adoc index ce1f942..c7c5da8 100644 --- a/README.adoc +++ b/README.adoc @@ -39,6 +39,32 @@ Die SIGNALDuino-Firmware (Microcontroller-Code) wird in einem separaten Reposito * **Ausführbares Hauptprogramm** – `main.py` bietet eine sofort einsatzbereite Lösung mit Logging, Signalbehandlung und Timeout‑Steuerung. * **Komprimierte Datenübertragung** – Effiziente Payload‑Kompression für MQTT‑Nachrichten. +[NOTE] +.JSON Output Schema +==== +Decodierte Nachrichten werden als JSON-Objekte publiziert. Die Protokoll-Metadaten sind nun im Feld `protocol` enthalten, das `data`-Feld enthält die reinen, von der Protokoll-Preamble bereinigten Nutzdaten, und das `raw`-Feld enthält die unveränderte, rohe Nachrichtenzeichenkette der Firmware. + +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", + "protocol_id": "125", + "raw": "MS;P0=-32001;P1=488;D=0101;CP=1;R=48;", + "metadata": { + "rssi": -74, + "freq_afc": 123 + }, + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + } +} +---- +==== + == Demo === MQTT-CLI-Integration diff --git a/docs/01_user_guide/mqtt_api.adoc b/docs/01_user_guide/mqtt_api.adoc index 706b502..670bd7b 100644 --- a/docs/01_user_guide/mqtt_api.adoc +++ b/docs/01_user_guide/mqtt_api.adoc @@ -36,6 +36,49 @@ Alle nachfolgenden Beispiele verwenden `signalduino/v1` als Basis. | Dekodierte Funknachrichten. |=== +=== Empfangene Nachrichten (Output-Schema) + +Dekodierte Funksignale werden auf dem Topic `signalduino/v1/state/messages` publiziert. + +Die Payload folgt dem neuen, strukturierten Schema, bei dem protokollspezifische Metadaten im Feld `protocol` enthalten sind, das `data`-Feld die reinen, von der Protokoll-Preamble bereinigten Nutzdaten enthält, und das `raw`-Feld die unveränderte, rohe Nachrichtenzeichenkette der Firmware. + +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", + "protocol_id": "125", + "raw": "MS;P0=-32001;P1=488;D=0101;CP=1;R=48;", + "metadata": { + "rssi": -74, + "freq_afc": 123 + }, + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + } +} +---- + +==== Detailierte Felder der Output-Nachricht + +[cols="1,3", options="header"] +|=== +| Feld | Beschreibung +| `data` +| Der dekodierte, bereinigte Payload (Hex- oder Bit-String), *ohne* Protokoll-Preamble (z.B. `W125#`). +| `protocol_id` +| Die numerische oder alphanumerische ID des erkannten Protokolls. +| `raw` +| Die ursprüngliche, unmodifizierte Nachrichtenzeichenkette (`string`), die von der Firmware empfangen wurde (z.B. `MS;P0=-32001;P1=488;D=0101;CP=1;R=48;`). +| `metadata` +| Allgemeine gerätespezifische Metadaten (z.B. `rssi`, `freq_afc`). +| `protocol` +| Ein Dictionary mit spezifischen Protokoll-Details, wie `name`, `id`, `preamble`, `format` und dem verwendeten `clock`-Wert. +|=== + === Request- und Response-Format Alle Requests verwenden das folgende JSON-Format. Für einfache Befehle (meiste GETs) kann die Payload einfach `{}` sein. diff --git a/docs/03_protocol_reference/protocol_details.adoc b/docs/03_protocol_reference/protocol_details.adoc index e111ef7..0831d78 100644 --- a/docs/03_protocol_reference/protocol_details.adoc +++ b/docs/03_protocol_reference/protocol_details.adoc @@ -6,6 +6,27 @@ PySignalduino unterstützt eine Vielzahl von Funkprotokollen im 433 MHz und 868 Die Datei `sd_protocols/protocols.json` ist die definitive Quelle für alle Protokollparameter (Timings, Preambles, Methoden). +=== Dekodiertes Nachrichtenformat + +Die dekodierten Nachrichten sind standardisierte JSON-Objekte, die protokollspezifische Metadaten im Feld `protocol` bereitstellen, den bereinigten Daten-Payload im Feld `data` (ohne Protokoll-Preamble), sowie die unveränderte, rohe Nachrichtenzeichenkette der Firmware im Feld `raw`. + +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", + "protocol_id": "125", + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + }, + "metadata": { ... }, + "raw": "MS;P0=-32001;P1=488;D=0101;CP=1;R=48;" +} +---- + == Auszug unterstützter Protokolle * **ID 10:** Oregon Scientific v2/v3 (Manchester, 433 MHz) diff --git a/docs/architecture/decisions/ADR-006-json-output-schema.adoc b/docs/architecture/decisions/ADR-006-json-output-schema.adoc new file mode 100644 index 0000000..0991b9d --- /dev/null +++ b/docs/architecture/decisions/ADR-006-json-output-schema.adoc @@ -0,0 +1,70 @@ += ADR-006: JSON Output Schema Refinement +:toc: macro +:toc-title: Inhaltsverzeichnis + +== Status +* **Status:** Proposed +* **Datum:** 2026-01-11 +* **Architecture Owner:** Roo (Architect) + +== Kontext +Das bestehende JSON-Output-Schema für decodierte Funksignale (von `DecodedMessage` abgeleitet) enthält die Protokoll-Preamble (z.B. `W125#`) als Präfix im Feld `payload`. Dies erschwert die automatische Verarbeitung durch Downstream-Systeme (wie MQTT-Clients oder andere FHEM-Module), da diese die Preamble manuell entfernen müssen, um an die reinen Nutzdaten zu gelangen. Des Weiteren fehlt eine standardisierte Möglichkeit, protokollspezifische Metadaten (wie Protokollname, Format, Taktfrequenz) im Output bereitzustellen, ohne diese in der allgemeinen `metadata`-Struktur zu verstecken. + +== Entscheidung +Wir werden das JSON-Output-Schema von `DecodedMessage` wie folgt anpassen: +1. **Nutzdaten-Bereinigung:** Die Protokoll-Preamble wird aus dem Nutzdatenfeld entfernt. Dieses Feld enthält nur noch die vom Protokolldecoder erzeugten reinen Daten (Hex- oder Bit-String). +2. **Hinzufügen des `protocol` Feldes:** Ein neues Feld `protocol` vom Typ `dict` wird zur `DecodedMessage` hinzugefügt, um strukturierte, protokollspezifische Informationen zu enthalten. + +Die Umbenennung des Nutzdatenfeldes von `payload` zu `data` sowie die Einführung des Feldes `raw` (für die ursprüngliche Nachricht) sind in link:ADR-007-data-and-raw-fields.adoc[ADR-007] dokumentiert, das dieses Schema ergänzt und präzisiert. Dieses ADR dient als Grundlage für die Einführung des `protocol`-Feldes und die Bereinigung des Nutzdateninhalts. + +=== Details zur neuen Struktur (Präzisiert durch ADR-007) + +| Feld | Typ | Beschreibung | +|---|---|---| +| `protocol_id` | `str` | Die numerische oder alphanumerische ID des erkannten Protokolls. | +| `data` | `str` | Die bereinigten Nutzdaten **ohne** Preamble und Postamble (ersetzt `payload`). | +| `raw` | `str` | Die ursprüngliche, unveränderte Nachricht vom Signalduino (z.B. `MU;...`). | +| `protocol` | `dict` | Strukturierte Metadaten des Protokolls. | +| `protocol.id` | `str` | Die ID des Protokolls (Redundanz zur einfachen Konsumierbarkeit). | +| `protocol.name` | `str` | Der menschenlesbare Name des Protokolls (aus `protocols.json`). | +| `protocol.preamble` | `str` | Die Preamble des Protokolls (z.B. `W125#`). | +| `protocol.format` | `str` | Das Format des Signals (z.B. `manchester`, `twostate`, `2-FSK`) (aus `protocols.json`). | +| `protocol.clock` | `int`/`float` | Der Clock-Wert, der für die Demodulation verwendet wurde (entweder `clockabs` oder `clockrange` Mittelwert/demodulierter Takt). | +| `protocol.modulation` | `str` | (Optional) Modulationsart (z.B. `2-FSK`, `GFSK`) für FSK-Protokolle. | + +=== Beispiel für die Datenstruktur (Präzisiert durch ADR-007) + +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", + "raw": "MC;LL=-1017;LH=932;...", + "protocol_id": "125", + "metadata": { + "rssi": -74, + "freq_afc": 123 + }, + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + } +} +---- + +== Konsequenzen +**Positiv:** +* Das `data`-Feld ist jetzt "sauber" und enthält nur die Nutzdaten. +* Protocolspezifische Metadaten sind standardisiert im `protocol`-Feld abrufbar. +* Vereinfacht die Integration mit Systemen, die strukturierte Daten erwarten. +* Das neue `raw`-Feld (siehe link:ADR-007-data-and-raw-fields.adoc[ADR-007]) ermöglicht besseres Debugging und vollständige Nachvollziehbarkeit. + +**Negativ:** +* Dies ist ein **Breaking Change** für alle existierenden Konsumenten des `DecodedMessage`-Outputs, die darauf angewiesen sind, dass die Preamble im `payload` enthalten ist. +* Alle Demodulations- und Parser-Logik muss angepasst werden, um die Preamble separat zu behandeln und das `protocol`-Feld sowie das neue `raw`-Feld zu befüllen. + +== Alternativen +1. **Preamble in `metadata` verschieben:** Hätte den Nutzdatenfeld gereinigt, aber die Protokolldetails weiterhin unstrukturiert gelassen. Abgelehnt, da ein dediziertes `protocol`-Feld die semantische Klarheit verbessert. +2. **Beibehaltung der alten Struktur:** Hätte Abwärtskompatibilität gewährleistet, aber die Notwendigkeit für eine Nutzdatenreinigung durch jeden Konsumenten beibehalten. Abgelehnt, da die verbesserte Struktur die Wartbarkeit und zukünftige Erweiterbarkeit deutlich erhöht. diff --git a/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc new file mode 100644 index 0000000..e11962c --- /dev/null +++ b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc @@ -0,0 +1,64 @@ += ADR-007: Renaming Payload to Data and Adding Raw Field +:doctype: article +:encoding: utf-8 +:lang: de +:status: Proposed +:decided-at: 2026-01-11 +:decided-by: Roo (Architect) + +[#adr-context] +== Kontext + +Im Zuge der Definition des neuen JSON-Output-Schemas (siehe ADR-006) wurde Feedback gesammelt, das zwei wesentliche Verbesserungen vorschlägt: + +1. **Ambiguität des Feldes `payload`:** Der Begriff `payload` wird in MQTT- und Messaging-Kontexten häufig für den gesamten Nachrichteninhalt verwendet. Die Verwendung von `payload` für die spezifischen, decodierten Hex-Daten kann daher zu Verwirrung führen. +2. **Bedarf an Rohdaten:** Für Debugging-Zwecke und fortgeschrittene Analysen ist es notwendig, Zugriff auf die ursprüngliche, unveränderte Nachricht zu haben, wie sie vom Signalduino-Gerät empfangen wurde (z.B. der komplette `MU;...`- oder `MC;...`-String), bevor irgendeine Verarbeitung oder Parsing stattgefunden hat. + +[#adr-decision] +== Entscheidung + +Wir passen das in ADR-006 definierte Schema wie folgt an: + +1. **Umbenennung `payload` zu `data`:** + Das Feld, das bisher `payload` hieß und die bereinigten Hex- oder Binärdaten (ohne Preamble) enthielt, wird in **`data`** umbenannt. + +2. **Einführung des Feldes `raw`:** + Es wird ein neues Feld **`raw`** auf der obersten Ebene des JSON-Objekts eingeführt. Dieses Feld enthält den ursprünglichen Nachrichten-String, der an den Parser übergeben wurde. + +=== Aktualisierte Datenstruktur + +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", // Ehemals "payload" + "raw": "MC;LL=-1017;LH=932;...", // Der originale Input-String + "protocol_id": "125", + "metadata": { + "rssi": -74, + "freq_afc": 123 + }, + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + } +} +---- + +[#adr-consequences] +== Konsequenzen + +=== Positive Konsequenzen +* **Klarere Semantik:** `data` ist ein neutralerer Begriff für den Dateninhalt und vermeidet die Überladung des Begriffs `payload`. +* **Verbesserte Debugging-Möglichkeiten:** Durch das `raw`-Feld können Entwickler und User jederzeit nachvollziehen, was genau vom Gerät empfangen wurde, und Parser-Fehler leichter diagnostizieren. +* **Vollständigkeit:** Keine Information geht verloren; sowohl die Rohdaten als auch die interpretierten Daten stehen zur Verfügung. + +=== Negative Konsequenzen +* **Breaking Change:** Dies ändert das gerade in ADR-006 vorgeschlagene Schema. Da ADR-006 jedoch noch neu ist, sollte der Anpassungsaufwand gering sein. Code, der bereits `payload` verwendet, muss auf `data` umgestellt werden. + +[#adr-alternatives] +== Alternativen +* **Beibehaltung von `payload`:** Wurde verworfen, um die Ambiguität aufzulösen. +* **`raw` als Objekt:** Es wurde erwogen, `raw` strukturierter zu gestalten, aber für die einfache Nachvollziehbarkeit ist der originale String am wertvollsten. diff --git a/docs/architecture/proposals/json_schema_refinement.adoc b/docs/architecture/proposals/json_schema_refinement.adoc new file mode 100644 index 0000000..2f5ed95 --- /dev/null +++ b/docs/architecture/proposals/json_schema_refinement.adoc @@ -0,0 +1,88 @@ += Architecture Proposal: JSON Output Schema Refinement +:toc: macro +:toc-title: Inhaltsverzeichnis + +== Status +* **Status:** Proposed +* **Datum:** 2026-01-11 +* **Autor:** Roo (Architect) + +== Kontext +Das aktuelle JSON-Nachrichtenschema für decodierte Signale vermischt Protokoll-Metadaten (die Preamble) mit den eigentlichen Nutzdaten (Payload). Zudem fehlen strukturierte Informationen über das erkannte Protokoll, die für weiterverarbeitende Systeme (z.B. Home Assistant, FHEM via MQTT) nützlich wären. + +Aktuelles Beispiel: +[source,json] +---- +{ + "payload": "W125#30E0A1AA4241DE6C000200000BC5", + "metadata": { ... }, + "protocol_id": "125" +} +---- + +== Problemstellung +1. **Verschmutzter Payload:** Der Payload enthält die Preamble (z.B. `W125#`), was nachgelagerte Parser zwingt, diese zu entfernen. +2. **Fehlende Protokolldetails:** Es gibt kein dediziertes Feld, das alle relevanten statischen und dynamischen Eigenschaften des erkannten Protokolls zusammenfasst. + +== Lösungsvorschlag + +=== 1. Bereinigung des Payloads +Die Preamble wird aus dem `payload`-Feld entfernt. Der Payload enthält nur noch die reinen Daten (Hex-String). + +=== 2. Erweiterung um `protocol`-Objekt +Ein neues Feld `protocol` wird eingeführt, das strukturierte Informationen enthält. + +Neues Schema: +[source,json] +---- +{ + "payload": "30E0A1AA4241DE6C000200000BC5", + "protocol_id": "125", + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "manchester", + "clock": 17257, // Optional: erkannter oder definierter Takt + "modulation": "2-FSK" // Optional: aus Protokolldefinition + }, + "metadata": { + "rssi": -74, + "freq_afc": 123 + } +} +---- + +== Betroffene Komponenten + +=== `sd_protocols` +Die Mixins für die Demodulation müssen angepasst werden, um die Preamble nicht mehr an den Payload anzuhängen, sondern separat zurückzugeben oder verfügbar zu machen. + +* `sd_protocols/manchester.py`: `_demodulate_mc_data` +* `sd_protocols/message_synced.py`: `demodulate_ms` +* `sd_protocols/message_unsynced.py`: `demodulate_mu` +* `sd_protocols/sd_protocols.py`: `demodulate_mc`, `demodulate_mn` + +=== `signalduino/types.py` +Die Klasse `DecodedMessage` muss um das Feld `protocol` erweitert werden. + +[source,python] +---- +@dataclass(slots=True) +class DecodedMessage: + protocol_id: str + payload: str + raw: RawFrame + metadata: dict = field(default_factory=dict) + protocol: dict = field(default_factory=dict) # Neu +---- + +=== `signalduino/parser` +Die Parser-Klassen (`MCParser`, `MSParser`, `MUParser`, `MNParser`) müssen sicherstellen, dass das `protocol`-Feld korrekt aus den Rückgabewerten der Protokollschicht befüllt wird. + +== Migration +Dies ist eine **Breaking Change** für Konsumenten, die erwarten, dass die Preamble Teil des Payloads ist. Die Versionsnummer sollte entsprechend (Minor oder Major, je nach Versionierungsstrategie) erhöht werden. + +== Alternativen +* **Status Quo beibehalten:** Führt zu unnötigem Parsing-Aufwand bei jedem Client. +* **Preamble nur in Metadata:** Löst das Payload-Problem, bietet aber keine strukturierte Sicht auf das Protokoll. diff --git a/signalduino/mqtt.py b/signalduino/mqtt.py index f9f520b..f9aa6a7 100644 --- a/signalduino/mqtt.py +++ b/signalduino/mqtt.py @@ -246,9 +246,10 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: message_dict["raw"] = _raw_frame_to_dict(message_dict["raw"]) # Remove empty or non-useful fields for publication - message_dict.pop("raw", None) # Do not publish raw frame data by default + # Note: 'raw' is now a string (ADR-007) and should be published. + # The pop operation (line 249 in original) is removed to include it. - # Append preamble to payload for FHEM compatibility (PreambleProtocolID#HexData) + # Append preamble to data for FHEM compatibility (PreambleProtocolID#HexData) preamble = "" if self._protocol_handler: try: @@ -260,8 +261,8 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: # Add new 'preamble' field message_dict["preamble"] = preamble - # Ensure payload is uppercase, but DO NOT prepend preamble anymore - message_dict["payload"] = message.payload.upper() + # Ensure data (formerly payload) is uppercase + message_dict["data"] = message.data.upper() return json.dumps(message_dict, indent=4) diff --git a/signalduino/parser/mc.py b/signalduino/parser/mc.py index 2fea412..221f096 100644 --- a/signalduino/parser/mc.py +++ b/signalduino/parser/mc.py @@ -75,7 +75,7 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: try: # Replace generic demodulate with MC-specific processing in the protocol layer # This call should now encapsulate the logic from SIGNALduino_Parse_MC (lines 2840-2919) - demodulated_list = self.protocols.demodulate_mc(msg_data, frame) + demodulated_list = self.protocols.demodulate_mc(msg_data, "MC") except Exception: self.logger.exception("Error during MC demodulation for line: %s", frame.line) return @@ -85,11 +85,31 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: self.logger.warning("Invalid result from demodulator: %s", decoded) continue + protocol_id_str = str(decoded["protocol_id"]) + + # Zugriff auf Protokolldaten über get_protocol_list() + protocol_data = self.protocols.get_protocol_list().get(protocol_id_str, {}) + raw_payload = str(decoded.get("payload", "")) + + # Protokollmetadaten extrahieren + protocol_meta: Dict[str, Any] = { + "id": protocol_id_str, + "name": protocol_data.get("name", f"Protocol_{protocol_id_str}"), + "format": protocol_data.get("format", ""), + "clock": protocol_data.get("clock", None), + "preamble": protocol_data.get("preamble", ""), + } + + # 1. Entferne die Preamble aus der Payload + preamble_len = len(protocol_meta["preamble"]) + payload = raw_payload[preamble_len:] + yield DecodedMessage( - protocol_id=str(decoded["protocol_id"]), - payload=str(decoded.get("payload", "")), - raw=frame, + protocol_id=protocol_id_str, + data=payload, + raw=frame.line, metadata=decoded.get("meta", {}), + protocol=protocol_meta, ) def _parse_to_dict(self, line: str) -> Dict[str, Any]: diff --git a/signalduino/parser/mn.py b/signalduino/parser/mn.py index 3a3ce42..b98b07f 100644 --- a/signalduino/parser/mn.py +++ b/signalduino/parser/mn.py @@ -180,8 +180,8 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: yield DecodedMessage( protocol_id=str(pid), - payload=final_payload, - raw=frame, + data=final_payload, + raw=frame.line, metadata={ "rssi": rssi, "freq_afc": freq_afc, diff --git a/signalduino/parser/ms.py b/signalduino/parser/ms.py index d1a2114..78fb0ed 100644 --- a/signalduino/parser/ms.py +++ b/signalduino/parser/ms.py @@ -60,8 +60,8 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: yield DecodedMessage( protocol_id=str(decoded["protocol_id"]), - payload=str(decoded.get("payload", "")), - raw=frame, + data=str(decoded.get("payload", "")), + raw=frame.line, metadata=decoded.get("meta", {}), ) diff --git a/signalduino/parser/mu.py b/signalduino/parser/mu.py index e3f1583..e48b6c1 100644 --- a/signalduino/parser/mu.py +++ b/signalduino/parser/mu.py @@ -72,11 +72,31 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: self.logger.warning("Invalid result from demodulator: %s", decoded) continue + protocol_id_str = str(decoded["protocol_id"]) + + # Korrektur: Zugriff auf Protokolldaten über get_protocol_list() + protocol_data = self.protocols.get_protocol_list().get(protocol_id_str, {}) + raw_payload = str(decoded.get("payload", "")) + + # Protokollmetadaten direkt aus dem Protokolldict extrahieren + protocol_meta: Dict[str, Any] = { + "id": protocol_id_str, + "name": protocol_data.get("name", f"Protocol_{protocol_id_str}"), # Fallback + "format": protocol_data.get("format", ""), + "clock": protocol_data.get("clock", None), + "preamble": protocol_data.get("preamble", ""), + } + + # 1. Entferne die Preamble aus der Payload + preamble_len = len(protocol_meta["preamble"]) + payload = raw_payload[preamble_len:] + yield DecodedMessage( - protocol_id=str(decoded["protocol_id"]), - payload=str(decoded.get("payload", "")), - raw=frame, + protocol_id=protocol_id_str, + data=payload, + raw=frame.line, metadata=decoded.get("meta", {}), + protocol=protocol_meta, ) def _parse_to_dict(self, line: str) -> Dict[str, Any]: diff --git a/signalduino/types.py b/signalduino/types.py index 72d03e0..5811acc 100644 --- a/signalduino/types.py +++ b/signalduino/types.py @@ -26,9 +26,10 @@ class DecodedMessage: """Higher-level frame after running through the parser.""" protocol_id: str - payload: str - raw: RawFrame + data: str + raw: str metadata: dict = field(default_factory=dict) + protocol: dict = field(default_factory=dict) @dataclass(slots=True) diff --git a/tests/test_controller.py b/tests/test_controller.py index 0e23f8a..c0d336d 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -194,7 +194,7 @@ async def test_send_command_timeout(mock_transport, mock_parser, mock_controller async def test_message_callback(mock_transport, mock_parser, mock_controller_initialize): """Test message callback invocation.""" callback_mock = Mock() - decoded_msg = DecodedMessage(protocol_id="1", payload="test", raw=RawFrame(line="")) + decoded_msg = DecodedMessage(protocol_id="1", data="test", raw="") mock_parser.parse_line.return_value = [decoded_msg] # Use side_effect to return the line once, then fall back to the fixture's yielding None diff --git a/tests/test_mc_parser.py b/tests/test_mc_parser.py index fb338ae..a87f55b 100644 --- a/tests/test_mc_parser.py +++ b/tests/test_mc_parser.py @@ -36,16 +36,41 @@ def mc_parser(mock_protocols, logger): ) def test_mc_parser_valid_message(mc_parser, mock_protocols, line, expected_protocol, expected_payload, expected_rssi): """Test valid MC messages.""" + + # Mock Protokolldaten + MOCKED_PROTOCOLS = { + "57": {"name": "FS20", "preamble": "W57#", "format": "HEX", "clock": 332}, + "119": {"name": "Funkbus", "preamble": "W119#", "format": "HEX", "clock": 342}, + "108": {"name": "Grothe", "preamble": "W108#", "format": "HEX", "clock": 500}, + } + mock_protocols.get_protocol_list.return_value = MOCKED_PROTOCOLS + + # Protokoll-Metadaten extrahieren + protocol_meta = MOCKED_PROTOCOLS.get(expected_protocol, {"preamble": ""}) + preamble = protocol_meta["preamble"] + + # Der Mock muss die Payload MIT Präambel zurückgeben + raw_payload_with_preamble = f"{preamble}{expected_payload}" + frame = RawFrame(line=line) - demodulated = [{"protocol_id": expected_protocol, "payload": expected_payload}] + demodulated = [{"protocol_id": expected_protocol, "payload": raw_payload_with_preamble}] mock_protocols.demodulate_mc.return_value = demodulated result = list(mc_parser.parse(frame)) mock_protocols.demodulate_mc.assert_called_once() assert len(result) == 1 + + # Neue/geänderte Assertions assert result[0].protocol_id == expected_protocol - assert result[0].payload == expected_payload + assert result[0].data == expected_payload # Erwartet die bereinigte Payload + assert result[0].raw == line + + # Protokoll-Metadaten Assertions + assert result[0].protocol["id"] == expected_protocol + assert result[0].protocol["name"] == protocol_meta["name"] + assert result[0].protocol["preamble"] == preamble + assert frame.rssi == expected_rssi diff --git a/tests/test_mn_bresser_lightning.py b/tests/test_mn_bresser_lightning.py index 8fcf92f..2a7f0f1 100644 --- a/tests/test_mn_bresser_lightning.py +++ b/tests/test_mn_bresser_lightning.py @@ -31,7 +31,8 @@ def test_bresser_lightning_decoding(caplog): msg = messages[0] assert msg.protocol_id == expected_protocol_id - assert msg.payload == expected_payload + assert msg.data == expected_payload + assert msg.raw == line assert msg.metadata["rfmode"] == "Bresser_lightning" # 26000000 / 16384 * -2 / 1000 = -3.1738... -> rounded to -3.0 assert msg.metadata["freq_afc"] == -3.0 \ No newline at end of file diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index 1d0d51b..7123afc 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -27,13 +27,8 @@ def mock_controller(): def mock_decoded_message() -> DecodedMessage: return DecodedMessage( protocol_id="1", - payload="9374A400", - raw=RawFrame( - line="MS;P1=1154;P2=-697;P3=559;P4=-1303;P5=-7173;D=351234341234341212341212123412343412341234341234343434343434343434;CP=3;SP=5;R=247;O;", - rssi=-80, - freq_afc=433.92, - message_type="MS", - ), + data="9374A400", + raw="MS;P1=1154;P2=-697;P3=559;P4=-1303;P5=-7173;D=351234341234341212341212123412343412341234341234343434343434343434;CP=3;SP=5;R=247;O;", metadata={ "protocol_name": "Conrad RSL v1", "message_hex": "AABBCC", @@ -138,10 +133,11 @@ async def test_mqtt_publisher_publish_success(MockClient, mock_decoded_message, # Payload sollte KEINE Preamble mehr enthalten, aber das neue Feld "preamble" schon # Protocol 1 (Conrad RSL v1) hat Preamble "P1#" - assert payload_dict["payload"] == "9374A400" + assert payload_dict["data"] == "9374A400" assert payload_dict["preamble"] == "P1#" - assert "raw" not in payload_dict # raw sollte entfernt werden + # 'raw' sollte jetzt enthalten sein, da es ein String-Feld ist. + assert payload_dict["raw"] == "MS;P1=1154;P2=-697;P3=559;P4=-1303;P5=-7173;D=351234341234341212341212123412343412341234341234343434343434343434;CP=3;SP=5;R=247;O;" assert call_kwargs == {} # assert {} da keine kwargs im Code von MqttPublisher.publish übergeben werden assert "Published message for protocol 1 to test/signalduino/v1/state/messages" in caplog.text diff --git a/tests/test_mu_parser.py b/tests/test_mu_parser.py index 1eba1ae..b27bffd 100644 --- a/tests/test_mu_parser.py +++ b/tests/test_mu_parser.py @@ -20,15 +20,43 @@ def mu_parser(mock_protocols, logger): ) def test_mu_parser_valid_messages(mu_parser, mock_protocols, line, expected_protocol, expected_rssi): """Test valid MU messages.""" + + # Mock Protokolldaten + MOCKED_PROTOCOLS = { + "44": {"name": "IT-V1_V3", "preamble": "W44#", "format": "BITS", "clock": 250}, + "84": {"name": "Bresser-3_1", "preamble": "W84#", "format": "BITS", "clock": 330}, + } + mock_protocols.get_protocol_list.return_value = MOCKED_PROTOCOLS + + # Erwartete Payloads (Roh-Payload enthält Präambel, gereinigte Payload nicht) + if expected_protocol == "44": + raw_payload = "W44#123456" + expected_clean_payload = "123456" + expected_protocol_meta = MOCKED_PROTOCOLS["44"] + else: # expected_protocol == "84" + raw_payload = "W84#ABCDEF" + expected_clean_payload = "ABCDEF" + expected_protocol_meta = MOCKED_PROTOCOLS["84"] + frame = RawFrame(line=line) - demodulated = [{"protocol_id": expected_protocol}] + demodulated = [{"protocol_id": expected_protocol, "payload": raw_payload}] mock_protocols.demodulate.return_value = demodulated result = list(mu_parser.parse(frame)) mock_protocols.demodulate.assert_called_once() assert len(result) == 1 + + # Neue/geänderte Assertions assert result[0].protocol_id == expected_protocol + assert result[0].data == expected_clean_payload + assert result[0].raw == line + + # Protokoll-Metadaten Assertions + assert result[0].protocol["id"] == expected_protocol + assert result[0].protocol["name"] == expected_protocol_meta["name"] + assert result[0].protocol["preamble"] == expected_protocol_meta["preamble"] + # Correct the expected RSSI value for R=217 if expected_protocol == "84": assert frame.rssi == -93.5 From 673730031c3ecef3430aef48e52e07c609b6ce35 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Sun, 11 Jan 2026 22:53:04 +0000 Subject: [PATCH 3/5] feat: JSON streucture optimized and docs updated --- .devcontainer/fhem-data/FHEM/99_MyUtils.pm | 66 +------------------ .../fhem-data/fhem_signalduino_example.cfg | 2 +- README.adoc | 22 +------ .../decisions/ADR-006-json-output-schema.adoc | 21 +----- .../ADR-007-data-and-raw-fields.adoc | 20 +----- .../snippets/json_output_schema_example.adoc | 18 +++++ signalduino/mqtt.py | 9 ++- signalduino/parser/mc.py | 1 - signalduino/parser/mn.py | 2 +- signalduino/parser/ms.py | 2 +- signalduino/parser/mu.py | 1 - signalduino/types.py | 1 - tests/test_controller.py | 2 +- tests/test_mc_parser.py | 2 +- tests/test_mn_bresser_lightning.py | 2 +- tests/test_mn_parser.py | 4 +- tests/test_mqtt.py | 4 +- tests/test_ms_parser.py | 2 +- tests/test_mu_parser.py | 2 +- 19 files changed, 41 insertions(+), 142 deletions(-) create mode 100644 docs/architecture/snippets/json_output_schema_example.adoc diff --git a/.devcontainer/fhem-data/FHEM/99_MyUtils.pm b/.devcontainer/fhem-data/FHEM/99_MyUtils.pm index a1dfca2..7f1cf32 100644 --- a/.devcontainer/fhem-data/FHEM/99_MyUtils.pm +++ b/.devcontainer/fhem-data/FHEM/99_MyUtils.pm @@ -13,70 +13,6 @@ sub MyUtils_Initialize { # Enter you functions below _this_ line. -sub MqttSignalduino_DispatchFromJSON { - my ($json_str, $name) = @_; - - if (!defined($json_str) || !defined($name)) { - Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: Missing arguments (JSON or Name)"; - return; - } - - my $hash = $defs{$name}; - if (!defined($hash)) { - Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: Device $name not found"; - return; - } - - my $data; - eval { - $data = decode_json($json_str); - }; - if ($@) { - Log3 $name, 3, "MqttSignalduino_DispatchFromJSON: JSON decode error: $@"; - return; - } - #print Dumper($data); - - $hash->{Clients} = 'SD_WS:'; - $hash->{MatchList} = { '12:SD_WS' => '^W\d+x{0,1}#.*' }; - - # Extract fields based on expected JSON structure from MQTT - # The full dispatch message is now constructed by combining 'preamble' (e.g., W126#) and 'state' (e.g., HexData). - - my $rmsg = $data->{rawmsg} // undef; - my $dmsg = $data->{payload} // undef; - my $rssi = $data->{metadata}->{rssi} // undef; - my $id = $data->{protocol}->{id} // undef; - my $freqafc = $data->{metadata}->{freqafc} // undef; - - if (!defined($dmsg)) { - Log3 $name, 4, "MqttSignalduino_DispatchFromJSON: No dmsg found in JSON"; - return; - } - - # Update hash with latest values - #$hash->{RAWMSG} = $rmsg if (defined($rmsg)); - #$hash->{RSSI} = $rssi if (defined($rssi)); - - # Prepare addvals similar to SIGNALduno_Dispatch - my %addvals = ( - Protocol_ID => $id - ); - - if (defined($rmsg)) { - $addvals{RAWMSG} = $rmsg; - } - if (defined($rssi)) { - $addvals{RSSI} = $rssi; - } - if (defined($freqafc)) { - $addvals{FREQAFC} = $freqafc; - } - - Log3 $name, 5, "MqttSignalduino_DispatchFromJSON: Dispatching $dmsg"; - - # Call FHEM Dispatch function - Dispatch($hash, $dmsg, \%addvals); -} +# MqttSignalduino_DispatchFromJSON wurde nach FHEM/lib/SD_Dispatch.pm verschoben. 1; diff --git a/.devcontainer/fhem-data/fhem_signalduino_example.cfg b/.devcontainer/fhem-data/fhem_signalduino_example.cfg index dd82fcb..5822557 100755 --- a/.devcontainer/fhem-data/fhem_signalduino_example.cfg +++ b/.devcontainer/fhem-data/fhem_signalduino_example.cfg @@ -40,7 +40,7 @@ attr mqtt_broker autocreate simple define PySignalDuino MQTT2_DEVICE setuuid PySignalDuino 695e9c21-f33f-c986-4f81-a9f0ab37b6bcedf8 attr PySignalDuino IODev mqtt_broker -attr PySignalDuino readingList signalduino/v1/state/messages:.* { MqttSignalduino_DispatchFromJSON($EVENT, $NAME);; json2nameValue($EVENT,'MSG_');; }\ +attr PySignalDuino readingList signalduino/v1/state/messages:.* { use FHEM::Devices::SIGNALDuino::Message;; FHEM::Devices::SIGNALDuino::Message::json2Dispatch($EVENT, $NAME);; json2nameValue($EVENT,'MSG_');; }\ signalduino/v1/responses:.* { json2nameValue($EVENT, 'RESP_') }\ signalduino/v1/errors:.* { json2nameValue($EVENT, 'ERR_') } # attr PySignalDuino Clients :CUL_EM:CUL_FHTTK:CUL_TCM97001:CUL_TX:CUL_WS:Dooya:FHT:FLAMINGO:FS10:FS20:Fernotron:Hideki:IT:KOPP_FC:LaCrosse:OREGON:PCA301:RFXX10REC:Revolt:SD_AS:SD_Rojaflex:SD_BELL:SD_GT:SD_Keeloq:SD_RSL:SD_UT:SD_WS07:SD_WS09:SD_WS:SD_WS_Maverick:SOMFY:Siro:SIGNALduino_un: diff --git a/README.adoc b/README.adoc index c7c5da8..5baf577 100644 --- a/README.adoc +++ b/README.adoc @@ -44,26 +44,8 @@ Die SIGNALDuino-Firmware (Microcontroller-Code) wird in einem separaten Reposito ==== Decodierte Nachrichten werden als JSON-Objekte publiziert. Die Protokoll-Metadaten sind nun im Feld `protocol` enthalten, das `data`-Feld enthält die reinen, von der Protokoll-Preamble bereinigten Nutzdaten, und das `raw`-Feld enthält die unveränderte, rohe Nachrichtenzeichenkette der Firmware. -[source,json] ----- -{ - "data": "30E0A1AA4241DE6C000200000BC5", - "protocol_id": "125", - "raw": "MS;P0=-32001;P1=488;D=0101;CP=1;R=48;", - "metadata": { - "rssi": -74, - "freq_afc": 123 - }, - "protocol": { - "name": "WH31", - "id": "125", - "preamble": "W125#", - "format": "2-FSK", - "clock": 17257 - } -} ----- -==== +include::./docs/architecture/snippets/json_output_schema_example.adoc[] + == Demo diff --git a/docs/architecture/decisions/ADR-006-json-output-schema.adoc b/docs/architecture/decisions/ADR-006-json-output-schema.adoc index 0991b9d..4597e01 100644 --- a/docs/architecture/decisions/ADR-006-json-output-schema.adoc +++ b/docs/architecture/decisions/ADR-006-json-output-schema.adoc @@ -21,7 +21,6 @@ Die Umbenennung des Nutzdatenfeldes von `payload` zu `data` sowie die Einführun | Feld | Typ | Beschreibung | |---|---|---| -| `protocol_id` | `str` | Die numerische oder alphanumerische ID des erkannten Protokolls. | | `data` | `str` | Die bereinigten Nutzdaten **ohne** Preamble und Postamble (ersetzt `payload`). | | `raw` | `str` | Die ursprüngliche, unveränderte Nachricht vom Signalduino (z.B. `MU;...`). | | `protocol` | `dict` | Strukturierte Metadaten des Protokolls. | @@ -34,25 +33,7 @@ Die Umbenennung des Nutzdatenfeldes von `payload` zu `data` sowie die Einführun === Beispiel für die Datenstruktur (Präzisiert durch ADR-007) -[source,json] ----- -{ - "data": "30E0A1AA4241DE6C000200000BC5", - "raw": "MC;LL=-1017;LH=932;...", - "protocol_id": "125", - "metadata": { - "rssi": -74, - "freq_afc": 123 - }, - "protocol": { - "name": "WH31", - "id": "125", - "preamble": "W125#", - "format": "2-FSK", - "clock": 17257 - } -} ----- +include::../snippets/json_output_schema_example.adoc[] == Konsequenzen **Positiv:** diff --git a/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc index e11962c..7823799 100644 --- a/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc +++ b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc @@ -27,25 +27,7 @@ Wir passen das in ADR-006 definierte Schema wie folgt an: === Aktualisierte Datenstruktur -[source,json] ----- -{ - "data": "30E0A1AA4241DE6C000200000BC5", // Ehemals "payload" - "raw": "MC;LL=-1017;LH=932;...", // Der originale Input-String - "protocol_id": "125", - "metadata": { - "rssi": -74, - "freq_afc": 123 - }, - "protocol": { - "name": "WH31", - "id": "125", - "preamble": "W125#", - "format": "2-FSK", - "clock": 17257 - } -} ----- +include::../snippets/json_output_schema_example.adoc[] [#adr-consequences] == Konsequenzen diff --git a/docs/architecture/snippets/json_output_schema_example.adoc b/docs/architecture/snippets/json_output_schema_example.adoc new file mode 100644 index 0000000..47c1fdc --- /dev/null +++ b/docs/architecture/snippets/json_output_schema_example.adoc @@ -0,0 +1,18 @@ +[source,json] +---- +{ + "data": "30E0A1AA4241DE6C000200000BC5", + "raw": "MC;LL=-1017;LH=932;...", + "metadata": { + "rssi": -74, + "freq_afc": 123 + }, + "protocol": { + "name": "WH31", + "id": "125", + "preamble": "W125#", + "format": "2-FSK", + "clock": 17257 + } +} +---- diff --git a/signalduino/mqtt.py b/signalduino/mqtt.py index f9aa6a7..9090246 100644 --- a/signalduino/mqtt.py +++ b/signalduino/mqtt.py @@ -253,8 +253,10 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: preamble = "" if self._protocol_handler: try: - # check_property returns the value or default - preamble = self._protocol_handler.check_property(message.protocol_id, 'preamble', '') + protocol_id = message.protocol.get('id') + if protocol_id: + # check_property returns the value or default + preamble = self._protocol_handler.check_property(protocol_id, 'preamble', '') except Exception as e: self.logger.warning("Failed to get preamble: %s", e) @@ -289,6 +291,7 @@ async def publish(self, message: DecodedMessage) -> None: topic = f"{self.base_topic}/state/messages" payload = self._message_to_json(message) await self.client.publish(topic, payload) - self.logger.debug("Published message for protocol %s to %s", message.protocol_id, topic) + protocol_id = message.protocol.get('id', 'N/A') + self.logger.debug("Published message for protocol %s to %s", protocol_id, topic) except Exception: self.logger.error("Failed to publish message", exc_info=True) diff --git a/signalduino/parser/mc.py b/signalduino/parser/mc.py index 221f096..6cf4643 100644 --- a/signalduino/parser/mc.py +++ b/signalduino/parser/mc.py @@ -105,7 +105,6 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: payload = raw_payload[preamble_len:] yield DecodedMessage( - protocol_id=protocol_id_str, data=payload, raw=frame.line, metadata=decoded.get("meta", {}), diff --git a/signalduino/parser/mn.py b/signalduino/parser/mn.py index b98b07f..5f74b15 100644 --- a/signalduino/parser/mn.py +++ b/signalduino/parser/mn.py @@ -179,7 +179,6 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: self.logger.info("MN Parse: Decoded matched MN Protocol id %s dmsg=%s", pid, final_payload) yield DecodedMessage( - protocol_id=str(pid), data=final_payload, raw=frame.line, metadata={ @@ -188,4 +187,5 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: "modulation": modulation, "rfmode": proto_rfmode }, + protocol={"id": str(pid), "model": "MN"} ) diff --git a/signalduino/parser/ms.py b/signalduino/parser/ms.py index 78fb0ed..ecc91c8 100644 --- a/signalduino/parser/ms.py +++ b/signalduino/parser/ms.py @@ -59,10 +59,10 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: continue yield DecodedMessage( - protocol_id=str(decoded["protocol_id"]), data=str(decoded.get("payload", "")), raw=frame.line, metadata=decoded.get("meta", {}), + protocol={"id": str(decoded["protocol_id"]), "model": "MS"}, ) def _parse_to_dict(self, line: str) -> Dict[str, Any]: diff --git a/signalduino/parser/mu.py b/signalduino/parser/mu.py index e48b6c1..baa04d3 100644 --- a/signalduino/parser/mu.py +++ b/signalduino/parser/mu.py @@ -92,7 +92,6 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: payload = raw_payload[preamble_len:] yield DecodedMessage( - protocol_id=protocol_id_str, data=payload, raw=frame.line, metadata=decoded.get("meta", {}), diff --git a/signalduino/types.py b/signalduino/types.py index 5811acc..e96c90d 100644 --- a/signalduino/types.py +++ b/signalduino/types.py @@ -25,7 +25,6 @@ class RawFrame: class DecodedMessage: """Higher-level frame after running through the parser.""" - protocol_id: str data: str raw: str metadata: dict = field(default_factory=dict) diff --git a/tests/test_controller.py b/tests/test_controller.py index c0d336d..6dee4af 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -194,7 +194,7 @@ async def test_send_command_timeout(mock_transport, mock_parser, mock_controller async def test_message_callback(mock_transport, mock_parser, mock_controller_initialize): """Test message callback invocation.""" callback_mock = Mock() - decoded_msg = DecodedMessage(protocol_id="1", data="test", raw="") + decoded_msg = DecodedMessage(data="test", raw="", protocol={"id": "1"}) mock_parser.parse_line.return_value = [decoded_msg] # Use side_effect to return the line once, then fall back to the fixture's yielding None diff --git a/tests/test_mc_parser.py b/tests/test_mc_parser.py index a87f55b..ff4358c 100644 --- a/tests/test_mc_parser.py +++ b/tests/test_mc_parser.py @@ -62,7 +62,7 @@ def test_mc_parser_valid_message(mc_parser, mock_protocols, line, expected_proto assert len(result) == 1 # Neue/geänderte Assertions - assert result[0].protocol_id == expected_protocol + assert result[0].protocol["id"] == expected_protocol assert result[0].data == expected_payload # Erwartet die bereinigte Payload assert result[0].raw == line diff --git a/tests/test_mn_bresser_lightning.py b/tests/test_mn_bresser_lightning.py index 2a7f0f1..e49a806 100644 --- a/tests/test_mn_bresser_lightning.py +++ b/tests/test_mn_bresser_lightning.py @@ -30,7 +30,7 @@ def test_bresser_lightning_decoding(caplog): assert len(messages) == 1 msg = messages[0] - assert msg.protocol_id == expected_protocol_id + assert msg.protocol["id"] == expected_protocol_id assert msg.data == expected_payload assert msg.raw == line assert msg.metadata["rfmode"] == "Bresser_lightning" diff --git a/tests/test_mn_parser.py b/tests/test_mn_parser.py index 01b25a4..7013599 100644 --- a/tests/test_mn_parser.py +++ b/tests/test_mn_parser.py @@ -110,7 +110,7 @@ def test_mn_parser_messages( if not raises_exception: assert len(result) == expected_message_count if result: - assert result[0].protocol_id == expected_protocol_id + assert result[0].protocol["id"] == expected_protocol_id else: assert not result else: @@ -202,7 +202,7 @@ def test_mn_parser_messages_perl_migration( if expected_message_count > 0: # Verify first message's protocol ID only if expected_protocol_id is set if expected_protocol_id is not None: - assert result[0].protocol_id == expected_protocol_id + assert result[0].protocol["id"] == expected_protocol_id # Verify freq_afc if expected if expected_freq_afc is not None: diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index 7123afc..bdf4714 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -26,7 +26,6 @@ def mock_controller(): @pytest.fixture def mock_decoded_message() -> DecodedMessage: return DecodedMessage( - protocol_id="1", data="9374A400", raw="MS;P1=1154;P2=-697;P3=559;P4=-1303;P5=-7173;D=351234341234341212341212123412343412341234341234343434343434343434;CP=3;SP=5;R=247;O;", metadata={ @@ -35,6 +34,7 @@ def mock_decoded_message() -> DecodedMessage: "message_bits": "101010101011101111001100", "is_repeat": False, }, + protocol={"id": "1"}, # Hinzugefügtes Protokoll-ID-Feld ) @pytest.fixture @@ -129,7 +129,7 @@ async def test_mqtt_publisher_publish_success(MockClient, mock_decoded_message, assert isinstance(published_payload, str) payload_dict = json.loads(published_payload) - assert payload_dict["protocol_id"] == "1" + assert payload_dict["protocol"]["id"] == "1" # Payload sollte KEINE Preamble mehr enthalten, aber das neue Feld "preamble" schon # Protocol 1 (Conrad RSL v1) hat Preamble "P1#" diff --git a/tests/test_ms_parser.py b/tests/test_ms_parser.py index a60045f..ee00018 100644 --- a/tests/test_ms_parser.py +++ b/tests/test_ms_parser.py @@ -57,5 +57,5 @@ def test_correct_mc_cul_tcm_97001(self, parser): assert len(results) > 0 # Optional: Check if it matched Protocol 0 - p0_match = any(r.protocol_id == '0' for r in results) + p0_match = any(r.protocol.get('id') == '0' for r in results) assert p0_match diff --git a/tests/test_mu_parser.py b/tests/test_mu_parser.py index b27bffd..069e449 100644 --- a/tests/test_mu_parser.py +++ b/tests/test_mu_parser.py @@ -48,7 +48,7 @@ def test_mu_parser_valid_messages(mu_parser, mock_protocols, line, expected_prot assert len(result) == 1 # Neue/geänderte Assertions - assert result[0].protocol_id == expected_protocol + assert result[0].protocol["id"] == expected_protocol assert result[0].data == expected_clean_payload assert result[0].raw == line From d5e3bb66439ab31f6fe855f44f807f6ce5f1f7d3 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Mon, 12 Jan 2026 21:20:57 +0000 Subject: [PATCH 4/5] fix: docs --- README.adoc | 2 +- .../decisions/ADR-006-json-output-schema.adoc | 59 +++++++++++++++---- .../ADR-007-data-and-raw-fields.adoc | 8 +-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/README.adoc b/README.adoc index 5baf577..4c79e6c 100644 --- a/README.adoc +++ b/README.adoc @@ -45,7 +45,7 @@ Die SIGNALDuino-Firmware (Microcontroller-Code) wird in einem separaten Reposito Decodierte Nachrichten werden als JSON-Objekte publiziert. Die Protokoll-Metadaten sind nun im Feld `protocol` enthalten, das `data`-Feld enthält die reinen, von der Protokoll-Preamble bereinigten Nutzdaten, und das `raw`-Feld enthält die unveränderte, rohe Nachrichtenzeichenkette der Firmware. include::./docs/architecture/snippets/json_output_schema_example.adoc[] - +==== == Demo diff --git a/docs/architecture/decisions/ADR-006-json-output-schema.adoc b/docs/architecture/decisions/ADR-006-json-output-schema.adoc index 4597e01..19b1eab 100644 --- a/docs/architecture/decisions/ADR-006-json-output-schema.adoc +++ b/docs/architecture/decisions/ADR-006-json-output-schema.adoc @@ -19,17 +19,54 @@ Die Umbenennung des Nutzdatenfeldes von `payload` zu `data` sowie die Einführun === Details zur neuen Struktur (Präzisiert durch ADR-007) -| Feld | Typ | Beschreibung | -|---|---|---| -| `data` | `str` | Die bereinigten Nutzdaten **ohne** Preamble und Postamble (ersetzt `payload`). | -| `raw` | `str` | Die ursprüngliche, unveränderte Nachricht vom Signalduino (z.B. `MU;...`). | -| `protocol` | `dict` | Strukturierte Metadaten des Protokolls. | -| `protocol.id` | `str` | Die ID des Protokolls (Redundanz zur einfachen Konsumierbarkeit). | -| `protocol.name` | `str` | Der menschenlesbare Name des Protokolls (aus `protocols.json`). | -| `protocol.preamble` | `str` | Die Preamble des Protokolls (z.B. `W125#`). | -| `protocol.format` | `str` | Das Format des Signals (z.B. `manchester`, `twostate`, `2-FSK`) (aus `protocols.json`). | -| `protocol.clock` | `int`/`float` | Der Clock-Wert, der für die Demodulation verwendet wurde (entweder `clockabs` oder `clockrange` Mittelwert/demodulierter Takt). | -| `protocol.modulation` | `str` | (Optional) Modulationsart (z.B. `2-FSK`, `GFSK`) für FSK-Protokolle. | +[cols="1,1,4"] +|=== +| Feld | Typ | Beschreibung + +| `data` +| `str` +| Die bereinigten Nutzdaten (meist Hex-String) **ohne** Preamble/Postamble. Repräsentiert die protokollspezifischen Daten (ersetzt `payload`). + +| `raw` +| `str` +| Die ursprüngliche, unveränderte Nachricht vom Signalduino (z.B. `MU;...`). Dient der vollständigen Nachvollziehbarkeit. + +| `protocol` +| `object` +| Container für strukturierte Metadaten der erfolgreichen Protokolldemodulation. + +| `protocol.id` +| `str` +| Die interne ID des Protokolls (z.B. `125`). + +| `protocol.name` +| `str` +| Der menschenlesbare Name des Protokolls (aus `protocols.json`). + +| `protocol.preamble` +| `str` +| Die Preamble (z.B. `W125#`) des erkannten Protokolls. + +| `protocol.encoding` +| `str` +| Das Codierungsformat des Signals (z.B. `manchester`, `twostate`, `pulse`). + +| `protocol.clock` +| `float` +| Der Takt-Wert in Mikrosekunden (`us`), der für die Demodulation verwendet wurde. + +| `protocol.modulation` +| `str` +| (Optional) Modulationsart (z.B. `2-FSK`, `GFSK`) für FSK-Protokolle. + +| `protocol.bitlength` +| `int` +| (Optional) Die tatsächliche Bitlänge der `data`-Nutzdaten, falls vom Protokoll bekannt/erzwungen. Unterstützt z.B. die Grothe-Constraint-Prüfung. + +| `protocol.repeats` +| `int` +| (Optional) Die Anzahl der erkannten Wiederholungen dieses Pakets, relevant für Duplikaterkennung. +|=== === Beispiel für die Datenstruktur (Präzisiert durch ADR-007) diff --git a/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc index 7823799..8d64201 100644 --- a/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc +++ b/docs/architecture/decisions/ADR-007-data-and-raw-fields.adoc @@ -11,18 +11,18 @@ Im Zuge der Definition des neuen JSON-Output-Schemas (siehe ADR-006) wurde Feedback gesammelt, das zwei wesentliche Verbesserungen vorschlägt: -1. **Ambiguität des Feldes `payload`:** Der Begriff `payload` wird in MQTT- und Messaging-Kontexten häufig für den gesamten Nachrichteninhalt verwendet. Die Verwendung von `payload` für die spezifischen, decodierten Hex-Daten kann daher zu Verwirrung führen. -2. **Bedarf an Rohdaten:** Für Debugging-Zwecke und fortgeschrittene Analysen ist es notwendig, Zugriff auf die ursprüngliche, unveränderte Nachricht zu haben, wie sie vom Signalduino-Gerät empfangen wurde (z.B. der komplette `MU;...`- oder `MC;...`-String), bevor irgendeine Verarbeitung oder Parsing stattgefunden hat. +. **Ambiguität des Feldes `payload`:** Der Begriff `payload` wird in MQTT- und Messaging-Kontexten häufig für den gesamten Nachrichteninhalt verwendet. Die Verwendung von `payload` für die spezifischen, decodierten Hex-Daten kann daher zu Verwirrung führen. +. **Bedarf an Rohdaten:** Für Debugging-Zwecke und fortgeschrittene Analysen ist es notwendig, Zugriff auf die ursprüngliche, unveränderte Nachricht zu haben, wie sie vom Signalduino-Gerät empfangen wurde (z.B. der komplette `MU;...`- oder `MC;...`-String), bevor irgendeine Verarbeitung oder Parsing stattgefunden hat. [#adr-decision] == Entscheidung Wir passen das in ADR-006 definierte Schema wie folgt an: -1. **Umbenennung `payload` zu `data`:** +. **Umbenennung `payload` zu `data`:** Das Feld, das bisher `payload` hieß und die bereinigten Hex- oder Binärdaten (ohne Preamble) enthielt, wird in **`data`** umbenannt. -2. **Einführung des Feldes `raw`:** +. **Einführung des Feldes `raw`:** Es wird ein neues Feld **`raw`** auf der obersten Ebene des JSON-Objekts eingeführt. Dieses Feld enthält den ursprünglichen Nachrichten-String, der an den Parser übergeben wurde. === Aktualisierte Datenstruktur From 49dfe6e35126dca86aab0b2685731580cf9e388c Mon Sep 17 00:00:00 2001 From: sidey79 Date: Tue, 13 Jan 2026 21:56:31 +0000 Subject: [PATCH 5/5] feat: Enhance MQTT response parsing and logging - Updated the response parser to support additional delimiters (`;` and `=`) for better command matching. - Implemented strict prefix matching to prevent incorrect command matches. - Introduced a new logging mechanism to clean payloads by removing preambles before logging. - Added functionality to skip publishing messages with empty or invalid data. - Ensured JSON output for MQTT messages is compact to maintain compatibility with brokers. - Added tests to verify the new parsing logic and ensure robustness against invalid payloads. - Refactored MQTT publisher to handle raw lines and improved error handling for empty payloads. --- .../ADR-004-mqtt-response-parsing.adoc | 6 +- .../decisions/ADR-006-json-output-schema.adoc | 3 + main.py | 27 ++- signalduino/constants.py | 4 + signalduino/controller.py | 99 ++++++++-- signalduino/mqtt.py | 81 +++++++-- signalduino/parser/base.py | 3 +- signalduino/parser/mc.py | 11 +- signalduino/types.py | 5 + tests/test_controller.py | 48 ++++- tests/test_controller_json_publish.py | 91 +++++++++ tests/test_mqtt.py | 172 +++++++++++++++++- tests/test_mqtt_commands.py | 4 - tests/test_response_matching.py | 106 +++++++++++ tests/test_stx_etx_handling.py | 86 +++++++++ 15 files changed, 700 insertions(+), 46 deletions(-) create mode 100644 tests/test_controller_json_publish.py create mode 100644 tests/test_response_matching.py create mode 100644 tests/test_stx_etx_handling.py diff --git a/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc b/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc index 3cb23b3..e0e723b 100644 --- a/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc +++ b/docs/architecture/decisions/ADR-004-mqtt-response-parsing.adoc @@ -24,7 +24,11 @@ Dies stellt sicher, dass alle erfolgreichen `GET` Anfragen über MQTT eine struk === Detaillierte Logik-Anpassungen -1. **`get_config` (CG):** +1. **Erweitertes Parsing (Update 2026-01-13):** + * Der Response-Parser unterstützt nun `;` und `=` als Trennzeichen zusätzlich zu Leerzeichen (z.B. für `SR;R=...`). + * Es wird ein striktes Prefix-Matching erzwungen, um zu verhindern, dass Befehls-Prefixe längere Nachrichtentypen matchen (z.B. darf Befehl `M` nicht `MN;...` matchen). + +2. **`get_config` (CG):** * Wird eine private Hilfsfunktion `_parse_decoder_config(response: str) -> Dict[str, int]` in [`signalduino/commands.py`](signalduino/commands.py) implementiert. * Diese Funktion parst den `key=value;` String in ein Dictionary (z.B. `{'MS': 1, 'MU': 1, 'MC': 1, 'Mred': 1}`). * Der Rückgabetyp von `get_config` wird von `str` auf `Dict[str, int]` geändert. diff --git a/docs/architecture/decisions/ADR-006-json-output-schema.adoc b/docs/architecture/decisions/ADR-006-json-output-schema.adoc index 19b1eab..92b5ca6 100644 --- a/docs/architecture/decisions/ADR-006-json-output-schema.adoc +++ b/docs/architecture/decisions/ADR-006-json-output-schema.adoc @@ -17,6 +17,9 @@ Wir werden das JSON-Output-Schema von `DecodedMessage` wie folgt anpassen: Die Umbenennung des Nutzdatenfeldes von `payload` zu `data` sowie die Einführung des Feldes `raw` (für die ursprüngliche Nachricht) sind in link:ADR-007-data-and-raw-fields.adoc[ADR-007] dokumentiert, das dieses Schema ergänzt und präzisiert. Dieses ADR dient als Grundlage für die Einführung des `protocol`-Feldes und die Bereinigung des Nutzdateninhalts. +=== Serialisierungsformat +Der JSON-Output für MQTT-Nutzdaten muss **kompakt** (ohne Zeilenumbrüche und Einrückungen) serialisiert werden, um die Kompatibilität mit MQTT-Brokern und Downstream-Systemen zu gewährleisten, die multiline-Nutzdaten falsch interpretieren könnten. Dies wird durch das Weglassen des `indent`-Parameters beim `json.dumps`-Aufruf in `MqttPublisher` sichergestellt. + === Details zur neuen Struktur (Präzisiert durch ADR-007) [cols="1,1,4"] diff --git a/main.py b/main.py index a58a891..cfaf3ae 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,13 @@ from signalduino.transport import SerialTransport, TCPTransport from signalduino.types import DecodedMessage, RawFrame # NEU: RawFrame +# NEU: Importiere Protokoll-Handler für Log-Bereinigung +try: + from sd_protocols.loader import _protocol_handler +except ImportError: + _protocol_handler = None + + # Konfiguration des Loggings def initialize_logging(log_level_str: str): """Initialisiert das Logging basierend auf dem übergebenen String.""" @@ -39,10 +46,28 @@ def initialize_logging(log_level_str: str): async def message_callback(message: DecodedMessage): """Callback-Funktion, die aufgerufen wird, wenn eine Nachricht dekodiert wurde.""" model = message.metadata.get("model", "Unknown") + + # NEU: Bereinige die Payload für die Log-Ausgabe, da der Parser die Preamble möglicherweise nicht entfernt hat + # (oder der Preamble-Eintrag im Protokoll-Handler fehlt). + log_payload = message.data + preamble = "" + + if _protocol_handler and message.protocol.get('id'): + try: + protocol_id = message.protocol['id'] + # Abrufen der Preamble, falls vorhanden + preamble = _protocol_handler.check_property(protocol_id, 'preamble', '') + except Exception: + logger.debug("Konnte Preamble nicht abrufen für Protokoll %s", message.protocol.get('id')) + + if preamble and log_payload.upper().startswith(preamble.upper()): + # Entferne die Preamble aus der Payload für das Logging + log_payload = log_payload[len(preamble):] + logger.info( f"Decoded message received: protocol={message.protocol_id}, " f"model={model}, " - f"payload={message.payload}" + f"payload={log_payload}" ) logger.debug(f"Full Metadata: {message.metadata}") # NEU: Überprüfe, ob RawFrame vorhanden ist und das Attribut 'line' hat diff --git a/signalduino/constants.py b/signalduino/constants.py index d038501..b520160 100644 --- a/signalduino/constants.py +++ b/signalduino/constants.py @@ -17,3 +17,7 @@ SDUINO_MC_DISPATCH_LOG_ID = "12.1" SDUINO_PARSE_DEFAULT_LENGHT_MIN = 8 SDUINO_GET_CONFIGQUERY_DELAY = 0.75 + +# Protocol Framing +ASCII_STX = "\x02" +ASCII_ETX = "\x03" diff --git a/signalduino/controller.py b/signalduino/controller.py index 552a7a2..f354985 100644 --- a/signalduino/controller.py +++ b/signalduino/controller.py @@ -5,6 +5,7 @@ import logging import asyncio from datetime import datetime, timedelta, timezone +from dataclasses import asdict from typing import Any, Awaitable, Callable, List, Optional, Dict, Tuple, Pattern from .commands import SignalduinoCommands @@ -250,13 +251,42 @@ async def _parser_task(self) -> None: # Führe die rechenintensive Parsing-Logik in einem separaten Thread aus. # Dadurch wird die asyncio-Event-Schleife nicht blockiert. decoded = await asyncio.to_thread(self.parser.parse_line, line) - if decoded and self.message_callback: - await self.message_callback(decoded[0]) - if self.mqtt_publisher and decoded: - # Verwende die neue MqttPublisher.publish(message: DecodedMessage) Signatur - await self.mqtt_publisher.publish(decoded[0]) - await self._handle_as_command_response(line) - + if decoded: + for message in decoded: + if isinstance(message, DecodedMessage): + # Überspringe die Veröffentlichung, wenn DecodedMessage keine Daten enthält. + # Das Feld 'data' kann leer sein, wenn die Decodierung fehlschlägt (z.B. Checksumme) + # oder ungültige Werte wie '[]' (leere JSON-Liste als String) enthält, + # was auf eine nicht parsbare Nachricht hinweist, die aber als DecodedMessage + # zurückgegeben wurde. + if not message.data or message.data.strip() == "[]": + self.logger.info("Skipping decoded message with empty/invalid data for protocol %s: %s", + message.protocol.get('id', 'N/A'), message) + continue + + if self.message_callback: + try: + await self.message_callback(message) + except Exception as exc: + self.logger.error("Error in message callback: %s", exc) + + if self.mqtt_publisher: + try: + # message is a DecodedMessage dataclass, pass directly to publish + # The MqttPublisher handles serialization. + await self.mqtt_publisher.publish(message) + except Exception as exc: + self.logger.error("Error publishing message to MQTT: %s", exc) + matched_cmd = False + if not decoded: + # Nur die Zeile als Befehlsantwort verarbeiten, wenn sie NICHT erfolgreich als Sensordaten geparst wurde. + matched_cmd = await self._handle_as_command_response(line) + + # If line was not parsed as a decoded message and was not a command response, + # publish it as a raw line via MQTT (Problem 2). + if not decoded and not matched_cmd and self.mqtt_publisher and line.strip(): + await self.mqtt_publisher.publish_raw_line(line) + # Ensure a minimal yield time for other tasks when the queue is rapidly processed. await asyncio.sleep(0.01) except Exception as e: @@ -357,34 +387,69 @@ async def _send_and_wait(self, command: str, timeout: float, response_pattern: O raise SignalduinoConnectionError(str(e)) raise - async def _handle_as_command_response(self, line: str) -> None: - """Check if the received line matches any pending command response.""" + async def _handle_as_command_response(self, line: str) -> bool: + """Check if the received line matches any pending command response. + + Returns: + True if a response was matched and processed, False otherwise. + """ self.logger.debug("Hardware response received: %s", line) + matched = False + async with self._pending_responses_lock: self.logger.debug(f"Current pending responses: {len(self._pending_responses)}") - for pending in self._pending_responses: + + # Use a copy of the list for safe iteration/removal if performance allows. + # Since the number of pending responses is usually small, this is safe. + for pending in list(self._pending_responses): try: - self.logger.debug(f"Checking pending response for command: {pending.command.payload}. Line: {line.strip()}") + cmd_payload = pending.command.payload + self.logger.debug(f"Checking pending response for command: {cmd_payload}. Line: {line.strip()}") pattern = pending.command.response_pattern if pattern: self.logger.debug(f"Testing pattern: {pattern.pattern}") if pattern.match(line): - self.logger.debug(f"Matched response pattern for command: {pending.command.payload}") + self.logger.debug(f"Matched response pattern for command: {cmd_payload}") pending.future.set_result(line) self._pending_responses.remove(pending) - return + matched = True + break # Exit for-loop - self.logger.debug(f"Testing direct match for: {pending.command.payload}") - if line.startswith(pending.command.payload): - self.logger.debug(f"Matched direct response for command: {pending.command.payload}") + self.logger.debug(f"Testing direct match for: {cmd_payload}") + + # Robust check for command match: + # 1. Startswith command payload (e.g., 'V', 'SR') + # 2. Response must be either exact match, or followed by a valid separator + # Separators: space (V command), ';' (SR command), '=' (Config) + # This ensures "V" matches "V 4.0.0", "SR" matches "SR;R=1", but "M" does NOT match "MN..." + if line.startswith(cmd_payload): + if len(line) == len(cmd_payload): + is_direct_match = True + else: + separator = line[len(cmd_payload)] + is_direct_match = separator.isspace() or separator in (';', '=') + else: + is_direct_match = False + + if is_direct_match: + self.logger.debug(f"Matched direct response for command: {cmd_payload}") pending.future.set_result(line) self._pending_responses.remove(pending) - return + matched = True + break # Exit for-loop + except Exception as e: self.logger.error(f"Error processing pending response: {e}") + # Remove the potentially failing pending response to prevent further errors + if pending in self._pending_responses: + self._pending_responses.remove(pending) continue + + if not matched: self.logger.debug("No matching pending response found") + + return matched async def _init_task_start_loop(self) -> None: """Main initialization task that handles version check and XQ command.""" diff --git a/signalduino/mqtt.py b/signalduino/mqtt.py index 9090246..9ed4e29 100644 --- a/signalduino/mqtt.py +++ b/signalduino/mqtt.py @@ -1,8 +1,8 @@ import json import logging import os -from dataclasses import asdict -from typing import Optional, Any, Callable, Awaitable # NEU: Awaitable für async callbacks +from dataclasses import asdict, is_dataclass +from typing import Optional, Any, Callable, Awaitable, Union # NEU: Awaitable für async callbacks from .commands import MqttCommandDispatcher, CommandValidationError, SignalduinoCommandTimeout # NEU: Import Dispatcher import aiomqtt as mqtt @@ -231,8 +231,28 @@ async def _handle_command(self, command_name: str, payload: str) -> None: ) - def _message_to_json(self, message: DecodedMessage) -> str: - """Serializes a DecodedMessage to a JSON string.""" + def _message_to_json(self, message: Union[DecodedMessage, Any]) -> Optional[str]: + """Serializes a DecodedMessage or other payload to a JSON string.""" + + # Check if message is a dataclass instance + if not is_dataclass(message): + # If not a dataclass, try to serialize it directly or wrap it + if isinstance(message, dict): + return json.dumps(message) + elif isinstance(message, str): + try: + # Check if it's already valid JSON + json.loads(message) + return message + except json.JSONDecodeError: + # Wrap string in a simple object + return json.dumps({"data": message}) + else: + # Fallback for other types + try: + return json.dumps(message) + except (TypeError, ValueError): + return json.dumps({"data": str(message)}) # DecodedMessage uses dataclasses, but RawFrame inside it also uses a dataclass. # We need a custom serializer to handle nested dataclasses like RawFrame. @@ -249,11 +269,13 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: # Note: 'raw' is now a string (ADR-007) and should be published. # The pop operation (line 249 in original) is removed to include it. - # Append preamble to data for FHEM compatibility (PreambleProtocolID#HexData) preamble = "" if self._protocol_handler: try: - protocol_id = message.protocol.get('id') + # Use .get only if message has a protocol attribute (it should if it's DecodedMessage) + protocol = getattr(message, 'protocol', {}) + protocol_id = protocol.get('id') if isinstance(protocol, dict) else None + if protocol_id: # check_property returns the value or default preamble = self._protocol_handler.check_property(protocol_id, 'preamble', '') @@ -264,9 +286,32 @@ def _raw_frame_to_dict(raw_frame: RawFrame) -> dict: message_dict["preamble"] = preamble # Ensure data (formerly payload) is uppercase - message_dict["data"] = message.data.upper() + # Use getattr to be safe even if dataclass structure changed + message_data = getattr(message, 'data', '') + if isinstance(message_data, str): + message_data = message_data.upper() + else: + message_data = str(message_data).upper() + + # REMOVE PREAMBLE FROM DATA FIELD IF PRESENT + # This ensures the 'data' field only contains the payload, consistent with ADR-007. + if preamble and message_data.startswith(preamble.upper()): + message_data = message_data[len(preamble):] + + # NEU: Fehlerbehebung für ungültige Parser-Rückgaben (dmsg=[]) + # Wenn der Parser eine leere Liste als String-Literal zurückgibt, wird dies als ungültiger + # Datenwert interpretiert. Setze auf leeren String. + if message_data.strip() == "[]": + message_data = "" + self.logger.warning("Invalid data '[]' received from parser, setting to empty string.") - return json.dumps(message_dict, indent=4) + # Check for empty payload to prevent sending empty messages + if not message_data: + return None + + message_dict["data"] = message_data + + return json.dumps(message_dict) async def publish_simple(self, subtopic: str, payload: str, retain: bool = False) -> None: """Publishes a simple string payload to a subtopic of the main topic.""" @@ -276,11 +321,19 @@ async def publish_simple(self, subtopic: str, payload: str, retain: bool = False try: topic = f"{self.base_topic}/{subtopic}" - await self.client.publish(topic, payload, retain=retain) + await self.client.publish(topic, payload.encode("utf-8"), retain=retain) self.logger.debug("Published simple message to %s: %s", topic, payload) except Exception: self.logger.error("Failed to publish simple message to %s", subtopic, exc_info=True) + async def publish_raw_line(self, line: str) -> None: + """Publishes a raw, non-decoded line from the transport (e.g., non-command status messages).""" + await self.publish_simple( + subtopic="state/raw_lines", + payload=json.dumps({"line": line.strip()}), + retain=False + ) + async def publish(self, message: DecodedMessage) -> None: """Publishes a DecodedMessage.""" if not self.client: @@ -290,8 +343,14 @@ async def publish(self, message: DecodedMessage) -> None: try: topic = f"{self.base_topic}/state/messages" payload = self._message_to_json(message) - await self.client.publish(topic, payload) + + if payload is None: + protocol_id = message.protocol.get('id', 'N/A') + self.logger.debug("Skipping MQTT publish due to empty payload for protocol %s", protocol_id) + return + + await self.client.publish(topic, payload.encode("utf-8")) protocol_id = message.protocol.get('id', 'N/A') - self.logger.debug("Published message for protocol %s to %s", protocol_id, topic) + self.logger.debug("Published message for protocol %s to %s (Payload length: %s)", protocol_id, topic, len(payload)) except Exception: self.logger.error("Failed to publish message", exc_info=True) diff --git a/signalduino/parser/base.py b/signalduino/parser/base.py index 5225f32..60c26ca 100644 --- a/signalduino/parser/base.py +++ b/signalduino/parser/base.py @@ -6,8 +6,9 @@ from typing import Optional, List, Tuple from ..exceptions import SignalduinoParserError +from ..constants import ASCII_STX, ASCII_ETX -_STX_ETX = re.compile(r"^\x02(M[sSuUcCNOo];.*;)\x03$") +_STX_ETX = re.compile(f"^{ASCII_STX}(M[sSuUcCNOo];.*;){ASCII_ETX}$") def decompress_payload(compressed_payload: str) -> str: diff --git a/signalduino/parser/mc.py b/signalduino/parser/mc.py index 6cf4643..08b66b3 100644 --- a/signalduino/parser/mc.py +++ b/signalduino/parser/mc.py @@ -100,9 +100,14 @@ def parse(self, frame: RawFrame) -> Iterable[DecodedMessage]: "preamble": protocol_data.get("preamble", ""), } - # 1. Entferne die Preamble aus der Payload - preamble_len = len(protocol_meta["preamble"]) - payload = raw_payload[preamble_len:] + # 1. Entferne die Preamble aus der Payload, falls vorhanden. + # Normalisiere Preamble und Payload zur korrekten Erkennung der Groß-/Kleinschreibung. + preamble = protocol_meta["preamble"] + if preamble and raw_payload.upper().startswith(preamble.upper()): + preamble_len = len(preamble) + payload = raw_payload[preamble_len:] + else: + payload = raw_payload yield DecodedMessage( data=payload, diff --git a/signalduino/types.py b/signalduino/types.py index e96c90d..f5246eb 100644 --- a/signalduino/types.py +++ b/signalduino/types.py @@ -30,6 +30,11 @@ class DecodedMessage: metadata: dict = field(default_factory=dict) protocol: dict = field(default_factory=dict) + @property + def protocol_id(self) -> Optional[str]: + """Provides backward compatibility for message.protocol_id.""" + return self.protocol.get('id') + @dataclass(slots=True) class QueuedCommand: diff --git a/tests/test_controller.py b/tests/test_controller.py index 6dee4af..d453a2b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -77,6 +77,7 @@ def autopatch_mqtt_publisher(): mock_instance.__aenter__ = AsyncMock(return_value=mock_instance) mock_instance.__aexit__ = AsyncMock(return_value=None) mock_instance.publish = AsyncMock() + mock_instance.publish_raw_line = AsyncMock() # NEU: Für RAW Line Publishing # Erstelle ein Mock für die Klasse, das die Mock-Instanz zurückgibt MqttPublisherClassMock = MagicMock(return_value=mock_instance) @@ -273,4 +274,49 @@ async def test_stx_message_bypasses_command_response(mock_transport, mock_parser # The STX message is stripped and passed to the parser mock_parser.parse_line.assert_any_call(stx_msg) # The command response is also passed to the parser - mock_parser.parse_line.assert_any_call(response) \ No newline at end of file + mock_parser.parse_line.assert_any_call(response) + + +@pytest.mark.asyncio +async def test_skip_message_with_empty_or_invalid_payload(mock_transport, mock_parser, mock_controller_initialize, autopatch_mqtt_publisher): + """Test that messages with empty data or data='[]' are skipped for callback and MQTT publication.""" + + # Der Parser gibt drei Nachrichten zurück: eine gültige, eine mit leerer Daten, eine mit '[]' + valid_msg = DecodedMessage(data="0F0F0F", raw="", protocol={"id": "1"}) + empty_data_msg = DecodedMessage(data="", raw="", protocol={"id": "2"}) + invalid_data_msg = DecodedMessage(data="[]", raw="", protocol={"id": "131"}) # Simuliert das vom Benutzer gemeldete Problem + + # Der Parser gibt alle drei DecodedMessage-Objekte zurück + mock_parser.parse_line.return_value = [valid_msg, empty_data_msg, invalid_data_msg] + + callback_mock = AsyncMock() + + # Verwende side_effect, um die rohe Zeile einmal zurückzugeben, dann None + mock_transport.readline.side_effect = ["MS;P0=1;D=...;\n", None] + + controller = SignalduinoController( + transport=mock_transport, + parser=mock_parser, + message_callback=callback_mock, + # Der MqttPublisher wird durch autopatch_mqtt_publisher bereitgestellt + mqtt_publisher=autopatch_mqtt_publisher.return_value + ) + + async with controller: + # Starte Controller-Tasks, um die Verarbeitung der Raw-Line-Queue zu ermöglichen + # Tasks werden durch mock_controller_initialize gestartet + + # Warte kurz, um dem Reader Task Zeit zu geben, die Raw-Line aus dem Transport + # in die _raw_message_queue zu legen (die Raw-Line wird vom side_effect des Mocks geliefert). + await asyncio.sleep(0.1) + + # Warte, bis die Nachrichten im Parser-Task verarbeitet wurden + await asyncio.sleep(0.5) + + # Nur die gültige Nachricht sollte an den Callback und den Publisher gesendet werden + + # 1. Prüfe den Callback + callback_mock.assert_called_once_with(valid_msg) + + # 2. Prüfe den MQTT Publisher + autopatch_mqtt_publisher.return_value.publish.assert_called_once_with(valid_msg) \ No newline at end of file diff --git a/tests/test_controller_json_publish.py b/tests/test_controller_json_publish.py new file mode 100644 index 0000000..634abf3 --- /dev/null +++ b/tests/test_controller_json_publish.py @@ -0,0 +1,91 @@ +import json +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch +import pytest +from dataclasses import asdict + +from signalduino.controller import SignalduinoController +from signalduino.types import DecodedMessage + +@pytest.fixture +def mock_transport(): + transport = AsyncMock() + transport.readline = AsyncMock(return_value=None) + transport.closed.return_value = False + transport.open = AsyncMock() + transport.close = AsyncMock() + transport.__aenter__ = AsyncMock(return_value=transport) + transport.__aexit__ = AsyncMock(return_value=None) + return transport + +@pytest.fixture +def mock_parser(): + parser = MagicMock() + parser.parse_line.return_value = [] + return parser + +@pytest.fixture +def mock_mqtt_publisher(): + publisher = MagicMock() + publisher.publish = AsyncMock() + publisher.publish_raw_line = AsyncMock() + publisher.base_topic = "sduino" + publisher.__aenter__ = AsyncMock(return_value=publisher) + publisher.__aexit__ = AsyncMock(return_value=None) + return publisher + +@pytest.mark.asyncio +async def test_controller_publishes_object(mock_transport, mock_parser, mock_mqtt_publisher): + """ + Test that the controller passes the DecodedMessage object directly + to the MQTT publisher (which handles serialization). + """ + # Create a dummy DecodedMessage + decoded_msg = DecodedMessage( + data="TestPayload", + raw="raw_data", + protocol={"id": "1", "name": "TestProtocol"}, + metadata={"rssi": -74.5} + ) + + # Configure parser to return this message + mock_parser.parse_line.return_value = [decoded_msg] + + # Initialize controller with mocked components + controller = SignalduinoController( + transport=mock_transport, + parser=mock_parser, + mqtt_publisher=mock_mqtt_publisher + ) + + # Patch initialize to avoid full startup sequence, we just want to test _parser_task logic + with patch.object(controller, 'initialize', new=AsyncMock()): + # Manually put a line into the raw queue to trigger parsing + await controller._raw_message_queue.put("TestLine") + + # Start the parser task + parser_task = asyncio.create_task(controller._parser_task()) + + # Allow some time for the task to process + await asyncio.sleep(0.1) + + # Stop the task + controller._stop_event.set() + parser_task.cancel() + try: + await parser_task + except asyncio.CancelledError: + pass + + # Verify publish was called + mock_mqtt_publisher.publish.assert_called_once() + + # Get the argument passed to publish + args, _ = mock_mqtt_publisher.publish.call_args + published_msg = args[0] + + # Assert it is the DecodedMessage object (Controller should pass it directly to Publisher) + assert isinstance(published_msg, DecodedMessage), "Published message should be a DecodedMessage object" + + # Compare with expected object + assert published_msg == decoded_msg diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index bdf4714..4b7174b 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -74,7 +74,7 @@ def set_mqtt_env_vars(): del os.environ["MQTT_TOPIC"] del os.environ["MQTT_USERNAME"] del os.environ["MQTT_PASSWORD"] - + # Der Test verwendet `patch` auf aiomqtt.Client, um die tatsächliche # Netzwerkimplementierung zu vermeiden. @patch("signalduino.mqtt.mqtt.Client") @@ -126,9 +126,9 @@ async def test_mqtt_publisher_publish_success(MockClient, mock_decoded_message, (call_topic, published_payload), call_kwargs = mock_client_instance.publish.call_args assert call_topic == expected_topic - assert isinstance(published_payload, str) + assert isinstance(published_payload, bytes) - payload_dict = json.loads(published_payload) + payload_dict = json.loads(published_payload.decode("utf-8")) assert payload_dict["protocol"]["id"] == "1" # Payload sollte KEINE Preamble mehr enthalten, aber das neue Feld "preamble" schon @@ -143,6 +143,46 @@ async def test_mqtt_publisher_publish_success(MockClient, mock_decoded_message, assert "Published message for protocol 1 to test/signalduino/v1/state/messages" in caplog.text +@patch("signalduino.mqtt.mqtt.Client") +@pytest.mark.asyncio +async def test_mqtt_publisher_strips_preamble_from_data(MockClient, mock_decoded_message, caplog, mock_controller): + """Testet, ob die Preamble aus dem 'data' Feld entfernt wird, wenn sie vorhanden ist.""" + caplog.set_level(logging.DEBUG) + + # Konfiguriere den MockClient-Kontextmanager-Rückgabewert, um das asynchrone await-Problem zu beheben + mock_client_instance = MockClient.return_value + mock_client_instance.publish = AsyncMock() + mock_client_instance.subscribe = AsyncMock() + + MockClient.return_value.__aenter__ = AsyncMock(return_value=None) + MockClient.return_value.__aexit__ = AsyncMock(return_value=None) + + # 1. Ändere die DecodedMessage, um die Preamble im data-Feld zu simulieren + # Preamble für Protokoll 1 ist 'P1#' + mock_decoded_message.data = "P1#9374A400" + + publisher = MqttPublisher(mock_controller) + + async with publisher: + await publisher.publish(mock_decoded_message) + + # Überprüfe den publish-Aufruf + expected_topic = f"{publisher.base_topic}/state/messages" + + mock_client_instance.publish.assert_called_once() + + # Überprüfe Topic und Payload des Aufrufs + (call_topic, published_payload), _ = mock_client_instance.publish.call_args + + assert call_topic == expected_topic + + payload_dict = json.loads(published_payload.decode("utf-8")) + + # 2. Assert, dass die Preamble aus 'data' entfernt wurde + assert payload_dict["data"] == "9374A400" + assert payload_dict["preamble"] == "P1#" + + @patch("signalduino.mqtt.mqtt.Client") @pytest.mark.asyncio async def test_mqtt_publisher_publish_simple(MockClient, caplog, mock_controller): @@ -170,7 +210,7 @@ async def test_mqtt_publisher_publish_simple(MockClient, caplog, mock_controller (call_topic, call_payload), call_kwargs = mock_client_instance.publish.call_args assert call_topic == expected_topic - assert call_payload == "online" + assert call_payload == "online".encode("utf-8") assert call_kwargs['retain'] is True assert 'qos' not in call_kwargs # qos sollte nicht übergeben werden, um KeyError zu vermeiden @@ -264,7 +304,7 @@ async def mock_messages_generator(): mock_msg.topic.__str__.return_value = "test/signalduino/v1/commands/get/cc1101/frequency" # Sende Payload mit req_id, um Konsistenz zu gewährleisten mock_msg.payload = b'{"req_id": "test_req_001"}' - + yield mock_msg # Generator endet hier @@ -323,7 +363,7 @@ async def mock_messages_generator(): mock_msg.topic.__str__.return_value = "test/signalduino/v1/commands/get/cc1101/frequency" # Sende Payload mit req_id, um Validierung zu gewährleisten mock_msg.payload = b'{"req_id": "test_req_empty"}' - + yield mock_msg # Generator endet hier @@ -479,4 +519,122 @@ async def test_controller_parser_loop_publishes_message( # Überprüfe, ob der Publisher für die DecodedMessage aufgerufen wurde # Der Publish-Aufruf ist jetzt auch async - mock_publisher_instance.publish.assert_called_once_with(mock_decoded_message) \ No newline at end of file + # Der Controller sendet jetzt das DecodedMessage-Objekt (keinen JSON-String mehr) + mock_publisher_instance.publish.assert_called_once() + args, _ = mock_publisher_instance.publish.call_args + published_payload = args[0] + assert isinstance(published_payload, DecodedMessage) + # Verify it matches the original message + assert published_payload == mock_decoded_message + + +@patch("signalduino.controller.MqttPublisher") +@patch("signalduino.controller.SignalParser") +@patch.dict(os.environ, {"MQTT_HOST": "test-host"}, clear=True) +@pytest.mark.asyncio +async def test_controller_parser_loop_publishes_raw_line( + MockParser, MockMqttPublisher, caplog +): + """Stellt sicher, dass nicht-zugeordnete RAW Lines veröffentlicht werden (Problem 2).""" + caplog.set_level(logging.DEBUG) + + mock_parser_instance = MockParser.return_value + mock_publisher_instance = MockMqttPublisher.return_value + mock_publisher_instance.publish_raw_line = AsyncMock() + + # 1. Simuliere, dass der Parser nichts dekodiert (decoded is None/empty) + mock_parser_instance.parse_line.return_value = [] + + # 2. Wir brauchen einen MockTransport mit readline, damit initialize nicht blockiert + mock_transport = MockTransport() + + controller = SignalduinoController(transport=mock_transport, parser=mock_parser_instance) + + # Die Zeile, die keine Protokollnachricht und keine Command-Antwort ist + raw_test_line = "Dies ist eine unbekannte Statusmeldung\n" + + # Verhindere, dass _handle_as_command_response matched (ist die Standardeinstellung bei diesem Inhalt) + # und simuliere eine einfache Initialisierung + with patch.object(controller, 'initialize', new=AsyncMock()): + controller._main_tasks = [] + async with controller: + # Stelle sicher, dass keine Pending Responses existieren (Standard nach initialize Mock) + + # Starte den Parser-Task manuell + parser_task = asyncio.create_task(controller._parser_task()) + + # Fügen Sie die Nachricht manuell in die Queue ein + await controller._raw_message_queue.put(raw_test_line) + + # Geben Sie dem Parser-Task Zeit, die Nachricht zu verarbeiten + await asyncio.sleep(0.5) + + # Beende den Parser-Task sauber + controller._stop_event.set() + parser_task.cancel() + await asyncio.gather(parser_task, return_exceptions=True) + + # Überprüfe, ob der Publisher für die RAW Line aufgerufen wurde + # Der Payload sollte der JSON-String sein, der in publish_raw_line erstellt wird + mock_publisher_instance.publish_raw_line.assert_called_once_with(raw_test_line) + + # Überprüfe, dass publish für DecodedMessage NICHT aufgerufen wurde + mock_publisher_instance.publish.assert_not_called() + + + @patch("signalduino.mqtt.mqtt.Client") + @pytest.mark.asyncio + async def test_mqtt_publisher_skip_empty_payload(MockClient, mock_decoded_message, caplog, mock_controller): + """Testet, ob publish übersprungen wird, wenn der Payload leer ist.""" + caplog.set_level(logging.DEBUG) + + # Konfiguriere MockClient + mock_client_instance = MockClient.return_value + mock_client_instance.publish = AsyncMock() + mock_client_instance.subscribe = AsyncMock() + MockClient.return_value.__aenter__ = AsyncMock(return_value=None) + MockClient.return_value.__aexit__ = AsyncMock(return_value=None) + + # Simuliere leeren Payload (nach Preamble-Bereinigung) + # Wenn data nur aus der Preamble besteht (P1#), wird data zu "" + mock_decoded_message.data = "P1#" + + publisher = MqttPublisher(mock_controller) + + async with publisher: + await publisher.publish(mock_decoded_message) + + # Prüfe, dass publish NICHT aufgerufen wurde + mock_client_instance.publish.assert_not_called() + + # Prüfe Log-Nachricht + assert "Skipping MQTT publish due to empty payload for protocol 1" in caplog.text + + + @patch("signalduino.mqtt.mqtt.Client") + @pytest.mark.asyncio + async def test_mqtt_publisher_skip_empty_brackets_payload(MockClient, mock_decoded_message, caplog, mock_controller): + """Testet, ob publish übersprungen wird, wenn der Payload '[]' ist.""" + caplog.set_level(logging.DEBUG) + + # Konfiguriere MockClient + mock_client_instance = MockClient.return_value + mock_client_instance.publish = AsyncMock() + mock_client_instance.subscribe = AsyncMock() + MockClient.return_value.__aenter__ = AsyncMock(return_value=None) + MockClient.return_value.__aexit__ = AsyncMock(return_value=None) + + # Simuliere '[]' Payload + mock_decoded_message.data = "[]" + + publisher = MqttPublisher(mock_controller) + + async with publisher: + await publisher.publish(mock_decoded_message) + + # Prüfe, dass publish NICHT aufgerufen wurde + mock_client_instance.publish.assert_not_called() + + # Prüfe Log-Nachrichten + assert "Invalid data '[]' received from parser, setting to empty string." in caplog.text + assert "Skipping MQTT publish due to empty payload for protocol 1" in caplog.text \ No newline at end of file diff --git a/tests/test_mqtt_commands.py b/tests/test_mqtt_commands.py index 7accc23..16a5e00 100644 --- a/tests/test_mqtt_commands.py +++ b/tests/test_mqtt_commands.py @@ -497,9 +497,6 @@ async def test_controller_handles_set_factory_reset(signalduino_controller, mock ) - - - @pytest.mark.asyncio async def test_controller_handles_get_cc1101_settings(signalduino_controller, mock_aiomqtt_client_cls, mock_logger): """ @@ -607,4 +604,3 @@ async def test_controller_handles_get_cc1101_register(signalduino_controller, mo # 5. Verifiziere, dass die Commands-Methode mit dem korrekten Payload aufgerufen wurde expected_payload_dict = json.loads(mqtt_payload) read_reg_mock.assert_called_once_with(expected_payload_dict, timeout=SDUINO_CMD_TIMEOUT) - diff --git a/tests/test_response_matching.py b/tests/test_response_matching.py new file mode 100644 index 0000000..73b25d6 --- /dev/null +++ b/tests/test_response_matching.py @@ -0,0 +1,106 @@ +import asyncio +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from signalduino.controller import SignalduinoController, SignalduinoCommandTimeout +from signalduino.types import DecodedMessage + +# Mock transport setup (copied from test_controller.py since it's not exported) +@pytest.fixture +def mock_transport(): + from signalduino.transport import BaseTransport + transport = AsyncMock(spec=BaseTransport) + transport.closed.return_value = False + + async def a_readline_side_effect(*args, **kwargs): + await asyncio.sleep(0.001) + return None + transport.readline.side_effect = a_readline_side_effect + return transport + +@pytest.fixture +def mock_parser(): + parser = MagicMock() + parser.parse_line.return_value = [] + return parser + +@pytest.fixture +def mock_controller_init(monkeypatch): + async def mock_initialize(self, timeout=None): + self._main_tasks = [ + asyncio.create_task(self._reader_task(), name="sd-reader"), + asyncio.create_task(self._parser_task(), name="sd-parser"), + asyncio.create_task(self._writer_task(), name="sd-writer") + ] + self._init_complete_event.set() + monkeypatch.setattr(SignalduinoController, "initialize", mock_initialize) + +@pytest.mark.asyncio +async def test_response_matching_separator(mock_transport, mock_parser, mock_controller_init): + """ + Test that commands match responses separated by ';' or '=' and not just space. + Reproduction of issue where 'SR' does not match 'SR;R=1'. + """ + # Setup transport to return the response + response_line = "SR;R=1\n" + + # We use an iterator to yield the response once, then sleep + response_iter = iter([response_line]) + async def readline_side_effect(): + try: + return next(response_iter) + except StopIteration: + await asyncio.sleep(1) # Keep connection open + return None + + mock_transport.readline.side_effect = readline_side_effect + + controller = SignalduinoController(transport=mock_transport, parser=mock_parser) + async with controller: + # We expect this to SUCCEED if the fix is implemented. + # Currently it should TIMEOUT or fail if the logic is too strict. + try: + result = await controller.send_command("SR", expect_response=True, timeout=1.0) + assert result.strip() == response_line.strip() + except SignalduinoCommandTimeout: + pytest.fail("Command 'SR' timed out waiting for 'SR;R=1' response. Logic is too strict.") + +@pytest.mark.asyncio +async def test_response_matching_false_positive(mock_transport, mock_parser, mock_controller_init): + """ + Test that 'M' does NOT match 'MN;D=...'. + Ensures that relaxing the check doesn't introduce false positives. + """ + # Transport sends MN message first, then the actual response + mn_message = "MN;D=01010101;\n" + actual_response = "M OK\n" + + response_iter = iter([mn_message, actual_response]) + async def readline_side_effect(): + try: + await asyncio.sleep(0.01) + return next(response_iter) + except StopIteration: + await asyncio.sleep(1) + return None + + mock_transport.readline.side_effect = readline_side_effect + + controller = SignalduinoController(transport=mock_transport, parser=mock_parser) + async with controller: + # We send 'M' and expect 'M OK'. 'MN...' should be ignored. + result = await controller.send_command("M", expect_response=True, timeout=1.0) + assert result.strip() == actual_response.strip() + + # Verify that MN was NOT matched + assert result.strip() != mn_message.strip() + +@pytest.mark.asyncio +async def test_response_matching_exact(mock_transport, mock_parser, mock_controller_init): + """Test exact match works.""" + response_line = "XYZ\n" + mock_transport.readline.side_effect = [response_line, asyncio.sleep(1)] + + controller = SignalduinoController(transport=mock_transport, parser=mock_parser) + async with controller: + result = await controller.send_command("XYZ", expect_response=True, timeout=1.0) + assert result.strip() == response_line.strip() diff --git a/tests/test_stx_etx_handling.py b/tests/test_stx_etx_handling.py new file mode 100644 index 0000000..9773e69 --- /dev/null +++ b/tests/test_stx_etx_handling.py @@ -0,0 +1,86 @@ +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock +from signalduino.controller import SignalduinoController, SignalduinoCommandTimeout +from signalduino.transport import BaseTransport +from signalduino.constants import ASCII_STX, ASCII_ETX + +@pytest.fixture +def mock_transport(): + transport = AsyncMock(spec=BaseTransport) + transport.closed.return_value = False + transport.readline.side_effect = lambda: asyncio.sleep(0.001) or None + return transport + +@pytest.fixture +def mock_parser(): + parser = MagicMock() + parser.parse_line.return_value = [] + return parser + +@pytest.mark.asyncio +async def test_valid_framing_and_responses(mock_transport, mock_parser): + """ + Test that: + 1. Valid STX/ETX framed messages (e.g. MC) are passed to parser. + 2. Valid unframed command responses (e.g. V) are handled by controller. + """ + + # Valid framed message and valid command response + framed_msg = f"{ASCII_STX}MC;LL=-1006;LH=937;{ASCII_ETX}\n" + cmd_response = "V 3.3.1\n" + + # Iterator to return messages + response_iter = iter([framed_msg, cmd_response]) + + async def readline_side_effect(): + try: + val = next(response_iter) + # Give tasks a chance to run + await asyncio.sleep(0.01) + return val.strip() # Simulate transport stripping + except StopIteration: + await asyncio.sleep(0.1) + return None + + mock_transport.readline.side_effect = readline_side_effect + + controller = SignalduinoController(transport=mock_transport, parser=mock_parser) + # Mock initialize + controller.initialize = AsyncMock() + controller.initialize.side_effect = lambda *args, **kwargs: controller._init_complete_event.set() + + async with controller: + # Start both tasks + reader_task = asyncio.create_task(controller._reader_task(), name="reader") + parser_task = asyncio.create_task(controller._parser_task(), name="parser") + controller._main_tasks.extend([reader_task, parser_task]) + + # 1. Send command "V" and wait for "V 3.3.1" + # The framed_msg should be processed by parser task in background + try: + result = await controller.send_command("V", expect_response=True, timeout=2.0) + assert result is not None + assert result.strip() == "V 3.3.1" + + # 2. Verify framed message was parsed + # We need to wait a bit to ensure parser task processed the first message + await asyncio.sleep(0.1) + + # Check if parse_line was called with the framed message + # The parser receives the line from transport, which includes newline (unless stripped by transport) + # Transport.readline does .strip(). + # So expected line is framed_msg.strip() + expected_line = framed_msg.strip() + + found = False + for call in mock_parser.parse_line.call_args_list: + args, _ = call + if args[0] == expected_line: + found = True + break + assert found, f"Parser should have received: {expected_line}" + + finally: + reader_task.cancel() + parser_task.cancel()