From 68728d087e2437f937840c2873281b39a943d6ac Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 16 Sep 2026 19:11:44 +0200 Subject: [PATCH 1/5] docs(adr): decide on sensor value decoding layer (ADR-006) PySignalduino stops at the demodulated hex payload today, so only FHEM can turn a received frame into actual measurements. ADR-006 accepts option 4 of the FHEM integration proposal and lays out the second stage: * a dedicated signalduino/decoders/ layer between parser and output * SensorEvent plus one optional field on DecodedMessage, keeping every existing signature intact * data-driven JSON decoder specifications instead of one module per protocol, with a Python escape hatch for the irregular ones * three output adapters over one internal model: FHEM legacy string, rtl_433 JSON and Home Assistant discovery * topics that separate event from retained state, leaving the existing state/messages untouched Numbering note: 007 is already taken on feat/extendRAWmqtt, so this fills the open 006 slot. --- .../ADR-006-sensor-decoding-layer.adoc | 173 ++++++++++++++++++ .../proposals/fhem_mqtt_integration.adoc | 8 + 2 files changed, 181 insertions(+) create mode 100644 docs/architecture/decisions/ADR-006-sensor-decoding-layer.adoc diff --git a/docs/architecture/decisions/ADR-006-sensor-decoding-layer.adoc b/docs/architecture/decisions/ADR-006-sensor-decoding-layer.adoc new file mode 100644 index 0000000..1561584 --- /dev/null +++ b/docs/architecture/decisions/ADR-006-sensor-decoding-layer.adoc @@ -0,0 +1,173 @@ += ADR 006: Sensorwert-Dekodierschicht (Stufe 2) +:doctype: article :encoding: utf-8 :lang: de :status: Accepted :decided-at: 2026-09-16 :decided-by: Architecture Owner + +[NOTE] +==== +Die Nummer 007 ist auf dem Branch `feat/extendRAWmqtt` bereits für das MQTT-Raw-Kommando vergeben +und bleibt reserviert. Dieses ADR füllt bewusst die Lücke 006, damit die Nummerierung lückenlos +bleibt. +==== + +[#adr-context] +== Kontext + +PySignalduino deckt heute nur die erste Hälfte der Verarbeitungskette ab, die FHEM zweistufig löst: + +. *Stufe 1 -- Demodulation:* Pulse und Pausen werden zu einer Bitfolge und daraus zu einem + Hex-Payload. Das leistet PySignalduino vollständig über `signalduino/parser/` und `sd_protocols/`. +. *Stufe 2 -- Interpretation:* Aus dem Hex-Payload werden konkrete Messwerte -- Temperatur, + Luftfeuchte, Batteriezustand, Sensor-ID, Kanal. Diese Stufe fehlt vollständig; in FHEM liegt sie + in den Client-Modulen wie `14_SD_WS.pm`. + +Das Ergebnis der Verarbeitung ist heute `DecodedMessage(protocol_id, payload, raw, metadata)` aus +`signalduino/types.py`, wobei `payload` ein Hex-String ist und `metadata` lediglich `bit_length`, +`rssi` und `clock` enthält. Ein Feld für Messwerte existiert nicht. +`sd_protocols/postdemodulation.py` prüft zwar Prüfsummen und Parität, liefert aber weiterhin nur +bereinigte Bits -- das ist Validierung, keine Interpretation. + +Daraus folgt die praktische Einschränkung, die dieses ADR auflöst: PySignalduino ist faktisch nur +mit FHEM nutzbar, weil erst FHEM aus den Rohdaten Messwerte macht. Andere Konsumenten wie Home +Assistant, Node-RED oder ioBroker müssten die Dekodierlogik jeweils selbst nachbauen. + +Vorgearbeitet wurde bereits an drei Stellen: + +* `sd_protocols/protocols.json` enthält für 160 Protokolle die aus FHEM übernommenen Felder + `clientmodule`, `preamble`, `modulematch` und `postamble` -- die Datengrundlage für das Routing + einer zweiten Stufe ist also vorhanden. +* `sd_protocols/helpers.py` berechnet in `ConvLaCrosse()` bereits echte Messwerte, serialisiert sie + aber sofort wieder in einen FHEM-Legacy-String, sodass sie verloren gehen. +* `docs/architecture/proposals/fhem_mqtt_integration.adoc` beschreibt dieses Vorhaben als + "Option 4" und bewertet es als Gold-Standard mit sehr hohem Aufwand, ohne es zu entscheiden. + +[#adr-decision] +== Entscheidung + +Wir führen eine eigene Dekodierschicht `signalduino/decoders/` ein, die zwischen Parser und Ausgabe +sitzt, sowie eine Adapterschicht `signalduino/output/`: + +---- +transport -> parser (Stufe 1: Bits/Hex) -> decoders (Stufe 2: Werte) -> output (Adapter) -> mqtt +---- + +*Ort der Schicht.* Die Dekodierung erfolgt weder in `sd_protocols/` noch im MQTT-Publisher. +`sd_protocols/` ist die mechanische Portierung der Perl-Quellen und wird über `tools/convert.pl` +regeneriert; dort abgelegter Code ginge beim nächsten Lauf verloren oder erzwänge Konfliktlösung. +Der MQTT-Publisher scheidet aus, weil die Messwerte auch für `message_callback` und künftige +Nicht-MQTT-Senken verfügbar sein müssen. Aufgerufen wird die Schicht in `signalduino/controller.py` +innerhalb desselben `asyncio.to_thread`-Aufrufs wie der Parser, da es sich um CPU-gebundene +Bitarithmetik handelt. + +*Datenmodell.* Eine neue Dataclass `SensorEvent` nimmt Modell, Sensortyp, Geräte-ID, Kanal, die +Messwerte und deren Einheiten auf. `DecodedMessage` erhält genau ein zusätzliches, optionales Feld +`sensor`. Damit bleiben alle bestehenden Signaturen und der bestehende Nachrichtenfluss unverändert. +Die Schlüssel in `SensorEvent.values` sind die FHEM-Readingnamen (`temperature`, `humidity`, +`batteryState`, `channel`, ...). Diese Namen sind für rund 150 Protokolle ein De-facto-Standard; +sie als kanonische Schlüssel zu verwenden macht den Paritätstest gegen die FHEM-Testdaten zu einem +direkten Vergleich und die Mapping-Tabellen der Ausgabeadapter wiederverwendbar. + +*Skalierung.* Die Dekodiervorschriften werden als validierte JSON-Spezifikationen abgelegt und von +einem gemeinsamen Evaluator ausgewertet, statt pro Protokoll ein Python-Modul zu schreiben. Das +Muster der FHEM-Client-Module ist überwiegend mechanisch -- Bitbereich, Skalierung, Offset, +Vorzeichen, Wertetabelle -- und damit deklarativ abbildbar. JSON und nicht YAML, weil `jsonschema` +bereits Abhängigkeit des Projekts ist. Für Protokolle, die sich nicht deklarativ beschreiben lassen, +existiert eine Registrierung von Python-Dekodern als Ausweg. + +*Routing.* Primärschlüssel ist die `protocol_id`, die in `DecodedMessage` bereits vorliegt. FHEM +muss den Umweg über Präambel-Strings gehen, weil seine `Dispatch()`-Funktion nur einen String kennt; +in Python wäre das ein unnötiger Informationsverlust. Die Felder aus `protocols.json` werden +weiterhin genutzt: `preamble` zum Abtrennen des Hex-Teils und zum Aufbau des Legacy-Strings, +`modulematch` als Vorabprüfung, `clientmodule` zur Gruppierung. + +*Robustheit.* Stufe 2 darf Stufe 1 niemals beeinträchtigen. Der Dekodieraufruf ist vollständig +gekapselt; schlägt er fehl, bleibt `sensor` leer und die bisherige Ausgabe unverändert. Für die +überwiegende Mehrheit der Protokolle ist genau das zunächst der Normalfall. + +*Ausgabeformate.* Drei Adapter rendern dasselbe interne Modell in unterschiedliche Zielformate: +FHEM-Legacy-String, rtl_433-kompatibles JSON und Home-Assistant-MQTT-Discovery. Die Adapter sind +reine Funktionen ohne MQTT-Kenntnis und einzeln über Umgebungsvariablen zuschaltbar. + +*Topic-Struktur.* Die Topics trennen Ereignis von Zustand auf oberster Ebene: + +[cols="2,3,1", options="header"] +|=== +| Zweck | Topic | Retain + +| Stufe 1 (unverändert) +| `signalduino/v1/state/messages` +| nein + +| FHEM-Legacy (Ereignis) +| `signalduino/v1/fhem//` +| nein + +| rtl_433 (Ereignis) +| `signalduino/v1/rtl433//` +| nein + +| Kanonischer Zustand +| `signalduino/v1/sensors/` +| ja + +| Home-Assistant-Discovery +| `homeassistant//pysd_//config` +| ja + +| Verfügbarkeit (LWT) +| `signalduino/v1/status` +| ja +|=== + +Ereignis-Topics tragen ein einzelnes Telegramm und werden nicht vorgehalten; Zustands-Topics tragen +den letzten bekannten Wert eines Geräts und werden vorgehalten, damit ein neu verbundener Konsument +sofort einen Wert sieht, statt auf das nächste Funktelegramm zu warten. Das bestehende +`state/messages` bleibt bewusst unverändert, obwohl es nach dieser Einteilung ein Ereignis ist -- +eine Umbenennung wäre ein Bruch für bestehende Konsumenten und bleibt einer Version 2 des +Topic-Schemas vorbehalten. + +[#adr-consequences] +== Konsequenzen + +=== Positive Konsequenzen + +* PySignalduino wird eigenständig nutzbar. Konsumenten erhalten unmittelbar verwertbare + Schlüssel-Wert-Paare, ohne proprietäre Dekodierlogik im Zielsystem. +* Home Assistant erkennt Sensoren über MQTT-Discovery automatisch; FHEM bleibt über den + Legacy-Adapter vollständig bedienbar und verliert keine Funktion. +* Ein neues Protokoll ist im Regelfall eine JSON-Datei plus ein Testvektor, kein Python-Code. Das + ist die Voraussetzung dafür, perspektivisch Parität zu den FHEM-Client-Modulen zu erreichen. +* Die FHEM-Testdaten unter `t/FHEM//testData.json` enthalten neben den Rohtelegrammen auch + die erwarteten Readings und dienen damit als unmittelbare Paritätsreferenz. +* Der bestehende Nachrichtenfluss bleibt unangetastet; der FHEM-Adapter funktioniert ab dem ersten + Tag für alle 160 Protokolle, weil er nicht von Stufe 2 abhängt. + +=== Negative Konsequenzen + +* Der Gesamtaufwand bis zur vollen Parität ist hoch; die Dekodierlogik von über 150 Protokollen + wandert schrittweise in dieses Projekt und muss dort gepflegt werden. +* Mit der Spezifikationssprache entsteht ein projekteigenes Format, das dokumentiert, versioniert + und gegen ein Schema validiert werden muss. +* Die Entscheidung, FHEM-Readingnamen als kanonische Schlüssel zu verwenden, bindet das Datenmodell + an eine fremde Namenskonvention. Der Nutzen für Paritätstests und Migration überwiegt, die + Abbildung auf andere Konventionen erfolgt in den Adaptern. +* Solange für ein Protokoll keine Spezifikation existiert, liefert die Schicht keine Messwerte. Die + Abdeckung wächst schrittweise und muss als Kennzahl sichtbar gemacht werden. +* Zusätzliche Topics und vorgehaltene Discovery-Nachrichten erhöhen die Last auf dem Broker. + +[#adr-alternatives] +== Alternativen + +* *Dekodierung in `sd_protocols/` (abgelehnt):* Naheliegend, weil dort bereits Protokollwissen + liegt. Das Verzeichnis wird jedoch aus den Perl-Quellen generiert; neu geschriebener Code würde + bei jeder Neugenerierung zum Konflikt. +* *Dekodierung im MQTT-Publisher (abgelehnt):* Die Messwerte stünden dann ausschließlich dem + MQTT-Pfad zur Verfügung und weder `message_callback` noch künftigen Senken. +* *Ein Python-Modul pro Protokoll (abgelehnt):* Skaliert nicht auf über 150 Protokolle und führt zu + massiver Codeverdopplung, da sich die Dekodierung überwiegend in Bitbereichen und Skalierungen + erschöpft. +* *Beibehaltung der FHEM-Bridge (Option 2 des Proposals, zurückgestellt):* Ein FHEM-Perl-Modul, das + das JSON zurück in Dispatch-Strings übersetzt, wäre deutlich billiger, löst aber die + Grundabhängigkeit von FHEM nicht auf und hilft anderen Zielsystemen nicht. Der Legacy-Adapter + dieses ADR deckt den Kompatibilitätsbedarf ohne zusätzliches Perl-Modul ab. +* *Neue Topic-Wurzel `events/` mit Migration von `state/messages` (zurückgestellt):* Semantisch + sauberer, wäre aber ein Bruch für bestehende Konsumenten innerhalb von Version 1 des + Topic-Schemas. Vorgesehen für Version 2. diff --git a/docs/architecture/proposals/fhem_mqtt_integration.adoc b/docs/architecture/proposals/fhem_mqtt_integration.adoc index f90bd0e..7266c47 100644 --- a/docs/architecture/proposals/fhem_mqtt_integration.adoc +++ b/docs/architecture/proposals/fhem_mqtt_integration.adoc @@ -61,6 +61,13 @@ PySignalDuino würde eine neue Konfigurationsoption erhalten, die es ihm erlaubt === Option 4: Portierung der Dekodier-Logik (Client-Module) nach PySignalDuino +[NOTE] +==== +*Entschieden.* Diese Option wurde mit link:../decisions/ADR-006-sensor-decoding-layer.adoc[ADR-006] +angenommen und dort ausgearbeitet. Der dort beschriebene FHEM-Legacy-Adapter deckt zusätzlich den +Kompatibilitätsbedarf ab, den Option 2 adressiert hätte, ohne ein eigenes Perl-Modul zu erfordern. +==== + Anstatt nur Rohdaten zu senden, übernimmt PySignalDuino auch die Interpretation der Daten (z.B. Umrechnung von Hex-Werten in Temperatur, Luftfeuchtigkeit, Batteriestatus, Windgeschwindigkeit etc.). Das entspricht der Logik, die aktuell in FHEM-Modulen wie `14_SD_WS.pm` liegt. [cols="1,3"] @@ -88,6 +95,7 @@ PySignalDuino publiziert die demodulierten Nachrichten in einem standardisierten **Fazit:** * Varianten, die das standardisierte JSON von PySignalDuino beibehalten (Option 1, 1b, 2), sind für eine Koexistenz gut geeignet. * **Option 4** ist der klare Gewinner für moderne IoT-Landschaften (HA, Node-RED), erfordert aber den größten Aufwand in PySignalDuino. +* **Umgesetzt wird Option 4**, entschieden in link:../decisions/ADR-006-sensor-decoding-layer.adoc[ADR-006]. Der Aufwand wird durch eine datengetriebene Dekodier-Spezifikation beherrschbar gemacht, sodass ein neues Protokoll im Regelfall eine JSON-Datei statt Python-Code ist. == Grobe POC-Implementierungen From b4e0ad2b411b03f60d785012115c9732b19fbed9 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 16 Sep 2026 19:28:09 +0200 Subject: [PATCH 2/5] chore(protocols): drop the obsolete sd_protocol_data stub sd_protocol_data.py held three hand-written protocol entries and was the only thing importing them, while the runtime loads all 160 protocols from protocols.json via loader.py. Neither `protocols` nor `VERSION` was read anywhere in the repository. Keeping it around was actively misleading: README and the user guide named SDProtocolData as the protocol data source, so both now point at protocols.json and the generator that produces it. --- README.adoc | 2 +- docs/01_user_guide/index.adoc | 2 +- sd_protocols/__init__.py | 1 - sd_protocols/sd_protocol_data.py | 23 ----------------------- 4 files changed, 2 insertions(+), 26 deletions(-) delete mode 100644 sd_protocols/sd_protocol_data.py diff --git a/README.adoc b/README.adoc index ce1f942..8502f34 100644 --- a/README.adoc +++ b/README.adoc @@ -34,7 +34,7 @@ Die SIGNALDuino-Firmware (Microcontroller-Code) wird in einem separaten Reposito * **Vollständig asynchron** – Basierend auf `asyncio` für hohe Performance und einfache Integration in asynchrone Anwendungen. * **MQTT-Integration** – Automatisches Publizieren dekodierter Nachrichten in konfigurierbare Topics und Empfang von Steuerbefehlen (z.B. `version`, `set`, `mqtt`). * **Unterstützte Transporte** – Serielle Verbindung (über `pyserial-asyncio`) und TCP-Verbindung. -* **Umfangreiche Protokollbibliothek** – Portierung der originalen FHEM‑SIGNALDuino‑Protokolle mit `SDProtocols` und `SDProtocolData`. +* **Umfangreiche Protokollbibliothek** – Portierung der originalen FHEM‑SIGNALDuino‑Protokolle über `SDProtocols`; die Protokolldefinitionen liegen in `sd_protocols/protocols.json`. * **Konfiguration über Umgebungsvariablen** – Einfache Einrichtung ohne Codeänderungen. * **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. diff --git a/docs/01_user_guide/index.adoc b/docs/01_user_guide/index.adoc index 9768d3a..485a1e8 100644 --- a/docs/01_user_guide/index.adoc +++ b/docs/01_user_guide/index.adoc @@ -87,7 +87,7 @@ Die Hauptkomponenten sind: 3. **Protokollbibliothek** (`sd_protocols`): * `SDProtocols` – Hauptklasse für Protokollerkennung und ‑dekodierung. - * `SDProtocolData` – Datenstrukturen für Protokolldefinitionen. + * `protocols.json` – Protokolldefinitionen, erzeugt aus den FHEM‑Perl‑Quellen via `tools/convert.pl`. 4. **Controller** (`signalduino.controller`): * `SignalduinoController` – Zentrale Steuerungsklasse, koordiniert Transport, Parser und MQTT. diff --git a/sd_protocols/__init__.py b/sd_protocols/__init__.py index 4b15b0c..4d5c1d1 100644 --- a/sd_protocols/__init__.py +++ b/sd_protocols/__init__.py @@ -1,3 +1,2 @@ # Ermöglicht den Import als Paket from .sd_protocols import SDProtocols -from .sd_protocol_data import protocols, VERSION \ No newline at end of file diff --git a/sd_protocols/sd_protocol_data.py b/sd_protocols/sd_protocol_data.py deleted file mode 100644 index 76675e5..0000000 --- a/sd_protocols/sd_protocol_data.py +++ /dev/null @@ -1,23 +0,0 @@ -VERSION = "1.0" - -protocols = { - "1": { - "name": "Conrad RSL v1", - "clientmodule": "SD_RSL", - "bitlength": 20, - "comment": "remotes and switches" - }, - "2": { - "name": "Arduino", - "clientmodule": "SD_AS", - "bitlength": 32, - "comment": "self build arduino sensor" - }, - "3": { - "name": "Intertechno", - "clientmodule": "IT", - "bitlength": 24, - "comment": "remote for ELRO, Intertek, etc." - } - # … hier kannst du alle weiteren Protokolle aus SD_ProtocolData.pm ergänzen -} From 432d3c02f786fe0c044919360fc7cc8c97f9731b Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 16 Sep 2026 19:28:14 +0200 Subject: [PATCH 3/5] feat(decoders): add the enablers for the sensor decoding layer Groundwork for ADR-006. No decoding happens yet, but everything stage 2 needs is now in place: * the demodulated bit string is carried in metadata["bits"] for MS, MU and MC messages. It was computed already but only its length was kept, and the FHEM decoding logic this layer mirrors works on bits, not on hex. * SensorEvent holds the interpreted measurements, and DecodedMessage gains a single optional `sensor` field, so no existing signature changes. The MQTT serializer drops that field, keeping state/messages byte identical for current consumers. * tools/fhem_testdata_import.py vendors the FHEM test vectors into tests/data/fhem/. They carry the raw telegram, the expected stage 1 string and the expected stage 2 readings, which makes them the parity reference for both stages. Vendoring keeps the suite independent of a RFFHEM checkout sitting next to this one. The new baseline test pins stage 1 at 86 of 99 reproduced vectors and requires the five protocols with hardware behind them here (27, 50, 85, 125, 126) to match completely, so a stage 2 change cannot quietly damage stage 1. --- sd_protocols/manchester.py | 2 + sd_protocols/message_synced.py | 1 + sd_protocols/message_unsynced.py | 1 + signalduino/mqtt.py | 5 +- signalduino/types.py | 27 + tests/data/fhem/sd_ws.json | 2969 ++++++++++++++++++++++++++++++ tests/fhem_vectors.py | 97 + tests/test_stage1_baseline.py | 95 + tools/fhem_testdata_import.py | 135 ++ 9 files changed, 3331 insertions(+), 1 deletion(-) create mode 100644 tests/data/fhem/sd_ws.json create mode 100644 tests/fhem_vectors.py create mode 100644 tests/test_stage1_baseline.py create mode 100644 tools/fhem_testdata_import.py diff --git a/sd_protocols/manchester.py b/sd_protocols/manchester.py index 35c51d3..8c80eab 100644 --- a/sd_protocols/manchester.py +++ b/sd_protocols/manchester.py @@ -135,6 +135,8 @@ def _demodulate_mc_data(self, name: str, protocol_id: int, clock: int, raw_hex: metadata = { "protocol_id": protocol_id, + "bits": bit_data, + "bit_length": len(bit_data), "rssi": None, "freq_afc": None, } diff --git a/sd_protocols/message_synced.py b/sd_protocols/message_synced.py index 37decfc..195542c 100644 --- a/sd_protocols/message_synced.py +++ b/sd_protocols/message_synced.py @@ -234,6 +234,7 @@ def demodulate_ms(self, msg_data: Dict[str, Any], msg_type: str = "MS") -> List[ "protocol_id": pid, "payload": final_payload, "meta": { + "bits": bit_str, "bit_length": len(bit_str), "rssi": msg_data.get('R'), "clock": clock_abs diff --git a/sd_protocols/message_unsynced.py b/sd_protocols/message_unsynced.py index bf3de16..5bd0bdd 100644 --- a/sd_protocols/message_unsynced.py +++ b/sd_protocols/message_unsynced.py @@ -283,6 +283,7 @@ def demodulate_mu(self, msg_data: Dict[str, Any], msg_type: str = "MU") -> List[ "protocol_id": pid, "payload": final_payload, "meta": { + "bits": bit_str, "bit_length": len(bit_str), "rssi": msg_data.get('R'), "clock": clock_abs diff --git a/signalduino/mqtt.py b/signalduino/mqtt.py index 0b55a98..5a580b5 100644 --- a/signalduino/mqtt.py +++ b/signalduino/mqtt.py @@ -241,7 +241,10 @@ 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 - + # Stage 2 results have their own topics (sensors/, rtl433/, fhem/), so this + # topic keeps the exact shape consumers already rely on. See ADR-006. + message_dict.pop("sensor", None) + return json.dumps(message_dict, indent=4) async def publish_simple(self, subtopic: str, payload: str, retain: bool = False) -> None: diff --git a/signalduino/types.py b/signalduino/types.py index 72d03e0..32776cc 100644 --- a/signalduino/types.py +++ b/signalduino/types.py @@ -21,6 +21,32 @@ class RawFrame: message_type: Optional[str] = None +@dataclass(slots=True) +class SensorEvent: + """Interpreted measurements of a single received frame (decoding stage 2). + + While DecodedMessage carries the demodulated payload, this carries what the + payload means: temperature, humidity, battery state and so on. The keys used + in ``values`` are the FHEM reading names, because those are the de facto + standard across the ported protocols and keep the parity tests against the + FHEM test vectors a plain comparison. Output adapters map them to their own + naming. + """ + + protocol_id: str + model: str + sensor_type: str + device_id: str + sensor_id: str + values: dict[str, Any] = field(default_factory=dict) + units: dict[str, str] = field(default_factory=dict) + channel: Optional[int] = None + raw_hex: str = "" + dmsg: str = "" + rssi: Optional[float] = None + timestamp: datetime = field(default_factory=datetime.utcnow) + + @dataclass(slots=True) class DecodedMessage: """Higher-level frame after running through the parser.""" @@ -29,6 +55,7 @@ class DecodedMessage: payload: str raw: RawFrame metadata: dict = field(default_factory=dict) + sensor: Optional[SensorEvent] = None @dataclass(slots=True) diff --git a/tests/data/fhem/sd_ws.json b/tests/data/fhem/sd_ws.json new file mode 100644 index 0000000..db9b398 --- /dev/null +++ b/tests/data/fhem/sd_ws.json @@ -0,0 +1,2969 @@ +{ + "_source": { + "repository": "https://github.com/RFD-FHEM/RFFHEM", + "path": "t/FHEM/14_SD_WS/testData.json", + "revision": "fb304ff8aa54105b1cd532d3e9125e3b2f74552b", + "imported_at": "2026-09-16T17:25:37Z", + "note": "Generated by tools/fhem_testdata_import.py - do not edit by hand." + }, + "vectors": [ + { + "data": [ + { + "comment": "EuroChron weatherstation EFTH-800 / Channel 2 (ID 61 additionally)", + "dmsg": "W27#113C49B04806", + "internals": { + "DEF": "SD_WS_27_TH_2", + "NAME": "SD_WS_27_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "48", + "state": "T: 15.5 H: 48", + "temperature": "15.5", + "type": "EFTH-800, EFS-3110A" + }, + "rmsg": "MU;P0=-224;P1=258;P2=-487;P3=505;P4=-4884;P5=743;P6=-718;D=0121212301212303030301212123012123012123030123030121212121230121230121212121212121230301214565656561212123012121230121230303030121212301212301212303012303012121212123012123012121212121212123030121;CP=1;R=53;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "EuroChron weatherstation EFTH-800 / Channel 3 (ID 61 additionally)", + "dmsg": "W27#21D442607679", + "internals": { + "DEF": "SD_WS_27_TH_3", + "NAME": "SD_WS_27_TH_3" + }, + "readings": { + "batteryState": "ok", + "channel": "3", + "humidity": "76", + "state": "T: 3.8 H: 76", + "temperature": "3.8", + "type": "EFTH-800, EFS-3110A" + }, + "rmsg": "MU;P0=-241;P1=251;P2=-470;P3=500;P4=-4868;P5=743;P6=-718;D=012121212303030123012301212123012121212301212303012121212121230303012303012123030303012123014565656561212301212121230303012301230121212301212121230121230301212121212123030301230301212303030301212301;CP=1;R=23;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "EuroChron weatherstation EFTH-800 / Channel 3 (ID 61 additionally)", + "dmsg": "W27#21D4435075DE", + "internals": { + "DEF": "SD_WS_27_TH_3", + "NAME": "SD_WS_27_TH_3" + }, + "readings": { + "batteryState": "ok", + "channel": "3", + "humidity": "75", + "state": "T: 5.3 H: 75", + "temperature": "5.3", + "type": "EFTH-800, EFS-3110A" + }, + "rmsg": "MU;P0=-240;P1=253;P2=-487;P3=489;P4=-4860;P5=746;P6=-725;D=012121212303030123012301212123012121212303012301230121212121230303012301230303012303030301214565656561212301212121230303012301230121212301212121230301230123012121212123030301230123030301230303030121;CP=1;R=19;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "EuroChron weatherstation EFTH-800 / Channel 3 - wrong CRC", + "dmsg": "W27#21D4435075DF", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + } + ], + "id": "27", + "module": "SD_WS", + "name": "EFTH-800" + }, + { + "data": [ + { + "comment": "BRESSER 6-in-1 temp diff not ok, without attr", + "dmsg": "W115#9104143025BE18FFFFFF2928925A97FFF0000000000000000003", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "97", + "state": "T: -7.5 H: 97 W: 0", + "temperature": "-7.5", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "292", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=9104143025BE18FFFFFF2928925A97FFF0000000000000000003;R=189;", + "tests": [ + { + "comment": "#0", + "returns": { + "ParseFn": "" + }, + "setreadings": { + "humidity": "92", + "temperature": "-1.5" + } + } + ] + }, + { + "comment": "BRESSER 6-in-1 temp diff ok, but hum diff not ok, with attr max-deviation-temp", + "dmsg": "W115#9104143025BE18FFFFFF2928925A97FFF0000000000000000003", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "97", + "state": "T: -7.5 H: 97 W: 0", + "temperature": "-7.5", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "292", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=9104143025BE18FFFFFF2928925A97FFF0000000000000000003;R=189;", + "tests": [ + { + "attributes": { + "max-deviation-temp": "50" + }, + "comment": "#1", + "returns": { + "ParseFn": "" + }, + "setreadings": { + "humidity": "92", + "temperature": "-1.5" + } + } + ] + }, + { + "comment": "BRESSER 6-in-1 hum diff ok, with attr max-deviation-hum", + "dmsg": "W115#9104143025BE18FFFFFF2928925A97FFF0000000000000000003", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "97", + "state": "T: -7.5 H: 97 W: 0", + "temperature": "-7.5", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "292", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=9104143025BE18FFFFFF2928925A97FFF0000000000000000003;R=189;", + "tests": [ + { + "attributes": { + "max-deviation-hum": "65" + }, + "comment": "#2", + "setreadings": { + "humidity": "32", + "temperature": "-7.4" + } + } + ] + }, + { + "comment": "BRESSER 6-in-1 temp diff ok, hum diff ok, without attr", + "dmsg": "W115#9104143025BE18FFFFFF2928925A97FFF0000000000000000003", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "97", + "state": "T: -7.5 H: 97 W: 0", + "temperature": "-7.5", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "292", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=9104143025BE18FFFFFF2928925A97FFF0000000000000000003;R=189;", + "tests": [ + { + "comment": "#3", + "setreadings": { + "humidity": "96", + "temperature": "-7.6" + } + } + ] + } + ], + "id": "115", + "module": "SD_WS", + "name": "BRESSER 6-in-1" + }, + { + "data": [ + { + "comment": "S522 (ID 33.2,51,53 additionally)", + "dmsg": "W33#0501DD80038", + "internals": { + "DEF": "SD_WS_33_T_1", + "NAME": "SD_WS_33_T_1" + }, + "readings": { + "channel": "1", + "state": "T: 24.2", + "temperature": "24.2", + "type": "E0001PA, s014, S522, TCM, TFA 30.3200, TX-EZ6" + }, + "rmsg": "MS;P1=-8035;P2=504;P3=-2027;P4=-3945;D=21232323232324232423232323232323242424232424242324242323232323232323232323232324242423;CP=2;SP=1;R=19;O;m0;", + "tests": [ + { + "attributes": { + "model": "S522" + }, + "comment": "#0" + } + ] + }, + { + "comment": "S522 (ID 33.2,51,53 additionally) - wrong CRC", + "dmsg": "W33#0501DD8003F", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "33", + "module": "SD_WS", + "name": "Conrad S522" + }, + { + "data": [ + { + "comment": "fuer Wetterstation TZS First Austria", + "dmsg": "W33#17412998A4C", + "internals": { + "DEF": "SD_WS_33_TH_1", + "NAME": "SD_WS_33_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "38", + "humidityTrend": "rising", + "sendmode": "manual", + "state": "T: 26.7 H: 38", + "temperature": "26.7", + "temperatureTrend": "consistent", + "type": "E0001PA, s014, S522, TCM, TFA 30.3200, TX-EZ6" + }, + "rmsg": "MS;P0=-3788;P1=-7610;P4=621;P5=-1895;D=41454545404540404045404545454545404545404540454540404545404045454540454045454045454040;CP=4;SP=1;O;", + "tests": [ + { + "attributes": { + "model": "TX-EZ6" + }, + "comment": "#0" + } + ] + } + ], + "id": "33", + "module": "SD_WS", + "name": "TX-EZ6" + }, + { + "data": [ + { + "comment": "Conrad (ID 33.2,51,53 additionally)", + "dmsg": "W33#26C6F570804", + "internals": { + "DEF": "SD_WS_33_TH_2", + "NAME": "SD_WS_33_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "44", + "sendmode": "auto", + "state": "T: 15.5 H: 44", + "temperature": "15.5", + "type": "E0001PA, s014, S522, TCM, TFA 30.3200, TX-EZ6" + }, + "rmsg": "MS;P0=-7990;P1=485;P3=-2061;P4=-4060;D=10131314131314141314141313131414131414141413141314131414141313131314131313131313131314;CP=1;SP=0;R=4;O;", + "tests": [ + { + "attributes": { + "model": "E0001PA" + }, + "comment": "#0" + } + ] + } + ], + "id": "33", + "module": "SD_WS", + "name": "renkforce E0001PA" + }, + { + "data": [ + { + "comment": "Thermo-hygro sensor for base station 35.1126 (ID 53 additionally)", + "dmsg": "W33#14C1C594C0C", + "internals": { + "DEF": "SD_WS_33_TH_1", + "NAME": "SD_WS_33_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "53", + "state": "T: 18.8 H: 53", + "temperature": "18.8", + "type": "E0001PA, s014, S522, TCM, TFA 30.3200, TX-EZ6" + }, + "rmsg": "MS;P1=-7796;P2=745;P3=-1976;P4=-3929;D=21232323242324232324242323232323242424232323242324242323242324232324242323232323232424;CP=2;SP=1;R=30;O;m2;", + "tests": [ + { + "attributes": { + "model": "other" + }, + "comment": "#0" + } + ] + } + ], + "id": "33.1", + "module": "SD_WS", + "name": "TFA 30.3200" + }, + { + "data": [ + { + "comment": "(ID 0.3 additionally)", + "dmsg": "W33#3E23C564824", + "internals": { + "DEF": "SD_WS_33_TH_1", + "NAME": "SD_WS_33_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "41", + "state": "T: 5.1 H: 41", + "temperature": "5.1", + "type": "E0001PA, s014, S522, TCM, TFA 30.3200, TX-EZ6" + }, + "rmsg": "MS;P1=393;P2=-7752;P3=-2047;P4=-3993;D=12131314141414141313131413131314141414131313141314131414131314131314131313131314131314;CP=1;SP=2;R=230;O;m1;", + "tests": [ + { + "attributes": { + "model": "other" + }, + "comment": "#0" + } + ] + } + ], + "id": "33.2", + "module": "SD_WS", + "name": "Tchibo Wetterstation" + }, + { + "data": [ + { + "comment": "SD_WS37_TH (ID 61,84,89 additionally)", + "dmsg": "W37#D9165C307B", + "internals": { + "DEF": "SD_WS37_TH_1", + "NAME": "SD_WS37_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "48", + "state": "T: 22.7 H: 48", + "temperature": "22.7", + "type": "Bresser 7009994" + }, + "rmsg": "MU;P0=729;P1=-736;P2=483;P3=-251;P4=238;P5=-491;D=010101012323452323454523454545234523234545234523232345454545232345454545452323232345232340;CP=4;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "SD_WS37_TH (ID 61,84,89 additionally) - wrong checksum", + "dmsg": "W37#D9165C307C", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "37", + "module": "SD_WS", + "name": "Bresser 7009994" + }, + { + "data": [ + { + "comment": "NC-3911", + "dispatch_repeats": "4", + "dmsg": "W38#12A2C5D0C", + "internals": { + "DEF": "SD_WS_38_T_2", + "NAME": "SD_WS_38_T_2" + }, + "readings": { + "batteryState": "ok", + "beep": "off", + "channel": "2", + "state": "T: 20.9", + "temperature": "20.9", + "type": "NC-3911" + }, + "rmsg": "MU;P0=-235;P1=496;P2=253;P3=-479;P4=-957;P5=743;P6=-720;CP=2;D=010231023232310231010232323102310101023102323232310102323245656565623232310232310231023102323231023101023232310231010102310232323231010232324565656562323231023231023102310232323102310102323231023101010231023232323101023232456565656232323102323102310231023232310231010232323102310101023102323232310102323245656565623232310232310231023102323231023101023232310231010102310232323231010232324565656562323231023231023102310232323102310102323231023101010231023232323101023232456565656232323102323102;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "NC-3911 - wrong checksum", + "dmsg": "W38#12A2C5FFC", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "38", + "module": "SD_WS", + "name": "NC-3911-675" + }, + { + "data": [ + { + "dmsg": "W44#D12160652EDE9F9B10", + "internals": { + "DEF": "BresserTemeo_1", + "NAME": "BresserTemeo_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "68", + "state": "T: 3.2 H: 68", + "temperature": "3.2", + "type": "BresserTemeo" + }, + "rmsg": "MU;P0=32001;P1=-1939;P2=1967;P3=3896;P4=-3895;D=01213424242124212121242121242121212124212424212121212121242421212421242121242124242421242421242424242124212124242424242421212424212424212121242121212;CP=2;R=39;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "44", + "module": "SD_WS", + "name": "BRESSER Temeo Trend" + }, + { + "data": [ + { + "comment": "von 2016 (ID 42 additionally)", + "dispatch_repeats": "1", + "dmsg": "W50#FF550541FF9A", + "internals": { + "DEF": "SD_WS_50_SM_1", + "NAME": "SD_WS_50_SM_1" + }, + "readings": { + "channel": "1", + "humidity": "5", + "state": "T: 25 H: 5", + "temperature": "25", + "type": "XT300" + }, + "rmsg": "MU;P0=248;P1=-21400;P2=545;P3=-925;P4=1368;P5=-12308;D=01232323232323232343234323432343234343434343234323432343434343432323232323232323232343432323432345232323232323232343234323432343234343434343234323432343434343432323232323232323232343432323432345232323232323232343234323432343234343434343234323432343434343;CP=2;O;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "50", + "module": "SD_WS", + "name": "Opus_XT300" + }, + { + "data": [ + { + "comment": "Lidl Weatherstation, Channel 1", + "dmsg": "W51#11225FB401", + "internals": { + "DEF": "SD_WS_51_TH_1", + "NAME": "SD_WS_51_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "40", + "sendmode": "auto", + "state": "T: 17.3 H: 40", + "temperature": "17.3", + "trend": "falling", + "type": "Auriol IAN 275901, IAN 114324, IAN 60107" + }, + "rmsg": "MS;P0=-1848;P1=577;P2=-4066;P3=-15997;P4=1013;P5=-1001;P6=-7875;D=16101010121010101210101210101012101012101212121212121012121012101010101010101010121345454545;CP=1;SP=6;O;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "51", + "module": "SD_WS", + "name": "IAN 114324" + }, + { + "data": [ + { + "comment": "Lidl Weatherstation, Channel 3 (ID 0 additionally, CUL_TCM97001 -> with DEF Auriol_IAN_8)", + "dmsg": "W51#0849536953", + "internals": { + "DEF": "SD_WS_51_TH_3", + "NAME": "SD_WS_51_TH_3" + }, + "readings": { + "batteryState": "ok", + "channel": "3", + "humidity": "95", + "sendmode": "manual", + "state": "T: 6.3 H: 95", + "temperature": "6.3", + "trend": "rising", + "type": "Auriol IAN 275901, IAN 114324, IAN 60107" + }, + "rmsg": "MS;P0=-4074;P1=608;P2=-1825;P3=-15980;P4=1040;P5=-975;P6=-7862;D=16121212121012121212101212101212101210121012121010121010121012121012101210121210101345454545;CP=1;SP=6;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "51", + "module": "SD_WS", + "name": "IAN 275901" + }, + { + "data": [ + { + "comment": "Lidl Weatherstation, Channel 1 (ID 0 additionally, CUL_TCM97001 -> with DEF Auriol_IAN_240)", + "dmsg": "W51#F03048F761", + "internals": { + "DEF": "SD_WS_51_TH_1", + "NAME": "SD_WS_51_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "76", + "sendmode": "auto", + "state": "T: -2.9 H: 76", + "temperature": "-2.9", + "trend": "consistent", + "type": "Auriol IAN 275901, IAN 114324, IAN 60107" + }, + "rmsg": "MS;P2=594;P3=-7386;P4=-4081;P5=-1873;D=2324242424252525252525242425252525252425252425252524242424252424242524242525252524;CP=2;SP=3;R=242;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "51", + "module": "SD_WS", + "name": "IAN 60107" + }, + { + "data": [ + { + "comment": "CH1, Lidl IAN 314695 (ID 33,51 additionally)", + "dmsg": "W53#0700DF7A4E0", + "internals": { + "DEF": "SD_WS_53_TH_1", + "NAME": "SD_WS_53_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "61", + "state": "T: 22.3 H: 61", + "temperature": "22.3", + "type": "Auriol IAN 314695" + }, + "rmsg": "MS;P1=608;P2=-2074;P3=-4138;P4=-9138;D=14121212121213131312121212121212121313121313131313121313131312131212131212131313121212;CP=1;SP=4;R=0;O;m1;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "CH1, Lidl IAN 314695 (ID 33,51 additionally)", + "dmsg": "W53#0700F2764A4", + "internals": { + "DEF": "SD_WS_53_TH_1", + "NAME": "SD_WS_53_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "59", + "state": "T: 24.2 H: 59", + "temperature": "24.2", + "type": "Auriol IAN 314695" + }, + "rmsg": "MS;P1=611;P2=-2075;P3=-4160;P4=-9134;D=14121212121213131312121212121212121313131312121312121313131213131212131212131213121213;CP=1;SP=4;R=0;O;m2;", + "tests": [ + { + "comment": "#1" + } + ] + } + ], + "id": "53", + "module": "SD_WS", + "name": "AURIOL AHFL 433 B2" + }, + { + "data": [ + { + "comment": "CH2, Lidl IAN 314695 (ID 33,51 additionally)", + "dmsg": "W53#0710B88C4CC", + "internals": { + "DEF": "SD_WS_53_TH_2", + "NAME": "SD_WS_53_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "70", + "state": "T: 18.4 H: 70", + "temperature": "18.4", + "type": "Auriol IAN 314695" + }, + "rmsg": "MS;P0=606;P1=-2075;P2=-4136;P3=-9066;D=03010101010102020201010102010101010201020202010101020101010202010101020101020201010202;CP=0;SP=3;R=0;O;m2;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "CH2, Lidl IAN 314695 (ID 33,51 additionally) - wrong checksum", + "dmsg": "W53#0710B88C4FF", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "53", + "module": "SD_WS", + "name": "AURIOL AHFL 433 B2" + }, + { + "data": [ + { + "comment": "Rain sensor for base station 47.3005.01 / decode as MU", + "dmsg": "W54#3D9C430618AA01340", + "internals": { + "DEF": "SD_WS_54_R", + "NAME": "SD_WS_54_R" + }, + "readings": { + "batteryState": "ok", + "rain_total": "73.66", + "rawRainCounter": "290", + "sendCounter": "3", + "state": "R: 73.66", + "type": "TFA 30.3233.01" + }, + "rmsg": "MU;P1=247;P2=-750;P3=722;P4=-489;P5=491;P6=-236;P7=-2184;D=1232141456565656145656141456565614141456141414145656141414141456561414141456561414145614561456145614141414141414145614145656145614141732321414565656561456561414565656141414561414141456561414141414565614141414565614141456145614561456141414141414141456141;CP=1;R=55;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Rain sensor for base station 47.3005.01 / decode as MU", + "dmsg": "W54#3D9C430A1BAA01898", + "internals": { + "DEF": "SD_WS_54_R", + "NAME": "SD_WS_54_R" + }, + "readings": { + "batteryState": "ok", + "rain_total": "74.422", + "rawRainCounter": "293", + "sendCounter": "5", + "state": "R: 74.422", + "type": "TFA 30.3233.01" + }, + "rmsg": "MU;P0=-1672;P1=740;P2=-724;P3=260;P4=-468;P5=504;P6=-230;D=012123434565656563456563434565656343434563434343456563434343456345634343434565634565656345634563456343434343434343456563434345634345656;CP=3;R=4;", + "tests": [ + { + "comment": "#1" + } + ] + } + ], + "id": "54", + "module": "SD_WS", + "name": "TFA 30.3233.01" + }, + { + "data": [ + { + "comment": "Rain sensor for base station 47.3005.01 / decode as MS", + "dmsg": "W54#3896E10467AA0068", + "internals": { + "DEF": "SD_WS_54_R", + "NAME": "SD_WS_54_R" + }, + "readings": { + "batteryState": "ok", + "rain_total": "28.702", + "rawRainCounter": "113", + "sendCounter": "2", + "state": "R: 28.702", + "type": "TFA 30.3233.01" + }, + "rmsg": "MS;P0=-241;P1=486;P2=241;P3=-488;P4=-2098;P5=738;P6=-730;D=24565623231010102323231023231023101023101010232323231023232323231023232310102323101010102310231023102323232323232323232310102310232323;CP=2;SP=4;R=30;O;b=19;s=1;m0;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Rain sensor for base station 47.3005.01 / decode as MS", + "dmsg": "W54#3896E1066AAA0076", + "internals": { + "DEF": "SD_WS_54_R", + "NAME": "SD_WS_54_R" + }, + "readings": { + "batteryState": "ok", + "rain_total": "29.464", + "rawRainCounter": "116", + "sendCounter": "3", + "state": "R: 29.464", + "type": "TFA 30.3233.01" + }, + "rmsg": "MS;P0=-491;P1=242;P2=476;P3=-248;P4=-2096;P5=721;P6=-745;D=14565610102323231010102310102310232310232323101010102310101010102323101023231023102310231023102310231010101010101010101023232310232310;CP=1;SP=4;R=10;O;b=135;s=1;m0;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Rain sensor for base station 47.3005.01 / decode as MS - wrong CRC", + "dmsg": "W54#3896E1066AAA0077", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#2" + } + ] + } + ], + "id": "54.1", + "module": "SD_WS", + "name": "TFA 30.3233.01" + }, + { + "data": [ + { + "comment": "(ID 12 additionally)", + "dmsg": "W58#468714600AED0", + "internals": { + "DEF": "SD_WS_58_T_2", + "NAME": "SD_WS_58_T_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "state": "T: 22.2", + "temperature": "22.2", + "type": "TFA 30.3208.02, FT007xx" + }, + "rmsg": "MC;LL=-1047;LH=903;SL=-545;SH=449;D=800AE5E3AE7FD44BC00572F1D73FEA25E002B9788;C=494;L=161;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "(ID 12 additionally)", + "dmsg": "W58#468714610AAE0", + "internals": { + "DEF": "SD_WS_58_T_2", + "NAME": "SD_WS_58_T_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "state": "T: 22.3", + "temperature": "22.3", + "type": "TFA 30.3208.02, FT007xx" + }, + "rmsg": "MC;LL=-1047;LH=902;SL=-546;SH=452;D=0015CBC75CF7AA8F800AE5E3AE7BD547C00572F1D0;C=487;L=165;", + "tests": [ + { + "comment": "#1" + } + ] + } + ], + "id": "58", + "module": "SD_WS", + "name": "Froggit FT007T" + }, + { + "data": [ + { + "comment": "Thermo-hygro sensor with 2 Repeats", + "dmsg": "W58#45C8142445DB0", + "internals": { + "DEF": "SD_WS_58_TH_2", + "NAME": "SD_WS_58_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "69", + "state": "T: 18.9 H: 69", + "temperature": "18.9", + "type": "TFA 30.3208.02, FT007xx" + }, + "rmsg": "MC;LL=-981;LH=964;SL=-480;SH=520;D=002BA37EBDBBA24F0015D1BF5EDDD127800AE8DFAF6EE893C;C=486;L=194;R=34;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Thermo-hygro sensor with 2 Repeats - wrong CRC", + "dmsg": "W58#45C8142445DC0", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "58", + "module": "SD_WS", + "name": "TFA 30.3208.02" + }, + { + "data": [ + { + "comment": "Fine Offset Electronics WH2, WH2A Temperature/Humidity sensor", + "dmsg": "W64#FE97615C94381E", + "internals": { + "DEF": "SD_WS_WH2_0", + "NAME": "SD_WS_WH2_0" + }, + "readings": { + "batteryState": "ok", + "channel": "0", + "humidity": "74", + "state": "T: 17.4 H: 74", + "temperature": "17.4", + "type": "WH2, WH2A" + }, + "rmsg": "MU;P0=-28888;P1=461;P2=-1012;P3=1440;D=01212121212121232123232123212121232121232323232123212321212123232123232123212323232321212123232323232321212121;CP=1;R=202;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Fine Offset Electronics WH2, WH2A Temperature/Humidity sensor", + "dmsg": "W64#FE976236540E90", + "internals": { + "DEF": "SD_WS_WH2_0", + "NAME": "SD_WS_WH2_0" + }, + "readings": { + "batteryState": "ok", + "channel": "0", + "humidity": "42", + "state": "T: 28.3 H: 42", + "temperature": "28.3", + "type": "WH2, WH2A" + }, + "rmsg": "MU;P0=-25696;P1=479;P2=-985;P3=1461;D=01212121212121232123232123212121232121232323212323232121232121232321232123212323232323232121212321232321232323;CP=1;R=215;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Fine Offset Electronics WH2, WH2A Temperature/Humidity sensor", + "dmsg": "W64#FE9041CC812844", + "internals": { + "DEF": "SD_WS_WH2_0", + "NAME": "SD_WS_WH2_0" + }, + "readings": { + "batteryState": "ok", + "channel": "0", + "humidity": "64", + "state": "T: 23 H: 64", + "temperature": "23", + "type": "WH2, WH2A" + }, + "rmsg": "MU;P0=134;P1=-113;P3=412;P4=-1062;P5=1379;D=01010101013434343434343454345454345454545454345454545454343434545434345454345454545454543454543454345454545434545454345;CP=3;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "Fine Offset Electronics WH2, WH2A Temperature/Humidity sensor - wrong CRC", + "dmsg": "W64#FE9041CC812C44", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + } + ], + "id": "64", + "module": "SD_WS", + "name": "WH2" + }, + { + "data": [ + { + "comment": "PEARL infactory Poolthermometer (ID 64 additionally)", + "dmsg": "W71#589A829FDFF4", + "internals": { + "DEF": "SD_WS71_T_1", + "NAME": "SD_WS71_T_1" + }, + "readings": { + "channel": "1", + "state": "T: 24.2", + "temperature": "24.2", + "type": "PV-8644" + }, + "rmsg": "MU;P0=1735;P1=-1160;P2=591;P3=-876;D=0123012323010101230101232301230123010101010123012301012323232323232301232323232323232323012301012;CP=2;R=97;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "71", + "module": "SD_WS", + "name": "PV-8644" + }, + { + "data": [ + { + "comment": "Version 06/2017", + "dmsg": "W84#033B1FC328", + "internals": { + "DEF": "SD_WS_84_TH_2", + "NAME": "SD_WS_84_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "59", + "sendmode": "auto", + "state": "T: -6.1 H: 59", + "temperature": "-6.1", + "type": "Auriol IAN 283582, TV-4848" + }, + "rmsg": "MU;P0=595;P1=344;P2=-862;P3=846;P4=244;P5=-602;P7=-251;D=1232323245454545454507074545070707450707454545070707070707074545454507074545074507454540;CP=4;R=4;", + "state": "T: -6.1 H: 59", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "84", + "module": "SD_WS", + "name": "Auriol IAN 283582" + }, + { + "data": [ + { + "comment": "Aldi (ID 40 additionally)", + "dmsg": "W84#A64D20D570", + "internals": { + "DEF": "SD_WS_84_TH_3", + "NAME": "SD_WS_84_TH_3" + }, + "readings": { + "batteryState": "ok", + "channel": "3", + "humidity": "77", + "sendmode": "auto", + "state": "T: 21.3 H: 77", + "temperature": "21.3", + "type": "Auriol IAN 283582, TV-4848" + }, + "rmsg": "MU;P0=-30004;P1=815;P2=-910;P3=599;P4=-263;P5=234;P6=-621;D=0121212345634565634345656345656343456345656345656565656343456345634563456343434565656;CP=5;R=5;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "84", + "module": "SD_WS", + "name": "Sempre 92596/65395" + }, + { + "data": [ + { + "comment": "Amazon (ID 37,63 additionally)", + "dispatch_repeats": "1", + "dmsg": "W84#5E36012D24", + "internals": { + "DEF": "SD_WS_84_TH_1", + "NAME": "SD_WS_84_TH_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "54", + "sendmode": "auto", + "state": "T: 30.1 H: 54", + "temperature": "30.1", + "type": "Auriol IAN 283582, TV-4848" + }, + "rmsg": "MU;P0=859;P1=-845;P2=-253;P3=240;P4=-598;P5=617;D=45252525234343452523452523434343434343434523434523452523452343452343452345234010101013452345252525234343452523452523434343434343434523434523452523452343452343452345234010101013452345252525234343452523452523434343434343434523434523452523452343452343452345;CP=3;R=63;O;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "84", + "module": "SD_WS", + "name": "Tecvance TV-4848" + }, + { + "data": [ + { + "comment": "Thermo-hygro sensor for base station 35.1140.01 (ID 54,61 additionally)", + "dmsg": "W85#0C45C60124B0554F8", + "internals": { + "DEF": "SD_WS_85_THW_1", + "NAME": "SD_WS_85_THW_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "humidity": "85", + "state": "T: 8.7 H: 85", + "temperature": "8.7", + "type": "TFA 30.3222.02, TFA 30.3251.10, LaCrosse TX141W" + }, + "rmsg": "MU;P0=-509;P1=474;P2=-260;P3=228;P4=718;P5=-745;D=01212303030303012301230123012301230301212121230454545453030303012123030301230303012301212123030301212303030303030303012303012303012303012301212303030303012301230123012301230301212121212454545453030303012123030301230303012301212123030301212303030303030303;CP=3;R=46;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Thermo-hygro sensor for base station 35.1140.01 - wrong CRC", + "dmsg": "W85#0C45C60124B0554E8", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "85", + "module": "SD_WS", + "name": "TFA 30.3222.02, Temp" + }, + { + "data": [ + { + "comment": "Thermo-hygro sensor for base station 35.1140.01 (ID 54,61 additionally)", + "dmsg": "W85#0C35A602015000C98", + "internals": { + "DEF": "SD_WS_85_THW_1", + "NAME": "SD_WS_85_THW_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "state": "W: 2.1", + "type": "TFA 30.3222.02, TFA 30.3251.10, LaCrosse TX141W", + "windSpeed": "2.1" + }, + "rmsg": "MU;P0=242;P1=-506;P2=467;P3=-248;P4=723;P5=-736;D=01012323010123010123014545454501010101232301010101232301230123230123010123230101010101010123010101010101010123012301230101010101010101010101012323010123010123234545454501010101232301010101232301230123230123010123230101010101010123010101010101010123012301;CP=0;R=52;O;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "85", + "module": "SD_WS", + "name": "TFA 30.3222.02, Wind" + }, + { + "data": [ + { + "comment": "additionally windDirectionDegree and windDirectionText", + "dmsg": "W85#0BFF0F0203B03A980", + "internals": { + "DEF": "SD_WS_85_THW_1", + "NAME": "SD_WS_85_THW_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "state": "W: 5.9", + "type": "TFA 30.3222.02, TFA 30.3251.10, LaCrosse TX141W", + "windDirectionDegree": "58", + "windDirectionText": "ENE", + "windSpeed": "5.9" + }, + "rmsg": "MU;P0=-28464;P1=493;P2=-238;P3=244;P4=-492;P5=728;P6=-732;D=01212123434343412121212343434343434123434343434343412121234121234343434343412121234123412343412123434343456565656343434341234121212121212121212123434343412121212343434343434123434343434343412121234121234343434343412121234123412343412123434343456565656343;CP=3;R=20;O;", + "tests": [ + { + "attributes": { + "model": "TFA_30.3251.10" + }, + "comment": "#0" + } + ] + }, + { + "comment": "windDirectionText N, windDirectionDegree 355", + "dmsg": "W85#0BFF0F02000163C10", + "internals": { + "DEF": "SD_WS_85_THW_1", + "NAME": "SD_WS_85_THW_1" + }, + "readings": { + "batteryState": "ok", + "channel": "1", + "state": "W: 0", + "type": "TFA 30.3222.02, TFA 30.3251.10, LaCrosse TX141W", + "windDirectionDegree": "355", + "windDirectionText": "N", + "windSpeed": "0" + }, + "rmsg": "MU;P0=-11716;P1=485;P2=-239;P3=251;P4=-490;P5=732;P6=-726;D=01212121234343434121212123434343434341234343434343434343434343434343434123412123434341212121234343434341234565656563434343412341212121212121212121234343434121212123434343434341234343434343434343434343434343434123412123434341212121234343434341234565656563;CP=3;R=23;O;", + "tests": [ + { + "attributes": { + "model": "TFA_30.3251.10" + }, + "comment": "#1" + } + ] + } + ], + "id": "85", + "module": "SD_WS", + "name": "TFA 30.3251.10 Windsensor" + }, + { + "data": [ + { + "comment": "Thermo-hygro sensor for base station 35.1140.01 (ID 37,61 additionally)", + "dispatch_repeats": "1", + "dmsg": "W89#F012333E01", + "internals": { + "DEF": "SD_WS_89_TH_2", + "NAME": "SD_WS_89_TH_2" + }, + "readings": { + "batteryState": "ok", + "channel": "2", + "humidity": "62", + "sendmode": "auto", + "state": "T: 6.3 H: 62", + "temperature": "6.3", + "type": "TFA 30.3221.02" + }, + "rmsg": "MU;P0=22960;P1=-893;P2=775;P3=409;P4=-296;P5=182;P6=-513;D=01212121343434345656565656565634565634565656343456563434565634343434345656565656565656342121212134343434565656565656563456563456565634345656343456563434343434565656565656565634212121213434343456565656565656345656345656563434565634345656343434343456565656;CP=5;R=22;O;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "89", + "module": "SD_WS", + "name": "TFA 30.3221.02" + }, + { + "data": [ + { + "comment": "Temp sensor Id: 0C (ID 63 additionally)", + "dmsg": "W94#0D830661B366C", + "internals": { + "DEF": "SD_WS_94_T", + "NAME": "SD_WS_94_T" + }, + "readings": { + "state": "T: -14.6", + "temperature": "-14.6", + "type": "Atech" + }, + "rmsg": "MU;P0=-32001;P1=1525;P2=-303;P3=-7612;P4=-2008;D=01212121212121213141414141212141212141414141412121414141414121214141212141414141212141212141412121412121414121214121;CP=1;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Temp sensor Id: 0C (ID 63 additionally)", + "dmsg": "W94#0D830018CCC", + "internals": { + "DEF": "SD_WS_94_T", + "NAME": "SD_WS_94_T" + }, + "readings": { + "state": "T: -0.4", + "temperature": "-0.4", + "type": "Atech" + }, + "rmsg": "MU;P0=-32001;P1=1533;P2=-297;P3=-7612;P4=-2005;D=0121212121212121314141414121214121214141414141212141414141414141414141412121414141212141412121414121;CP=1;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Temp sensor Id: 0C (ID 63 additionally)", + "dmsg": "W94#0D8000336CC", + "internals": { + "DEF": "SD_WS_94_T", + "NAME": "SD_WS_94_T" + }, + "readings": { + "state": "T: 0.2", + "temperature": "0.2", + "type": "Atech" + }, + "rmsg": "MU;P0=-32001;P1=1532;P2=-299;P3=-7608;P4=-2005;D=0121212121212121314141414121214121214141414141414141414141414141414141212141412121412121412121414121;CP=1;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "Temp sensor Id: 0C (ID 63 additionally)", + "dispatch_repeats": "1", + "dmsg": "W94#0D80180CDB6C", + "internals": { + "DEF": "SD_WS_94_T", + "NAME": "SD_WS_94_T" + }, + "readings": { + "state": "T: 10.2", + "temperature": "10.2", + "type": "Atech" + }, + "rmsg": "MU;P0=-31292;P1=1529;P2=-300;P3=-7610;P4=-2009;D=012121212121212131414141412121412121414141414141414141412121414141414141412121414121214121214121214121214121012121212121212131414141412121412121414141414141414141412121414141414141412121414121214121214121214121214121;CP=1;", + "tests": [ + { + "comment": "#3" + } + ] + }, + { + "comment": "Temp sensor Id: 0C (ID 63 additionally)", + "dispatch_repeats": "1", + "dmsg": "W94#0D8031B60C6C", + "internals": { + "DEF": "SD_WS_94_T", + "NAME": "SD_WS_94_T" + }, + "readings": { + "state": "T: 27", + "temperature": "27", + "type": "Atech" + }, + "rmsg": "MU;P0=-31290;P1=1533;P2=-297;P3=-7608;P4=-2006;D=012121212121212131414141412121412121414141414141414141212141414121214121214121214141414141212141414121214121012121212121212131414141412121412121414141414141414141212141414121214121214121214141414141212141414121214121;CP=1;", + "tests": [ + { + "comment": "#4" + } + ] + } + ], + "id": "94", + "module": "SD_WS", + "name": "Atech" + }, + { + "data": [ + { + "comment": "BBQ temperature sensor", + "dmsg": "W106#2632DC", + "internals": { + "DEF": "SD_WS_106_T", + "NAME": "SD_WS_106_T" + }, + "readings": { + "state": "T: 22.6", + "temperature": "22.6", + "type": "GT-TMBBQ-01" + }, + "rmsg": "MS;P0=525;P1=-2051;P3=-8905;P4=-4062;D=0301010401010404010101040401010401040401040404;CP=0;SP=3;R=35;e;b=2;m0;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "BBQ temperature sensor", + "dmsg": "W106#9A57A8", + "internals": { + "DEF": "SD_WS_106_T", + "NAME": "SD_WS_106_T" + }, + "readings": { + "state": "T: 88.1", + "temperature": "88.1", + "type": "GT-TMBBQ-01" + }, + "rmsg": "MS;P1=-8514;P2=488;P3=-4075;P4=-2068;D=2123242423232423242423242324232323232423242324;CP=2;SP=1;R=31;e;b=70;s=4;m0;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "BBQ temperature sensor", + "dmsg": "W106#9A5D20", + "internals": { + "DEF": "SD_WS_106_T", + "NAME": "SD_WS_106_T" + }, + "readings": { + "state": "T: 97.8", + "temperature": "97.8", + "type": "GT-TMBBQ-01" + }, + "rmsg": "MS;P1=-9144;P2=469;P3=-4101;P4=-2099;D=2123242423232423242423242323232423242423242424;CP=2;SP=1;R=58;O;b=70;s=4;m0;", + "tests": [ + { + "comment": "#2" + } + ] + } + ], + "id": "106", + "module": "SD_WS", + "name": "GT-TMBBQ-01s" + }, + { + "data": [ + { + "comment": "Fine Offset WH51, ECOWITT WH51, MISOL/1, Froggit DP100 Soil Moisture Sensor", + "dmsg": "W107#5100C6BF107F1FF8BBFFFFFFEE22", + "internals": { + "DEF": "SD_WS_107_H", + "NAME": "SD_WS_107_H" + }, + "readings": { + "adc": "187", + "batteryVoltage": "1.6", + "humidity": "31", + "state": " H: 31", + "type": "WH51, DP100, MISOL/1" + }, + "rmsg": "MN;D=5100C6BF107F1FF8BBFFFFFFEE22;R=14;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Fine Offset WH51, ECOWITT WH51, MISOL/1, Froggit DP100 Soil Moisture Sensor", + "dmsg": "W107#51006B586E7F24F8D2FFFFFF3C288", + "internals": { + "DEF": "SD_WS_107_H", + "NAME": "SD_WS_107_H" + }, + "readings": { + "adc": "210", + "batteryVoltage": "1.4", + "humidity": "36", + "state": " H: 36", + "type": "WH51, DP100, MISOL/1" + }, + "rmsg": "MN;D=51006B586E7F24F8D2FFFFFF3C288;R=14;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Fine Offset WH51, ECOWITT WH51, MISOL/1, Froggit DP100 Soil Moisture Sensor", + "dmsg": "W107#510D48E6107F1B00AA00000010F0", + "internals": { + "DEF": "SD_WS_107_H", + "NAME": "SD_WS_107_H" + }, + "readings": { + "adc": "170", + "batteryVoltage": "1.6", + "humidity": "27", + "state": " H: 27", + "type": "WH51, DP100, MISOL/1" + }, + "rmsg": "MN;D=510D48E6107F1B00AA00000010F0;R=53;", + "tests": [ + { + "comment": "#3" + } + ] + }, + { + "comment": "Fine Offset WH51, ECOWITT WH51, MISOL/1, Froggit DP100 Soil Moisture Sensor - wrong checksum", + "dmsg": "W107#510D48E6107F1B00AA00000010F1", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#4" + } + ] + }, + { + "comment": "Fine Offset WH51, ECOWITT WH51, MISOL/1, Froggit DP100 Soil Moisture Sensor - wrong CRC", + "dmsg": "W107#510D48E6107F1B00AA00000011F0", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#5" + } + ] + } + ], + "id": "107", + "module": "SD_WS", + "name": "WH51" + }, + { + "data": [ + { + "comment": "BRESSER 5-in-1 Weather Center, Bresser Professional Rain Gauge", + "dmsg": "W108#AD8008700810070228443500", + "internals": { + "DEF": "SD_WS_108", + "NAME": "SD_WS_108" + }, + "readings": { + "batteryState": "ok", + "humidity": "28", + "rain": "354.4", + "state": "T: 20.7 H: 28 W: 0.8 R: 354.4", + "temperature": "20.7", + "type": "Bresser_5in1, Bresser_rain_gauge, Fody_E42, Fody_E43", + "windDirectionDegree": "157.5", + "windDirectionText": "SSE", + "windGust": "0.8", + "windSpeed": "0.8" + }, + "rmsg": "MN;D=E7527FF78FF7EFF8FDD7BBCAFF18AD80087008100702284435000002;R=213;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "BRESSER 5-in-1 Weather Center, Bresser Professional Rain Gauge", + "dmsg": "W108#AD8000D00010280078443508", + "internals": { + "DEF": "SD_WS_108", + "NAME": "SD_WS_108" + }, + "readings": { + "batteryState": "ok", + "humidity": "78", + "rain": "354.4", + "state": "T: -2.8 H: 78 W: 0 R: 354.4", + "temperature": "-2.8", + "type": "Bresser_5in1, Bresser_rain_gauge, Fody_E42, Fody_E43", + "windDirectionDegree": "292.5", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=E8527FFF2FFFEFD7FF87BBCAF717AD8000D000102800784435080000;R=214;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "BRESSER 5-in-1 Weather Center, Bresser Professional Rain Gauge", + "dmsg": "W108#AD8014F01310800088483600", + "internals": { + "DEF": "SD_WS_108", + "NAME": "SD_WS_108" + }, + "readings": { + "batteryState": "ok", + "humidity": "88", + "rain": "364.8", + "state": "T: 8 H: 88 W: 1.3 R: 364.8", + "temperature": "8", + "type": "Bresser_5in1, Bresser_rain_gauge, Fody_E42, Fody_E43", + "windDirectionDegree": "337.5", + "windDirectionText": "NNW", + "windGust": "2", + "windSpeed": "1.3" + }, + "rmsg": "MN;D=E6527FEB0FECEF7FFF77B7C9FF19AD8014F013108000884836000003;R=211;", + "tests": [ + { + "comment": "#2" + } + ] + } + ], + "id": "108", + "module": "SD_WS", + "name": "BRESSER 5in1" + }, + { + "data": [ + { + "comment": "Weather station with rain gauge", + "dmsg": "W110#9C1B060001EA05AD4", + "internals": { + "DEF": "SD_WS_110_TR", + "NAME": "SD_WS_110_TR" + }, + "readings": { + "batteryState": "ok", + "rain": "26.6", + "rawRainCounter": "266", + "sendCounter": "3", + "state": "T: 16.3 R: 26.6", + "temperature": "16.3", + "type": "ADE WS1907" + }, + "rmsg": "MU;P0=970;P1=-112;P2=516;P3=-984;P4=2577;P5=-2692;P6=7350;D=01234343450503450503434343434505034343434343434343434343434343434505050503450345034343434343450345050345034505034503456503434505050343434343450503450503434343434505034343434343434343434343434343434505050503450345034343434343450345050345034505034503456503;CP=0;R=12;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Weather station with rain gauge", + "dmsg": "W110#9C1B041E03A705884", + "internals": { + "DEF": "SD_WS_110_TR", + "NAME": "SD_WS_110_TR" + }, + "readings": { + "batteryState": "ok", + "rain": "80.8", + "rawRainCounter": "808", + "sendCounter": "2", + "state": "T: 12.6 R: 80.8", + "temperature": "12.6", + "type": "ADE WS1907" + }, + "rmsg": "MU;P0=7344;P1=384;P2=-31380;P3=272;P4=-972;P5=2581;P6=-2689;P7=990;D=12345454545676745676745454545456745454545456767676745454545454545676767456745456767674545454545674567674545456745454545606745456767674545454545676745676745454545456745454545456767676745454545454545676767456745456767674545454545674567674545456745454545606;CP=7;R=19;O;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Weather station with rain gauge", + "dmsg": "W110#9C1B042B039805864", + "internals": { + "DEF": "SD_WS_110_TR", + "NAME": "SD_WS_110_TR" + }, + "readings": { + "batteryState": "ok", + "rain": "82.1", + "rawRainCounter": "821", + "sendCounter": "2", + "state": "T: 11.8 R: 82.1", + "temperature": "11.8", + "type": "ADE WS1907" + }, + "rmsg": "MU;P0=-5332;P1=6864;P2=-2678;P3=994;P4=-977;P5=2693;D=01234545232323454545454523234523234545454545234545454523452345232345454545454523232345452323454545454545454523452323454545452323454521234545232323454545454523234523234545454545234545454523452345232345454545454523232345452323454545454545454523452323454545;CP=3;R=248;O;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "Weather station with rain gauge - wrong checksum", + "dmsg": "W110#9C1B042B039805874", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + } + ], + "id": "110", + "module": "SD_WS", + "name": "ADE WS 1907" + }, + { + "data": [ + { + "comment": "Water tank level monitor with temperature", + "dmsg": "W111#5F5B8860F110C400C9", + "internals": { + "DEF": "SD_WS_111_TL", + "NAME": "SD_WS_111_TL" + }, + "readings": { + "distance": "111", + "state": "T: 16.8 D: 111", + "temperature": "16.8", + "type": "TS-FT002" + }, + "rmsg": "MU;P0=-21110;P1=484;P2=-971;P3=-488;D=01213121212121213121312121312121213131312131313131212131313131312121212131313121313131213131313121213131312131313131313131313131212131312131312101213121212121213121312121312121213131312131313131212131313131312121212131313121313131213131313121213131312131;CP=1;R=26;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Water tank level monitor with temperature", + "dmsg": "W111#5F5B8840F170240069", + "internals": { + "DEF": "SD_WS_111_TL", + "NAME": "SD_WS_111_TL" + }, + "readings": { + "distance": "47", + "state": "T: 19 D: 47", + "temperature": "19", + "type": "TS-FT002" + }, + "rmsg": "MU;P0=-31628;P1=469;P2=-980;P3=-499;P4=-22684;D=01213121212121213121312121312121213131312131313131213131313131312121212131313121312121213131313131312131312131313131313131313131312121312131312141213121212121213121312121312121213131312131313131213131313131312121212131313121312121213131313131312131312131;CP=1;R=38;O;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Water tank level monitor with temperature", + "dmsg": "W111#5F5B8840F110A40089", + "internals": { + "DEF": "SD_WS_111_TL", + "NAME": "SD_WS_111_TL" + }, + "readings": { + "distance": "47", + "state": "T: 20 D: 47", + "temperature": "20", + "type": "TS-FT002" + }, + "rmsg": "MU;P0=-5980;P1=464;P2=-988;P3=-511;P4=-22660;D=01213121212121213121312121312121213131312131313131213131313131312121212131313121313131213131313121312131312131313131313131313131213131312131312141213121212121213121312121312121213131312131313131213131313131312121212131313121313131213131313121312131312131;CP=1;R=38;O;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "Water tank level monitor with temperature - wrong XOR", + "dmsg": "W111#5F5B8840F110A4008A", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + } + ], + "id": "111", + "module": "SD_WS", + "name": "TS-FT002" + }, + { + "data": [ + { + "comment": "Wireless Grill Thermometer", + "dmsg": "W113#2F06E896D14E", + "internals": { + "DEF": "SD_WS_113_T", + "NAME": "SD_WS_113_T" + }, + "readings": { + "state": "T: 203 T2: 300", + "temperature": "203", + "temperature2": "300", + "type": "GFGT_433_B1" + }, + "rmsg": "MS;P1=-262;P2=237;P3=-760;P6=-2972;P7=721;D=26232371237171717123232323237171237171712371232323712323712371712371712371232323712371232371717123;CP=2;SP=6;R=1;O;m2;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Wireless Grill Thermometer", + "dmsg": "W113#2F06E348D102", + "internals": { + "DEF": "SD_WS_113_T", + "NAME": "SD_WS_113_T" + }, + "readings": { + "state": "T: 201 T2: 257", + "temperature": "201", + "temperature2": "257", + "type": "GFGT_433_B1" + }, + "rmsg": "MS;P2=-754;P3=247;P5=-2996;P6=718;P7=-272;D=35323267326767676732323232326767326767673232326767326732326732323267673267323232673232323232326732;CP=3;SP=5;R=3;O;m2;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Wireless Grill Thermometer", + "dmsg": "W113#2F00A5AED106", + "internals": { + "DEF": "SD_WS_113_T", + "NAME": "SD_WS_113_T" + }, + "readings": { + "state": "T: 24 T2: 29", + "temperature": "24", + "temperature2": "29", + "type": "GFGT_433_B1" + }, + "rmsg": "MS;P1=-761;P2=249;P4=-3005;P5=718;P6=-270;D=24212156215656565621212121212121215621562121562156562156215656562156562156212121562121212121565621;CP=2;SP=4;R=34;O;m2;", + "tests": [ + { + "comment": "#2" + } + ] + } + ], + "id": "113", + "module": "SD_WS", + "name": "GFGT_433_B1" + }, + { + "data": [ + { + "comment": "BRESSER 3-in-1", + "dmsg": "W115#C898BE40041218FF88FF0008017E88FFF04F0000000000000000", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "88", + "state": "T: -1.7 H: 88 W: 0.7", + "temperature": "-1.7", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "0", + "windDirectionText": "N", + "windGust": "0.7", + "windSpeed": "0.7" + }, + "rmsg": "MN;D=C898BE40041218FF88FF0008017E88FFF04F0000000000000000;R=190;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "115", + "module": "SD_WS", + "name": "BRESSER 3-in-1" + }, + { + "data": [ + { + "comment": "BRESSER 6-in-1 Weather Center, Bresser new 5-in-1 sensors 7002550", + "dmsg": "W115#3BF120B00C1618FF77FF0458152293FFF06B0000", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryState": "ok", + "channel": "0", + "humidity": "93", + "state": "T: 15.2 H: 93 W: 0.8", + "temperature": "15.2", + "type": "Bresser_6in1, new Bresser_5in1", + "windDirectionDegree": "45", + "windDirectionText": "NE", + "windGust": "0.8", + "windSpeed": "0.8" + }, + "rmsg": "MN;D=3BF120B00C1618FF77FF0458152293FFF06B0000;R=242;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "BRESSER 6-in-1 Weather Center, Bresser new 5-in-1 sensors 7002550", + "dmsg": "W115#1E6C20B00C1618FF99FF0458FFFFA9FF015B0000", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "channel": "0", + "rain": "5.6", + "state": "W: 0.6 R: 5.6", + "type": "Bresser_6in1, new Bresser_5in1", + "windDirectionDegree": "45", + "windDirectionText": "NE", + "windGust": "0.6", + "windSpeed": "0.6" + }, + "rmsg": "MN;D=1E6C20B00C1618FF99FF0458FFFFA9FF015B0000;R=241;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "BRESSER 6-in-1", + "dmsg": "W115#9104143025BE18FFFFFF2928925A97FFF0000000000000000003", + "internals": { + "DEF": "SD_WS_115_0", + "NAME": "SD_WS_115_0" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "channel": "0", + "humidity": "97", + "state": "T: -7.5 H: 97 W: 0", + "temperature": "-7.5", + "type": "Bresser_6in1, new Bresser_5in1", + "uv": "0", + "windDirectionDegree": "292", + "windDirectionText": "WNW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=9104143025BE18FFFFFF2928925A97FFF0000000000000000003;R=189;", + "tests": [ + { + "comment": "#2" + } + ] + } + ], + "id": "115", + "module": "SD_WS", + "name": "BRESSER 6-in-1" + }, + { + "data": [ + { + "comment": "Bresser_6in1 Thermo-/hygro sensor", + "dmsg": "W115#6CD6197005FD2900000000002126630000A1FFFF07000000000000000000", + "internals": { + "DEF": "SD_WS_115_21", + "NAME": "SD_WS_115_21" + }, + "readings": { + "batteryState": "ok", + "channel": "21", + "humidity": "63", + "state": "T: 21.2 H: 63", + "temperature": "21.2", + "type": "Bresser_6in1, new Bresser_5in1" + }, + "rmsg": "MN;D=6CD6197005FD2900000000002126630000A1FFFF07000000000000000000;R=28;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "115", + "module": "SD_WS", + "name": "BRESSER 6-in-1" + }, + { + "data": [ + { + "comment": "Bresser Explore Scientific SM60020 Soil moisture Sensor", + "dmsg": "W115#F16E187000E347FFFFFF0000252216FFF004000", + "internals": { + "DEF": "SD_WS_115_47", + "NAME": "SD_WS_115_47" + }, + "readings": { + "batteryState": "ok", + "channel": "47", + "humidity": "99", + "state": "T: 25.2 H: 99", + "temperature": "25.2", + "type": "Bresser_6in1, new Bresser_5in1" + }, + "rmsg": "MN;D=F16E187000E347FFFFFF0000252216FFF004000;R=242;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "115", + "module": "SD_WS", + "name": "Bresser Explore Scientific" + }, + { + "data": [ + { + "comment": "Thunder and lightning sensor Fine Offset WH57, aka Froggit DP60, aka Ambient Weather WH31L", + "dmsg": "W116#5780C655051401C4D0", + "internals": { + "DEF": "SD_WS_116", + "NAME": "SD_WS_116" + }, + "readings": { + "batteryPercent": "100", + "count": "1", + "distance": "20", + "identified": "lightning", + "state": "I: lightning D: 20", + "type": "WH57, DP60, WH31L" + }, + "rmsg": "MN;D=5780C655051401C4D0;R=37;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Thunder and lightning sensor Fine Offset WH57, aka Froggit DP60, aka Ambient Weather WH31L", + "dmsg": "W116#5740C655053F0A7272", + "internals": { + "DEF": "SD_WS_116", + "NAME": "SD_WS_116" + }, + "readings": { + "batteryPercent": "100", + "count": "10", + "distance": "63", + "identified": "disturbance", + "state": "I: disturbance D: 63", + "type": "WH57, DP60, WH31L" + }, + "rmsg": "MN;D=5740C655053F0A7272;R=39;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Thunder and lightning sensor Fine Offset WH57, aka Froggit DP60, aka Ambient Weather WH31L - wrong checksum", + "dmsg": "W116#5740C655053F0A7273", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + }, + { + "comment": "Thunder and lightning sensor Fine Offset WH57, aka Froggit DP60, aka Ambient Weather WH31L - wrong CRC", + "dmsg": "W116#5740C655053F0A7372", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + } + ], + "id": "116", + "module": "SD_WS", + "name": "WH57" + }, + { + "data": [ + { + "comment": "BRESSER 7-in-1 Weather Center", + "dmsg": "W117#56820C5F2760B2000000000084001270870066760000000000AAAAAA", + "internals": { + "DEF": "SD_WS_117", + "NAME": "SD_WS_117" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "brightness": "6.676", + "humidity": "87", + "rain": "8.4", + "state": "T: 12.7 H: 87 W: 0 R: 8.4 B: 6.676", + "temperature": "12.7", + "type": "Bresser_7in1", + "uv": "0", + "windDirectionDegree": "276", + "windDirectionText": "W", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=FC28A6F58DCA18AAAAAAAAAA2EAAB8DA2DAACCDCAAAAAAAAAA000000;R=29;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "BRESSER 7-in-1 Weather Center", + "dmsg": "W117#E76E0C5F1920BA000000000000001310880003600000000000AAAAAA", + "internals": { + "DEF": "SD_WS_117", + "NAME": "SD_WS_117" + }, + "readings": { + "batteryChanged": "0", + "batteryState": "ok", + "brightness": "0.36", + "humidity": "88", + "rain": "0", + "state": "T: 13.1 H: 88 W: 0 R: 0 B: 0.36", + "temperature": "13.1", + "type": "Bresser_7in1", + "uv": "0", + "windDirectionDegree": "192", + "windDirectionText": "SSW", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=4DC4A6F5B38A10AAAAAAAAAAAAAAB9BA22AAA9CAAAAAAAAAAA000000;R=15;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "BRESSER 7-in-1 Weather Center", + "dmsg": "W117#A65A0C5F1320BA000000000000001016940011560000000000AAAAAA", + "internals": { + "DEF": "SD_WS_117", + "NAME": "SD_WS_117" + }, + "readings": { + "batteryChanged": "0", + "batteryState": "low", + "brightness": "1.156", + "humidity": "94", + "rain": "0", + "state": "T: 10.1 H: 94 W: 0 R: 0 B: 1.156", + "temperature": "10.1", + "type": "Bresser_7in1", + "uv": "0", + "windDirectionDegree": "132", + "windDirectionText": "SE", + "windGust": "0", + "windSpeed": "0" + }, + "rmsg": "MN;D=0CF0A6F5B98A10AAAAAAAAAAAAAABABC3EAABBFCAAAAAAAAAA000000;R=28;", + "tests": [ + { + "comment": "#2" + } + ] + } + ], + "id": "117", + "module": "SD_WS", + "name": "BRESSER 7-in-1" + }, + { + "data": [ + { + "comment": "BRESSER PM2.5/10 air quality meter", + "dmsg": "W117#49C8CAC2176023178000700080009004400530054039600000AA", + "internals": { + "DEF": "SD_WS_117_1", + "NAME": "SD_WS_117_1" + }, + "readings": { + "batteryChanged": "1", + "batteryVoltage": 3.96, + "channel": 1, + "pm_10": 9, + "pm_2_5": 8, + "state": "PM2.5: 8 PM10: 9", + "type": "Bresser_7in1" + }, + "rmsg": "MN;D=E3626068BDCA89BD2AAADAAA2AAA3AAEEAAF9AAFEA93CAAAAA00;R=10;", + "tests": [ + { + "attributes": {}, + "comment": "#0" + } + ] + }, + { + "comment": "BRESSER PM2.5/10 air quality meter", + "dmsg": "W117#065CCAC2176023178058806290636099999999999939600000AA", + "internals": { + "DEF": "SD_WS_117_1", + "NAME": "SD_WS_117_1" + }, + "readings": { + "batteryChanged": "1", + "batteryVoltage": 3.96, + "channel": 1, + "pm_10": 636, + "pm_2_5": 629, + "state": "PM2.5: 629 PM10: 636", + "type": "Bresser_7in1" + }, + "rmsg": "MN;D=ACF66068BDCA89BD2AF22AC83AC9CA33333333333393CAAAAA00;R=9;", + "tests": [ + { + "attributes": {}, + "comment": "#1" + } + ] + } + ], + "id": 117, + "module": "SD_WS", + "name": "BRESSER PM2.5/10" + }, + { + "data": [ + { + "comment": "Weather station with 30.3151 (T/H-transmitter), 30.3152 (rain gauge), 30.3153 (anemometer)", + "dmsg": "W120#FEAB049EA804080C53AC", + "internals": { + "DEF": "SD_WS_120", + "NAME": "SD_WS_120" + }, + "readings": { + "batteryState": "ok", + "humidity": "84", + "rain": "473.1", + "rawRainCounter": "1577", + "state": "T: 19.1 H: 84 W: 0.7 R: 473.1", + "temperature": "19.1", + "type": "TFA_35.1077", + "windGust": "1.3", + "windSpeed": "0.7" + }, + "rmsg": "MU;P0=-6544;P1=486;P2=-987;P3=1451;D=01212121212121232123212321232121232323232321232321232321212121232123212321232323232323232321232323232323212323232323232321212323232123212323212121232123212123;CP=1;R=51;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Weather station with 30.3151 (T/H-transmitter), 30.3152 (rain gauge), 30.3153 (anemometer)", + "dmsg": "W120#FEAB04D85602040DD0F6", + "internals": { + "DEF": "SD_WS_120", + "NAME": "SD_WS_120" + }, + "readings": { + "batteryState": "ok", + "humidity": "43", + "rain": "530.4", + "rawRainCounter": "1768", + "state": "T: 22 H: 43 W: 0.3 R: 530.4", + "temperature": "22", + "type": "TFA_35.1077", + "windGust": "0.7", + "windSpeed": "0.3" + }, + "rmsg": "MU;P0=-15856;P1=480;P2=-981;P3=1460;D=01212121212121232123212321232121232323232321232321212321212323232321232123212123232323232323212323232323232123232323232321212321212123212323232321212121232121;CP=1;R=47;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Weather station with 30.3151 (T/H-transmitter), 30.3152 (rain gauge), 30.3153 (anemometer)", + "dmsg": "W120#FEDFD52C040444920614", + "internals": { + "DEF": "SD_WS_120", + "NAME": "SD_WS_120" + }, + "readings": { + "batteryState": "ok", + "dcf": "2022-09-03 16:02:02", + "state": "", + "type": "TFA_35.1077" + }, + "rmsg": "MU;P0=-13168;P1=469;P2=-1000;P3=1450;D=01212121212121232121232121212121212123212321232123232123212123232323232323212323232323232321232323212323232123232123232123232123232323232321212323232321232123;CP=1;R=79;", + "tests": [ + { + "comment": "#2" + } + ] + }, + { + "comment": "Weather station with 30.3151 (T/H-transmitter), 30.3152 (rain gauge), 30.3153 (anemometer) - wrong CRC", + "dmsg": "W120#FEDFD52C040444920615", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#2" + } + ] + } + ], + "id": "120", + "module": "SD_WS", + "name": "TFA 35.1077.54.S2" + }, + { + "data": [ + { + "comment": "Wireless Grill-, Meat-, Roasting-Thermometer with 4 Temperature Sensors", + "dmsg": "W122#926301360136014001680000B88", + "internals": { + "DEF": "SD_WS_122_T", + "NAME": "SD_WS_122_T" + }, + "readings": { + "batteryState": "ok", + "state": "T: 36 T2: 32 T3: 31 T4: 31", + "temperature": "36", + "temperature2": "32", + "temperature3": "31", + "temperature4": "31", + "transmitter": "on", + "type": "TM40" + }, + "rmsg": "MU;P0=3412;P1=-1029;P2=1043;P3=4706;P4=-2986;P5=549;P6=-1510;P7=-562;D=01212121212121213456575756575756575756565757575656575757575757575657575656575656575757575757575756575756565756565757575757575757565756575757575757575757575757575657565657565757575757575757575757575757575757575756575656565757575621212121212121213456575756;CP=5;R=2;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Wireless Grill-, Meat-, Roasting-Thermometer with 4 Temperature Sensors", + "dmsg": "W122#926300DC00DC00DC033E0000DE8", + "internals": { + "DEF": "SD_WS_122_T", + "NAME": "SD_WS_122_T" + }, + "readings": { + "batteryState": "ok", + "state": "T: 83 T2: 22 T3: 22 T4: 22", + "temperature": "83", + "temperature2": "22", + "temperature3": "22", + "temperature4": "22", + "transmitter": "on", + "type": "TM40" + }, + "rmsg": "MU;P0=11276;P1=-1039;P2=1034;P3=4704;P4=-2990;P5=543;P6=-1537;P7=-559;D=01212121212121213456575756575756575756565757575656575757575757575756565756565657575757575757575757565657565656575757575757575757575656575656565757575757575757565657575656565656575757575757575757575757575757575756565756565656575621212121212121213456575756;CP=5;R=12;O;", + "tests": [ + { + "comment": "#1" + } + ] + } + ], + "id": "122", + "module": "SD_WS", + "name": "Temola TM40" + }, + { + "data": [ + { + "comment": "Inkbird IBS-P01R Pool Thermometer, Inkbird ITH-20R", + "dmsg": "W123#D3910F800301005A0655FA001405140535F6", + "internals": { + "DEF": "SD_WS_123_T", + "NAME": "SD_WS_123_T" + }, + "readings": { + "batteryChanged": "0", + "batteryPercent": 90, + "state": "T: 25", + "temperature": 25, + "type": "IBS-P01R, ITH-20R" + }, + "rmsg": "MN;D=D3910F800301005A0655FA001405140535F6;R=10;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Inkbird IBS-P01R Pool Thermometer, Inkbird ITH-20R - wrong CRC", + "dmsg": "W123#D3910F800301005A0655FA001405140535F7", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#1" + } + ] + } + ], + "id": "123", + "module": "SD_WS", + "name": "Inkbird IBS-P01R" + }, + { + "data": [ + { + "comment": "Inkbird IBS-P01R Pool Thermometer, Inkbird ITH-20R", + "dmsg": "W123#D3910F00010301207E43FE0014055802772A", + "internals": { + "DEF": "SD_WS_123_T", + "NAME": "SD_WS_123_T" + }, + "readings": { + "batteryChanged": "1", + "batteryPercent": 32, + "humidity": 60, + "state": "T: 25.4 H: 60", + "temperature": 25.4, + "type": "IBS-P01R, ITH-20R" + }, + "rmsg": "MN;D=D3910F00010301207E43FE0014055802772A;R=232;", + "tests": [ + { + "comment": "#0" + } + ] + } + ], + "id": "123", + "module": "SD_WS", + "name": "Inkbird ITH-20R" + }, + { + "data": [ + { + "comment": "Ecowitt WH31 Temp Hum sensor channel 1", + "dmsg": "W125#300282623704516C000200", + "rmsg": "MN;D=300282623704516C000200;R=63;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_125_TH_1", + "NAME": "SD_WS_125_TH_1" + }, + "readings": { + "humidity": 55, + "state": "T: 21.0 H: 55", + "temperature": "21.0", + "type": "WH31e, WH31b, DP50, DNT000005", + "batteryState": "ok", + "channel": 1 + }, + "comment": "#0" + } + ] + }, + { + "comment": "Ecowitt WH31 Temp Hum sensor channel 2", + "dmsg": "W125#300292373CDA116C000200", + "rmsg": "MN;D=300292373CDA116C000200;R=63;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_125_TH_2", + "NAME": "SD_WS_125_TH_2" + }, + "readings": { + "humidity": 60, + "state": "T: 16.7 H: 60", + "temperature": "16.7", + "type": "WH31e, WH31b, DP50, DNT000005", + "batteryState": "ok", + "channel": 2 + }, + "comment": "#1" + } + ] + }, + { + "comment": "Ecowitt WH31 Temp Hum sensor channel 3", + "dmsg": "W125#30E0A1C634FEA96C000200", + "rmsg": "MN;D=30E0A1C634FEA96C000200;R=63;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_125_TH_3", + "NAME": "SD_WS_125_TH_3" + }, + "readings": { + "humidity": 52, + "state": "T: 5.4 H: 52", + "temperature": "5.4", + "type": "WH31e, WH31b, DP50, DNT000005", + "batteryState": "ok", + "channel": 3 + }, + "comment": "#2" + } + ] + }, + { + "comment": "DNT000005 Temp Hum sensor DCF message", + "dmsg": "W125#52971025010910492909B3", + "rmsg": "MN;D=52971025010910492909B3;R=33;A=2;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_125_DCF", + "NAME": "SD_WS_125_DCF" + }, + "readings": { + "dcf": "2025-01-09 10:49:29", + "state": "97: 2025-01-09 10:49:29", + "type": "WH31e, WH31b, DP50, DNT000005" + }, + "comment": "#3" + } + ] + }, + { + "comment": "Ecowitt WH31 Temp Hum sensor - wrong checksum ", + "dmsg": "W125#300282623704526C000200", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#4" + } + ] + }, + { + "comment": "Ecowitt WH31 Temp Hum sensor - wrong CRC ", + "dmsg": "W125#302282623705616C000200", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#5" + } + ] + } + ], + "id": "125", + "module": "SD_WS", + "name": "Fine Offset | Ambient Weather WH31E Thermo-Hygrometer Sensor" + }, + { + "data": [ + { + "comment": "Ecowitt WH40 rain gauge (14 byte)", + "dmsg": "W126#40011CDF8F0000976220A6802801", + "rmsg": "MN;D=40011CDF8F0000976220A6802801;R=61;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_126_R", + "NAME": "SD_WS_126_R" + }, + "readings": { + "state": "R: 0", + "rawRainCounter": 0, + "rain_total": 0, + "batteryVoltage": 1.5, + "batteryState": "ok", + "type": "WH40" + }, + "comment": "#0" + } + ] + }, + { + "comment": "Ecowitt WH40 rain gauge (14 byte)", + "dmsg": "W126#40013E3C90005AB55AA0A0800408", + "rmsg": "MN;D=40013E3C90005AB55AA0A0800408;R=61;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_126_R", + "NAME": "SD_WS_126_R" + }, + "readings": { + "state": "R: 9", + "batteryVoltage": 1.6, + "batteryState": "ok", + "rain_total": 9, + "rawRainCounter": 90, + "type": "WH40" + }, + "comment": "#1" + } + ] + }, + { + "comment": "Ecowitt WH40 rain gauge (11 byte)", + "dmsg": "W126#40013E3C900000105BA02A", + "rmsg": "MN;D=40013E3C900000105BA02A;R=61;", + "tests": [ + { + "internals": { + "DEF": "SD_WS_126_R", + "NAME": "SD_WS_126_R" + }, + "readings": { + "state": "R: 0", + "rain_total": 0, + "rawRainCounter": 0, + "type": "WH40" + }, + "comment": "#2" + } + ] + }, + { + "comment": "Ecowitt WH40 rain gauge - wrong checksum ", + "dmsg": "W126#40013E3C900000105CA02A", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#3" + } + ] + }, + { + "comment": "Ecowitt WH40 rain gauge - wrong CRC ", + "dmsg": "W126#40013E3C900000115BA02A", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#4" + } + ] + } + ], + "id": "126", + "module": "SD_WS", + "name": "Fine Offset | Ambient Weather WH40 rain gauge" + }, + { + "data": [ + { + "comment": "Sainlogic weather stations", + "dmsg": "W129#FFD4C0E002031B000084C024FFFBFB06", + "internals": { + "DEF": "SD_WS_129", + "NAME": "SD_WS_129" + }, + "readings": { + "batteryState": "ok", + "humidity": 36, + "rain": 0, + "state": "T: 27.6 H: 36 W: 0.2 R: 0", + "temperature": 27.6, + "type": "FT-0835, FT0300, FT-0310, FT020T, WS019T", + "windDirectionDegree": 27, + "windDirectionText": "NNE", + "windGust": "0.3", + "windSpeed": "0.2" + }, + "rmsg": "MC;LL=-987;LH=970;SL=-506;SH=473;D=002B3F1FFDFCE4FFFF7B3FDB000404F9;C=489;L=128;R=60;", + "tests": [ + { + "attributes": {}, + "comment": "#0" + } + ] + }, + { + "comment": "Sainlogic weather stations", + "dmsg": "W129#FFD4CBD80D11A73C06841B5AC1A14864", + "internals": { + "DEF": "SD_WS_129", + "NAME": "SD_WS_129" + }, + "readings": { + "batteryState": "low", + "brightness": 115105, + "humidity": 90, + "rain": 1536.6, + "state": "T: 18.4 H: 90 W: 1.3 R: 1536.6 B: 115105", + "temperature": 18.4, + "type": "FT-0835, FT0300, FT-0310, FT020T, WS019T", + "uv": 7.2, + "windDirectionDegree": 167, + "windDirectionText": "SSE", + "windGust": "1.7", + "windSpeed": "1.3" + }, + "rmsg": "MC;LL=-1036;LH=918;SL=-533;SH=435;D=002B3427F2EE58C3F97BE4A53E5EB79B;C=486;L=128;R=212;", + "tests": [ + { + "attributes": {}, + "comment": "#1" + } + ] + }, + { + "comment": "Sainlogic weather stations - wrong CRC", + "dmsg": "W129#FFD4CBD80D11A73C06841B5AC1A14865", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#2" + } + ] + } + ], + "id": 129, + "module": "SD_WS", + "name": "FT-0835" + }, + { + "data": [ + { + "comment": "BRESSER lightning detector", + "dmsg": "W131#70F082CC00083A000000", + "internals": { + "DEF": "SD_WS_131", + "NAME": "SD_WS_131" + }, + "readings": { + "batteryChanged": "0", + "batteryState": "ok", + "count": 0, + "distance": 0, + "state": "D: 0", + "type": "Bresser_lightning" + }, + "rmsg": "MN;D=DA5A2866AAA290AAAAAA;R=23;A=-2;", + "tests": [ + { + "attributes": {}, + "comment": "#0" + } + ] + }, + { + "comment": "BRESSER lightning detector", + "dmsg": "W131#009C82CC148832080000", + "internals": { + "DEF": "SD_WS_131", + "NAME": "SD_WS_131" + }, + "readings": { + "batteryChanged": "1", + "batteryState": "ok", + "count": 148, + "distance": 8, + "state": "D: 8", + "type": "Bresser_lightning" + }, + "rmsg": "MN;D=AA362866BE2298A2AAAA;R=24;A=-2;", + "tests": [ + { + "attributes": {}, + "comment": "#1" + } + ] + } + ], + "id": 131, + "module": "SD_WS", + "name": "Bresser lightning" + }, + { + "data": [ + { + "comment": "Temperature transmitter TFA 30.3212", + "dmsg": "W48#FF49C0F3FFD9", + "internals": { + "DEF": "SD_WS_48_T", + "NAME": "SD_WS_48_T" + }, + "readings": { + "state": "T: 24.3", + "temperature": "24.3", + "type": "Temperature transmitter" + }, + "rmsg": "MU;P0=591;P1=-1488;P2=-3736;P3=1338;P4=-372;P6=-988;D=23406060606063606363606363606060636363636363606060606363606060606060606060606060636060636360106060606060606063606363606363606060636363636363606060606363606060606060606060606060636060636360106060606060606063606363606363606060636363636363606060606363606060;CP=0;O;", + "tests": [ + { + "comment": "#0" + } + ] + }, + { + "comment": "Temperature transmitter TFA 30.3212", + "dmsg": "W48#FF4D40A3FFE5", + "internals": { + "DEF": "SD_WS_48_T", + "NAME": "SD_WS_48_T" + }, + "readings": { + "state": "T: 16.3", + "temperature": "16.3", + "type": "Temperature transmitter" + }, + "rmsg": "MU;P0=96;P1=-244;P2=510;P3=-1000;P4=1520;P5=-1506;D=01232323232343234343232343234323434343434343234323434343232323232323232323232323234343234325232323232323232343234343232343234323434343434343234323434343232323232323232323232323234343234325232323232323232343234343232343234323434343434343234323434343232323;CP=2;O;", + "tests": [ + { + "comment": "#1" + } + ] + }, + { + "comment": "Temperature transmitter TFA 30.3212 - wrong CRC", + "dmsg": "W48#FF4D40A3FFEF", + "tests": [ + { + "returns": { + "ParseFn": "" + }, + "comment": "#2" + } + ] + } + ], + "id": "48", + "module": "SD_WS", + "name": "Temperature transmitter TFA 30.3212" + }, + { + "data": [ + { + "comment": "Temperatursensor TFA Dostmann 30.3255.02", + "dmsg": "W135#8D92E55C0", + "internals": { + "DEF": "SD_WS_135_T_1", + "NAME": "SD_WS_135_T_1" + }, + "readings": { + "batteryState": "ok", + "channel": 1, + "model": "SD_WS_135_T", + "sendmode": "auto", + "state": "T: 24.1", + "temperature": 24.1, + "type": "TFA 30.3255.02" + }, + "rmsg": "MU;P0=-10720;P1=965;P2=-994;P3=470;P4=-265;P5=237;P6=-501;D=01212121234565656343456343456563456563456343434565634563456345634343456565612121212345656563434563434565634565634563434345656345634563456343434565656121212123456565634345634345656345656345634343456563456345634563434345656561212121234565656343456343456563;CP=5;R=60;O;", + "tests": [ + { + "attributes": {}, + "comment": "#0" + } + ] + } + ], + "id": 135, + "module": "SD_WS", + "name": "SD_WS_135_T_1" + }, + { + "data": [ + { + "comment": "Wind, temperature and humidity sensor EMOS E06016 with DCF77", + "dmsg": "W136#83B59A1BC54A00CD3A0080D200", + "internals": { + "DEF": "SD_WS_136_THW_1", + "NAME": "SD_WS_136_THW_1" + }, + "readings": { + "batteryState": "ok", + "channel": 1, + "count": 0, + "dcf": "2026-01-23 17:20:40 CET", + "dcfStatus": "ok", + "humidity": 58, + "model": "SD_WS_136_THW", + "state": "T: 20.5 H: 58 W: 0.0", + "temperature": 20.5, + "type": "EMOS E06016 wind", + "windDirectionDegree": 180, + "windDirectionText": "S", + "windSpeed": "0.0" + }, + "rmsg": "MU;P0=-160;P1=773;P2=-299;P3=243;P4=-824;P5=1828;D=01234123412341234123412343412341212343434343412121234121234123412123434121234123434343412123412121212343434123412341234341234123434343434343434341212343412123412343412121234123434343434343434341234343434343434121234123434123434343434343434345212341234123;CP=3;R=48;O;", + "tests": [ + { + "attributes": {}, + "comment": "#0" + } + ] + } + ], + "id": 136, + "module": "SD_WS", + "name": "SD_WS_136_THW_1" + } + ] +} diff --git a/tests/fhem_vectors.py b/tests/fhem_vectors.py new file mode 100644 index 0000000..f423211 --- /dev/null +++ b/tests/fhem_vectors.py @@ -0,0 +1,97 @@ +"""Access to the vendored FHEM test vectors under tests/data/fhem/. + +The vectors are imported by tools/fhem_testdata_import.py and are the shared +reference for both decoding stages: ``dmsg`` is what stage 1 has to produce, +``readings`` is what stage 2 has to produce (ADR-006). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Any, Optional + +DATA_DIR = Path(__file__).parent / "data" / "fhem" + +STX = "\x02" +ETX = "\x03" + + +@dataclass(frozen=True) +class FhemVector: + """One FHEM test vector.""" + + rmsg: str + dmsg: str + module: str + comment: str = "" + readings: dict[str, Any] = field(default_factory=dict) + expects_no_result: bool = False + + @property + def protocol_id(self) -> Optional[str]: + """Protocol id taken from the dmsg preamble, e.g. 'W125#...' -> '125'.""" + if "#" not in self.dmsg: + return None + head = self.dmsg.split("#", 1)[0] + digits = "".join(char for char in head if char.isdigit()) + return digits or None + + @property + def framed_rmsg(self) -> str: + """The raw message with the STX/ETX framing the parser expects.""" + return f"{STX}{self.rmsg}{ETX}" + + def __str__(self) -> str: # keeps pytest ids readable + return f"{self.module}:{self.dmsg}" + + +def _walk(node: Any, module: str, found: list[FhemVector]) -> None: + if isinstance(node, dict): + if isinstance(node.get("rmsg"), str) and isinstance(node.get("dmsg"), str): + readings: dict[str, Any] = {} + expects_no_result = False + for test in node.get("tests") or []: + if not isinstance(test, dict): + continue + if isinstance(test.get("readings"), dict): + readings = test["readings"] + returns = test.get("returns") + if isinstance(returns, dict) and returns.get("ParseFn") == "": + expects_no_result = True + found.append( + FhemVector( + rmsg=node["rmsg"], + dmsg=node["dmsg"], + module=module, + comment=node.get("comment", ""), + readings=readings, + expects_no_result=expects_no_result, + ) + ) + return + for value in node.values(): + _walk(value, module, found) + elif isinstance(node, list): + for item in node: + _walk(item, module, found) + + +@lru_cache(maxsize=None) +def load_vectors(module: str = "sd_ws") -> tuple[FhemVector, ...]: + """Loads all vectors of one vendored module file.""" + path = DATA_DIR / f"{module}.json" + if not path.is_file(): + return () + with path.open(encoding="utf-8") as handle: + document = json.load(handle) + found: list[FhemVector] = [] + _walk(document.get("vectors", document), module, found) + return tuple(found) + + +def vectors_for_protocol(protocol_id: str, module: str = "sd_ws") -> tuple[FhemVector, ...]: + """All vectors of one protocol id.""" + return tuple(v for v in load_vectors(module) if v.protocol_id == protocol_id) diff --git a/tests/test_stage1_baseline.py b/tests/test_stage1_baseline.py new file mode 100644 index 0000000..1a5e6c7 --- /dev/null +++ b/tests/test_stage1_baseline.py @@ -0,0 +1,95 @@ +"""Stage 1 baseline and the bit string enabler for stage 2 (ADR-006). + +Two things are guarded here. First, how many of the FHEM test vectors the +demodulation currently reproduces exactly - if a stage 2 change breaks stage 1, +that number drops and this test fails. Second, that the demodulated bit string +actually reaches DecodedMessage.metadata, because the decoders work on bits and +cannot be built without it. +""" + +from __future__ import annotations + +import pytest + +from signalduino.parser import SignalParser +from signalduino.types import DecodedMessage, SensorEvent + +from .fhem_vectors import load_vectors, vectors_for_protocol + +# Measured on the vendored sd_ws vectors. Raise it when stage 1 improves, +# never lower it silently - a drop means a regression. +BASELINE_MATCHES = 86 +BASELINE_TOTAL = 99 + +# Protocols in daily use here, which must not regress individually. +REQUIRED_PROTOCOLS = ["27", "50", "85", "125", "126"] + + +@pytest.fixture(scope="module") +def parser() -> SignalParser: + return SignalParser() + + +def _payloads(parser: SignalParser, vector) -> list[str]: + return [message.payload for message in parser.parse_line(vector.framed_rmsg)] + + +def test_vectors_are_vendored(): + vectors = load_vectors("sd_ws") + assert len(vectors) == BASELINE_TOTAL, ( + "Vendored vector count changed - re-run tools/fhem_testdata_import.py " + "and update the baseline deliberately." + ) + + +def test_stage1_baseline_is_met(parser): + """The demodulation must reproduce at least the known number of dmsg strings.""" + matches = sum( + 1 for vector in load_vectors("sd_ws") if vector.dmsg in _payloads(parser, vector) + ) + assert matches >= BASELINE_MATCHES, ( + f"Stage 1 regression: {matches}/{BASELINE_TOTAL} vectors match, " + f"baseline is {BASELINE_MATCHES}." + ) + + +@pytest.mark.parametrize("protocol_id", REQUIRED_PROTOCOLS) +def test_protocols_in_use_decode_completely(parser, protocol_id): + """Protocols with actual hardware behind them must match every vector.""" + vectors = vectors_for_protocol(protocol_id) + assert vectors, f"No vectors for protocol {protocol_id}" + for vector in vectors: + assert vector.dmsg in _payloads(parser, vector), f"{vector} did not decode" + + +@pytest.mark.parametrize("protocol_id", ["27", "85"]) +def test_bit_string_reaches_metadata(parser, protocol_id): + """Stage 2 needs the bit string, not just its length.""" + for vector in vectors_for_protocol(protocol_id): + for message in parser.parse_line(vector.framed_rmsg): + if message.payload != vector.dmsg: + continue + bits = message.metadata.get("bits") + assert isinstance(bits, str) and bits, f"No bit string for {vector}" + assert set(bits) <= {"0", "1"}, f"Bit string is not binary: {bits[:32]}" + assert len(bits) == message.metadata["bit_length"] + return + pytest.fail(f"No matching message for protocol {protocol_id}") + + +def test_decoded_message_has_optional_sensor_field(): + """DecodedMessage carries stage 2 results without changing existing usage.""" + message = DecodedMessage(protocol_id="125", payload="W125#AB", raw=None) + assert message.sensor is None + + message.sensor = SensorEvent( + protocol_id="125", + model="SD_WS_125_TH", + sensor_type="WH31e", + device_id="SD_WS_125_TH_02_1", + sensor_id="02", + values={"temperature": 21.0}, + units={"temperature": "°C"}, + channel=1, + ) + assert message.sensor.values["temperature"] == 21.0 diff --git a/tools/fhem_testdata_import.py b/tools/fhem_testdata_import.py new file mode 100644 index 0000000..0da5135 --- /dev/null +++ b/tools/fhem_testdata_import.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Imports the FHEM client module test vectors into tests/data/fhem/. + +The RFFHEM repository ships, for every client module, a testData.json holding +raw telegrams, the expected stage 1 string and the expected stage 2 readings. +That makes it the reference for both decoding stages (see ADR-006). + +The vectors are vendored into this repository on purpose: the test suite must +not depend on a sibling checkout of RFFHEM being present, and a pinned copy +makes it visible in the diff when upstream expectations change. + +Usage: + python3 tools/fhem_testdata_import.py --rffhem ../RFFHEM + python3 tools/fhem_testdata_import.py --rffhem ../RFFHEM --module 14_SD_WS +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +DEFAULT_MODULES = [ + "14_SD_WS", + "14_SD_WS07", + "14_SD_WS09", + "14_SD_UT", + "14_SD_BELL", + "14_SD_AS", + "14_Hideki", + "41_OREGON", + "14_FLAMINGO", + "10_FS10", + "10_SD_GT", + "10_SD_Rojaflex", + "14_SD_WS_Maverick", +] + +TARGET_DIR = Path(__file__).resolve().parent.parent / "tests" / "data" / "fhem" + + +def _source_revision(rffhem: Path) -> str: + """Returns the RFFHEM commit the vectors were taken from, or 'unknown'.""" + try: + result = subprocess.run( + ["git", "-C", str(rffhem), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + +def _target_name(module: str) -> str: + """14_SD_WS -> sd_ws.json""" + name = module.split("_", 1)[1] if "_" in module else module + return f"{name.lower()}.json" + + +def import_module(rffhem: Path, module: str, revision: str) -> bool: + source = rffhem / "t" / "FHEM" / module / "testData.json" + if not source.is_file(): + print(f" skip {module}: {source} not found") + return False + + with source.open(encoding="utf-8") as handle: + vectors = json.load(handle) + + document = { + "_source": { + "repository": "https://github.com/RFD-FHEM/RFFHEM", + "path": f"t/FHEM/{module}/testData.json", + "revision": revision, + "imported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "note": "Generated by tools/fhem_testdata_import.py - do not edit by hand.", + }, + "vectors": vectors, + } + + TARGET_DIR.mkdir(parents=True, exist_ok=True) + target = TARGET_DIR / _target_name(module) + with target.open("w", encoding="utf-8") as handle: + json.dump(document, handle, ensure_ascii=False, indent=1) + handle.write("\n") + + count = _count_vectors(vectors) + print(f" {module} -> {target.relative_to(TARGET_DIR.parent.parent.parent)} ({count} vectors)") + return True + + +def _count_vectors(node) -> int: + if isinstance(node, dict): + if "rmsg" in node and "dmsg" in node: + return 1 + return sum(_count_vectors(value) for value in node.values()) + if isinstance(node, list): + return sum(_count_vectors(item) for item in node) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--rffhem", + default="../RFFHEM", + help="Path to a RFFHEM checkout (default: ../RFFHEM)", + ) + parser.add_argument( + "--module", + action="append", + help="Import only this module, may be given several times", + ) + args = parser.parse_args() + + rffhem = Path(args.rffhem).expanduser().resolve() + if not rffhem.is_dir(): + print(f"RFFHEM checkout not found: {rffhem}", file=sys.stderr) + return 1 + + revision = _source_revision(rffhem) + modules = args.module or DEFAULT_MODULES + print(f"Importing from {rffhem} at {revision[:12]}") + + imported = sum(import_module(rffhem, module, revision) for module in modules) + print(f"{imported} of {len(modules)} modules imported") + return 0 if imported else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 79502f7ab9f0827442edf375ebc599d7b2d9b84c Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 16 Sep 2026 20:25:48 +0200 Subject: [PATCH 4/5] docs(agents): document running the test suite in the devcontainer There is deliberately no virtualenv in the repository root, which invites building an ad hoc environment instead - that pulls its own package versions, misses the companion services and produces numbers that are not the project's. The devcontainer already defines both, so AGENTS.md now says so and gives the plain Compose commands for use outside VS Code, including the explicit pip3 install that the postCreateCommand would otherwise do. Also records why a branch switch can fail with a permission error on .devcontainer/fhem-data/: the FHEM image owns those files as 6061 unless FHEM_UID and FHEM_GID point at the host user. --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a0df873..8c9c5eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,33 @@ This file provides guidance to agents when working with code in this repository. oder um eine längere Laufzeit zu analysieren: `python3 main.py --timeout 30` +## Testausführung im Devcontainer +- **Tests laufen im Devcontainer des Projekts, nicht in einer selbst angelegten Umgebung.** Im + Repository-Wurzelverzeichnis existiert bewusst kein Virtualenv; die Abhängigkeiten und die + Begleitdienste (Mosquitto, FHEM) sind in `.devcontainer/` definiert. Eine ad-hoc erzeugte + Umgebung läuft daran vorbei, kann abweichende Paketversionen ziehen und liefert Messergebnisse, + die nicht die des Projekts sind. +- In VS Code genügt "Reopen in Container"; der `postCreateCommand` aus + `.devcontainer/devcontainer.json` installiert `requirements-dev.txt` und `requirements.txt`. +- Ohne VS Code lässt sich derselbe Service direkt über Compose nutzen: + ```bash + cd .devcontainer + docker compose up -d devcontainer + docker compose exec -T devcontainer bash -lc \ + "cd /workspaces/PySignalduino && pip3 install --quiet --user -r requirements-dev.txt -r requirements.txt" + docker compose exec -T devcontainer bash -lc \ + "cd /workspaces/PySignalduino && timeout 120 python3 -m pytest -q" + ``` + Hinweis: Über Compose gestartet greifen die `features` aus `devcontainer.json` (Node, uv, + AsciiDoc) nicht, und der `postCreateCommand` läuft nicht automatisch — deshalb der explizite + `pip3 install`. Für die Testsuite reicht das aus. +- Der FHEM-Dienst desselben Compose-Setups schreibt nach `.devcontainer/fhem-data/`. Das Image legt + diese Dateien standardmäßig unter UID/GID 6061 an, wodurch Git sie auf dem Host nicht mehr + ersetzen kann — ein Branch-Wechsel scheitert dann mit `unable to unlink old '': + Permission denied`. Abhilfe schafft, im `fhem`-Service die Variablen `FHEM_UID` und `FHEM_GID` + auf den eigenen Host-Benutzer zu setzen (`id -u` / `id -g`); der Dateibesitz sollte dagegen + nicht per `chown` umgebogen werden, da FHEM im Container sonst nicht mehr schreiben kann. + ## Test Timeout Configuration - Für pytest wurde ein globaler Timeout von 30 Sekunden in der `pyproject.toml` konfiguriert: ```toml From d7820836633111ae3c1bf332fcc5b8a6498029f3 Mon Sep 17 00:00:00 2001 From: sidey79 Date: Wed, 16 Sep 2026 21:51:19 +0200 Subject: [PATCH 5/5] feat(decoders): add the decoding core for sensor values Implements the machinery ADR-006 describes, without any protocol yet: the first specification follows separately, so the core can be reviewed on its own merits. * crc.py holds the checksums once instead of once per client module, as pure functions over bytes. Verified against the published check values for CRC-8/NRSC-5, CRC-8/MAXIM, CRC-16/CCITT-FALSE and CRC-16/ARC. * dsl.py evaluates a field rule - bit or hex range, BCD, sign handling, offset, scale, rounding, value maps, derivations. Indices are inclusive and zero based so they can be copied straight from the FHEM comments. * spec.py and spec_schema.json define and validate the specification language. Validating at load time means a typo is reported once with its path, not silently dropped on every telegram. * registry.py resolves a protocol id to a decoder, preferring a registered Python decoder over a specification so a protocol can move between the two. A broken specification is logged and skipped instead of taking the others down with it. * pipeline.py runs the sequence from SD_WS_Parse: strip preamble, get bits, prematch, checksum, variant, fields, limits. SensorDecoder wraps it so that no failure can reach stage 1. Documented in docs/02_developer_guide/decoder_specs.adoc, including how to add a protocol and when to reach for a Python decoder instead. --- docs/02_developer_guide/decoder_specs.adoc | 135 +++++++++++++ docs/02_developer_guide/index.adoc | 2 +- docs/index.adoc | 2 + signalduino/decoders/__init__.py | 25 +++ signalduino/decoders/crc.py | 147 ++++++++++++++ signalduino/decoders/custom/__init__.py | 18 ++ signalduino/decoders/dsl.py | 167 ++++++++++++++++ signalduino/decoders/pipeline.py | 198 +++++++++++++++++++ signalduino/decoders/registry.py | 151 +++++++++++++++ signalduino/decoders/spec.py | 110 +++++++++++ signalduino/decoders/spec_schema.json | 167 ++++++++++++++++ signalduino/decoders/specs/.gitkeep | 0 tests/test_decoder_crc.py | 117 +++++++++++ tests/test_decoder_dsl.py | 185 ++++++++++++++++++ tests/test_decoder_pipeline.py | 214 +++++++++++++++++++++ tests/test_decoder_registry.py | 187 ++++++++++++++++++ 16 files changed, 1824 insertions(+), 1 deletion(-) create mode 100644 docs/02_developer_guide/decoder_specs.adoc create mode 100644 signalduino/decoders/__init__.py create mode 100644 signalduino/decoders/crc.py create mode 100644 signalduino/decoders/custom/__init__.py create mode 100644 signalduino/decoders/dsl.py create mode 100644 signalduino/decoders/pipeline.py create mode 100644 signalduino/decoders/registry.py create mode 100644 signalduino/decoders/spec.py create mode 100644 signalduino/decoders/spec_schema.json create mode 100644 signalduino/decoders/specs/.gitkeep create mode 100644 tests/test_decoder_crc.py create mode 100644 tests/test_decoder_dsl.py create mode 100644 tests/test_decoder_pipeline.py create mode 100644 tests/test_decoder_registry.py diff --git a/docs/02_developer_guide/decoder_specs.adoc b/docs/02_developer_guide/decoder_specs.adoc new file mode 100644 index 0000000..f8ecbc6 --- /dev/null +++ b/docs/02_developer_guide/decoder_specs.adoc @@ -0,0 +1,135 @@ += Decoder-Spezifikationen (Stufe 2) +:sectlinks: + +Der Empfang läuft in zwei Stufen, wie in link:../architecture/decisions/ADR-006-sensor-decoding-layer.adoc[ADR-006] beschrieben: + +. *Stufe 1* — `signalduino/parser` und `sd_protocols` machen aus Pulsen eine Bitfolge und daraus einen Hex-Payload. +. *Stufe 2* — `signalduino/decoders` macht daraus Messwerte: Temperatur, Luftfeuchte, Batteriezustand, Sensor-ID, Kanal. + +Dieses Kapitel beschreibt, wie man Stufe 2 um ein Protokoll erweitert. + +== Der Regelfall: eine JSON-Datei + +Die meisten Protokolle lassen sich vollständig als Daten beschreiben. Eine Spezifikation liegt unter `signalduino/decoders/specs/` und wird beim Start gegen `spec_schema.json` validiert; ein Tippfehler fällt damit sofort beim Laden auf und nicht still bei jedem Telegramm. + +[source,json] +---- +{ + "protocol_id": "900", + "model": "TEST_900", + "sensor_type": "Beispielgerät", + "prematch": "^12", + "crc": { + "algorithm": "crc8", + "data": { "from": 0, "to": 5 }, + "check": { "from": 6, "to": 7 } + }, + "fields": { + "id": { "source": "hex", "from": 0, "to": 1, "type": "str" }, + "channel": { "source": "bits", "from": 17, "to": 19, "offset": 1 }, + "temperature": { "source": "bits", "from": 22, "to": 31, + "type": "float", "offset": -400, "scale": 0.1, "unit": "°C" } + }, + "limits": { "temperature": [-40, 60] } +} +---- + +Die Namen unter `fields` sind bewusst die FHEM-Readingnamen (`temperature`, `humidity`, `batteryState`, `channel`, ...). Sie sind über rund 150 Protokolle hinweg etabliert, machen den Paritätstest gegen die FHEM-Testvektoren zu einem direkten Vergleich und werden von den Ausgabeadaptern auf deren jeweilige Namen abgebildet. + +== Indizes + +`from` und `to` sind **nullbasiert und einschließlich** — `from: 18, to: 27` ist ein Zehn-Bit-Feld. Das entspricht `SD_WS_binaryToNumber($bitData, 18, 27)` in FHEM, sodass sich die Bitpositionen aus den Kommentaren der Perl-Module direkt übernehmen lassen. + +`source` wählt die Datenquelle: + +* `bits` — die demodulierte Bitfolge aus `metadata["bits"]`. Fehlt sie, etwa bei MN-Telegrammen, wird sie aus dem Hex-Payload erzeugt. +* `hex` — die Zeichen des Hex-Payloads, ohne Präambel. + +== Rechenschritte eines Feldes + +Die Reihenfolge liegt fest und ist so gewählt, dass eine Regel sich liest wie die FHEM-Zeile, die sie ersetzt: + +. Bereich ausschneiden +. `bcd` — als binär codierte Dezimalzahl lesen +. `sign_bit` mit `sign_style` anwenden +. `offset` addieren +. `scale` multiplizieren +. `round` auf Nachkommastellen kürzen +. `map` — Rohwert über eine Wertetabelle abbilden +. `derive` — abgeleiteten Wert berechnen + +[cols="1,3", options="header"] +|=== +| Schlüssel | Bedeutung + +| `type` +| `int` (Vorgabe), `float`, `str`, `bool`. `str` liefert den Rohausschnitt, solange die Regel keine Rechenoperation enthält — typisch für Sensor-IDs. + +| `sign_style` +| `offset` zieht `sign_offset` ab (das FHEM-Idiom `wert - 1024`), `negate` kehrt das Vorzeichen um, `twos_complement` liest den Bereich im Zweierkomplement. + +| `sign_value` +| Welcher Wert des Vorzeichenbits negativ bedeutet, Vorgabe `"1"`. + +| `map` +| Bildet den Rohwert ab, z. B. `{"0": "ok", "1": "low"}` für den Batteriezustand. + +| `derive` +| Abgeleiteter Wert, derzeit `wind_dir_text`. Mit `derive_from` rechnet die Regel auf einem bereits dekodierten Feld statt auf einem eigenen Bereich. + +| `unit` +| Einheit, die im `SensorEvent` neben dem Wert geführt wird. +|=== + +== Prüfsummen + +`crc` verweist auf einen Algorithmus aus `signalduino/decoders/crc.py` und auf zwei Bereiche des Hex-Payloads: `data` ist der geprüfte Bereich, `check` die im Telegramm mitgesendete Prüfsumme. Beide Bereiche sind Zeichenindizes und müssen ganze Bytes umfassen. + +Verfügbar sind `crc8`, `crc16`, `crc16lsb`, `sum8`, `xor8`, `lfsr_digest8` und `lfsr_digest8_reflect`. Abweichende Parameter stehen unter `params`, etwa `{"poly": 49, "init": 255}`. + +Schlägt die Prüfung fehl, wird das Telegramm verworfen — es entsteht kein `SensorEvent`. Genauso wirkt eine Verletzung von `limits`; das entspricht dem Verhalten der FHEM-Module, die bei unplausiblen Werten ebenfalls nichts liefern. + +== Varianten + +Trägt ein Protokoll je nach Telegrammtyp unterschiedliche Felder, wählt `variant_selector` den passenden Block aus `variants` aus. Die Felder unter `fields` gelten für alle Varianten, der Variantenblock ergänzt und überschreibt sie; `model` und `sensor_type` lassen sich pro Variante anpassen. + +[source,json] +---- +{ + "variant_selector": { "source": "hex", "from": 0, "to": 1, "type": "str" }, + "fields": { "id": { "source": "hex", "from": 2, "to": 3, "type": "str" } }, + "variants": { + "30": { "model": "SD_WS_125_TH", "fields": { "temperature": { "…": "…" } } }, + "52": { "model": "SD_WS_125_T", "fields": { "…": "…" } } + } +} +---- + +== Der Ausnahmefall: ein Python-Decoder + +Lässt sich ein Protokoll nicht als Daten beschreiben — etwa weil Felder voneinander abhängen, die Prüfsumme über einen umsortierten Payload läuft oder der Modellname selbst aus den Daten entsteht — gehört es nach `signalduino/decoders/custom/`: + +[source,python] +---- +from ..registry import register_decoder + +@register_decoder("115") +def decode_bresser_5in1(message): + ... + return SensorEvent(...) +---- + +Ein solcher Decoder hat Vorrang vor einer gleichnamigen Spezifikation. Damit lässt sich ein Protokoll später von der einen Form in die andere überführen, ohne etwas zu löschen. + +== Routing + +Gefunden wird ein Decoder über die `protocol_id`, die in `DecodedMessage` bereits vorliegt. Die Felder aus `protocols.json` dienen dabei nur der Vorbereitung: `preamble` trennt den Hex-Teil ab, `modulematch` ist der Vorgabewert für `prematch`, und `clientmodule` gruppiert die Protokolle für Topics und Berichte. + +== Neues Protokoll hinzufügen + +. Bitlayout und Prüfsumme im zugehörigen FHEM-Client-Modul nachlesen, etwa in `14_SD_WS.pm`. +. Spezifikation unter `signalduino/decoders/specs/` anlegen. +. Testvektoren vorhanden? `tests/data/fhem/` enthält die FHEM-Vektoren samt erwarteter Readings; ergänzt werden sie mit `tools/fhem_testdata_import.py`. +. Tests ausführen (siehe `AGENTS.md`, Abschnitt zur Testausführung im Devcontainer). + +Stufe 2 kann Stufe 1 nicht beschädigen: Jeder Fehler im Decoder führt zu `sensor = None`, während der bisherige Nachrichtenfluss unverändert weiterläuft. diff --git a/docs/02_developer_guide/index.adoc b/docs/02_developer_guide/index.adoc index 07a0ffc..f354939 100644 --- a/docs/02_developer_guide/index.adoc +++ b/docs/02_developer_guide/index.adoc @@ -3,7 +3,7 @@ Dieser Abschnitt beschreibt die Architektur, wie man zur Entwicklung beitragen kann (Contributing) und wie man Tests durchführt. -include::architecture.adoc[] include::contribution.adoc[] +include::architecture.adoc[] include::contribution.adoc[] include::decoder_specs.adoc[] == Weitere Ressourcen diff --git a/docs/index.adoc b/docs/index.adoc index 621b9ad..2e18610 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -127,4 +127,6 @@ include::02_developer_guide/architecture.adoc[] include::02_developer_guide/contribution.adoc[] +include::02_developer_guide/decoder_specs.adoc[] + include::03_protocol_reference/protocol_details.adoc[] \ No newline at end of file diff --git a/signalduino/decoders/__init__.py b/signalduino/decoders/__init__.py new file mode 100644 index 0000000..ca46cbc --- /dev/null +++ b/signalduino/decoders/__init__.py @@ -0,0 +1,25 @@ +"""Sensor value decoding, the second stage of the receive chain (ADR-006). + +Stage 1, in signalduino/parser and sd_protocols, turns pulses into a payload. +This package turns that payload into measurements: temperature, humidity, +battery state, sensor id, channel. + +Most protocols are described declaratively in specs/*.json and evaluated by +dsl.py; the irregular ones register a Python decoder in custom/. Either way +the result is a SensorEvent, which the output adapters render into the FHEM, +rtl_433 and Home Assistant formats. +""" + +from .pipeline import DecodeError, SensorDecoder, decode_with_spec +from .registry import DecoderRegistry, register_decoder +from .spec import DecoderSpec, SpecError + +__all__ = [ + "DecodeError", + "DecoderRegistry", + "DecoderSpec", + "SensorDecoder", + "SpecError", + "decode_with_spec", + "register_decoder", +] diff --git a/signalduino/decoders/crc.py b/signalduino/decoders/crc.py new file mode 100644 index 0000000..9e6f632 --- /dev/null +++ b/signalduino/decoders/crc.py @@ -0,0 +1,147 @@ +"""Checksum algorithms used by the decoder specifications (ADR-006). + +FHEM implements its checks inside each client module, so the same CRC-8 turns +up several times in slightly different spellings. Here they live once, as pure +functions over bytes, addressed by name from a specification. + +Adding an algorithm means adding a function and one ALGORITHMS entry. Anything +irregular enough that it cannot be expressed as "digest over a byte range, +compared against another byte range" belongs in a custom decoder instead. +""" + +from __future__ import annotations + +from typing import Callable + +ChecksumFunc = Callable[..., int] + + +def _reflect(value: int, width: int) -> int: + """Reverses the bit order of ``value`` within ``width`` bits.""" + result = 0 + for _ in range(width): + result = (result << 1) | (value & 1) + value >>= 1 + return result + + +def crc8(data: bytes, poly: int = 0x31, init: int = 0x00, + reflect_in: bool = False, reflect_out: bool = False, + xor_out: int = 0x00) -> int: + """CRC-8 with configurable parameters. + + The default is polynomial 0x31 with initial value 0, neither input nor + output reflected, which is what the Fine Offset and EuroChron sensors use + and what Digest::CRC produces for ``width => 8, poly => 0x31`` in FHEM. + """ + crc = init + for byte in data: + if reflect_in: + byte = _reflect(byte, 8) + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ poly) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + if reflect_out: + crc = _reflect(crc, 8) + return crc ^ xor_out + + +def crc16(data: bytes, poly: int = 0x8005, init: int = 0xFFFF, + reflect_in: bool = False, reflect_out: bool = False, + xor_out: int = 0x0000) -> int: + """CRC-16 with configurable parameters.""" + crc = init + for byte in data: + if reflect_in: + byte = _reflect(byte, 8) + crc ^= byte << 8 + for _ in range(8): + crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF + if reflect_out: + crc = _reflect(crc, 16) + return crc ^ xor_out + + +def crc16lsb(data: bytes, poly: int = 0x8810, init: int = 0x0000) -> int: + """CRC-16 processed least significant bit first. + + Mirrors SD_WS_crc16lsb from 14_SD_WS.pm, used by several weather sensors. + """ + crc = init + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ poly if crc & 1 else crc >> 1 + return crc & 0xFFFF + + +def sum8(data: bytes, init: int = 0x00) -> int: + """Sum of all bytes, truncated to 8 bits.""" + return (init + sum(data)) & 0xFF + + +def xor8(data: bytes, init: int = 0x00) -> int: + """XOR over all bytes.""" + result = init + for byte in data: + result ^= byte + return result & 0xFF + + +def lfsr_digest8(data: bytes, gen: int = 0x31, key: int = 0xF4) -> int: + """Galois LFSR digest, bits processed most significant first. + + Mirrors the digest used by Bresser style sensors: for every set bit the + current key is XORed into the sum, and the key advances through the LFSR + on each bit. + """ + result = 0 + current = key + for byte in data: + for bit in range(7, -1, -1): + if (byte >> bit) & 1: + result ^= current + current = (current >> 1) ^ gen if current & 1 else current >> 1 + return result & 0xFF + + +def lfsr_digest8_reflect(data: bytes, gen: int = 0x31, key: int = 0xF4) -> int: + """Galois LFSR digest over the bytes in reverse order, bits least first. + + Mirrors SD_WS_LFSR_digest8_reflect from 14_SD_WS.pm. + """ + result = 0 + current = key + for byte in reversed(data): + for bit in range(8): + if (byte >> bit) & 1: + result ^= current + current = ((current << 1) ^ gen) & 0xFF if current & 0x80 else (current << 1) & 0xFF + return result & 0xFF + + +ALGORITHMS: dict[str, ChecksumFunc] = { + "crc8": crc8, + "crc16": crc16, + "crc16lsb": crc16lsb, + "sum8": sum8, + "xor8": xor8, + "lfsr_digest8": lfsr_digest8, + "lfsr_digest8_reflect": lfsr_digest8_reflect, +} + + +def compute(algorithm: str, data: bytes, **params) -> int: + """Runs a named algorithm over ``data``. + + Raises: + KeyError: if the algorithm is unknown. Specifications are validated + against the schema, so this only happens for custom decoders. + """ + try: + func = ALGORITHMS[algorithm] + except KeyError: + raise KeyError( + f"Unknown checksum algorithm '{algorithm}'. Known: {', '.join(sorted(ALGORITHMS))}" + ) from None + return func(data, **params) diff --git a/signalduino/decoders/custom/__init__.py b/signalduino/decoders/custom/__init__.py new file mode 100644 index 0000000..3606519 --- /dev/null +++ b/signalduino/decoders/custom/__init__.py @@ -0,0 +1,18 @@ +"""Python decoders for protocols a specification cannot express (ADR-006). + +A protocol belongs here when its fields depend on each other, when the +checksum runs over a reordered payload, or when the model name itself is +derived from the data. Everything else belongs in specs/*.json. + +A module in this package registers itself: + + from ..registry import register_decoder + + @register_decoder("115") + def decode_bresser_5in1(message): + ... + return SensorEvent(...) + +and is imported here so the registry picks it up. A custom decoder takes +precedence over a specification with the same protocol id. +""" diff --git a/signalduino/decoders/dsl.py b/signalduino/decoders/dsl.py new file mode 100644 index 0000000..2200391 --- /dev/null +++ b/signalduino/decoders/dsl.py @@ -0,0 +1,167 @@ +"""Evaluates the field rules of a decoder specification (ADR-006). + +The FHEM client modules extract their values with small closures over bit +offsets - take bits 18 to 27, subtract 1024 if bit 17 is set, divide by ten. +That is mechanical enough to describe as data, and this module is the +interpreter for that description. + +Indices are always inclusive and zero based, matching SD_WS_binaryToNumber in +14_SD_WS.pm: ``from`` 18 and ``to`` 27 is a ten bit field. For ``hex`` they +index the characters of the payload instead. + +The order of operations is fixed: extract, decode BCD, apply the sign, add the +offset, scale, round, map, derive. It is chosen so a rule reads like the FHEM +line it replaces. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +WIND_DIRECTIONS = [ + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", +] + + +class FieldError(ValueError): + """A field rule could not be applied to this message.""" + + +def _slice(field: dict, bits: str, raw_hex: str) -> str: + source = field["source"] + data = bits if source == "bits" else raw_hex + start, end = field["from"], field["to"] + if start > end: + raise FieldError(f"from {start} is behind to {end}") + if end >= len(data): + raise FieldError( + f"{source} range {start}..{end} exceeds the message ({len(data)} available)" + ) + return data[start:end + 1] + + +def _to_number(chunk: str, source: str, bcd: bool) -> int: + if bcd: + digits = chunk + if source == "bits": + if len(chunk) % 4: + raise FieldError("BCD on bits needs a multiple of four bits") + digits = "".join( + f"{int(chunk[i:i + 4], 2):X}" for i in range(0, len(chunk), 4) + ) + if any(char not in "0123456789" for char in digits): + raise FieldError(f"Not a valid BCD value: {digits}") + return int(digits, 10) + try: + return int(chunk, 2 if source == "bits" else 16) + except ValueError as error: + raise FieldError(f"Cannot read '{chunk}' as a number") from error + + +def _apply_sign(value: int, field: dict, bits: str, raw_hex: str) -> float: + sign_bit = field.get("sign_bit") + if sign_bit is None: + return value + + source = field["source"] + data = bits if source == "bits" else raw_hex + if sign_bit >= len(data): + raise FieldError(f"Sign bit {sign_bit} is outside the message") + if source != "bits": + raise FieldError("sign_bit is only meaningful on bits") + + is_negative = data[sign_bit] == field.get("sign_value", "1") + style = field.get("sign_style", "negate") + + if style == "twos_complement": + width = field["to"] - field["from"] + 1 + return value - (1 << width) if is_negative else value + if not is_negative: + return value + if style == "offset": + if "sign_offset" not in field: + raise FieldError("sign_style 'offset' requires sign_offset") + return value - field["sign_offset"] + return -value + + +def _derive(name: str, value: Any) -> Any: + if name == "wind_dir_text": + try: + index = int(round(float(value) / 22.5)) % 16 + except (TypeError, ValueError) as error: + raise FieldError(f"wind_dir_text needs a number, got {value!r}") from error + return WIND_DIRECTIONS[index] + raise FieldError(f"Unknown derivation '{name}'") + + +def evaluate_field(field: dict, bits: str, raw_hex: str, + decoded: Optional[dict[str, Any]] = None) -> Any: + """Applies one field rule and returns the resulting value. + + Args: + field: The rule, already validated against the schema. + bits: The demodulated bit string. + raw_hex: The payload without its preamble. + decoded: Fields decoded so far, for rules using ``derive_from``. + + Raises: + FieldError: if the rule does not fit this message. + """ + source_field = field.get("derive_from") + if source_field is not None: + if not decoded or source_field not in decoded: + raise FieldError(f"derive_from references unknown field '{source_field}'") + value = decoded[source_field] + if "derive" in field: + value = _derive(field["derive"], value) + return value + + chunk = _slice(field, bits, raw_hex) + value_type = field.get("type", "int") + + # A plain string field hands back the slice as it stands, which is how + # sensor ids are read. As soon as the rule asks for arithmetic, the value + # goes through the numeric path and is stringified at the end - silently + # dropping a scale here would be a trap for whoever writes the spec. + transforms = ("bcd", "map", "scale", "offset", "sign_bit", "round", "derive") + if value_type == "str" and not any(key in field for key in transforms): + return chunk + + number = _to_number(chunk, field["source"], field.get("bcd", False)) + + if "map" in field: + mapping = field["map"] + key = str(number) + if key not in mapping: + raise FieldError(f"No mapping for value {key} in {sorted(mapping)}") + return mapping[key] + + value: float = _apply_sign(number, field, bits, raw_hex) + value = (value + field.get("offset", 0)) * field.get("scale", 1) + + if "round" in field: + value = round(value, field["round"]) + elif isinstance(value, float) and not value.is_integer(): + # Scaling by 0.1 and friends leaves binary noise behind; the FHEM + # values this is compared against never carry it. + value = round(value, 10) + + if "derive" in field: + return _derive(field["derive"], value) + + if value_type == "int": + if isinstance(value, float) and not float(value).is_integer(): + raise FieldError(f"Value {value} is not an integer") + return int(value) + if value_type == "bool": + return bool(value) + if value_type == "str": + return str(value) + + result = float(value) + if math.isnan(result) or math.isinf(result): + raise FieldError("Value is not finite") + return result diff --git a/signalduino/decoders/pipeline.py b/signalduino/decoders/pipeline.py new file mode 100644 index 0000000..5169eaa --- /dev/null +++ b/signalduino/decoders/pipeline.py @@ -0,0 +1,198 @@ +"""Turns a demodulated message into sensor values (ADR-006, stage 2). + +The sequence mirrors SD_WS_Parse in 14_SD_WS.pm: strip the preamble, get hold +of the bits, check the prematch, verify the checksum, pick the variant, read +the fields, check the plausibility limits. + +Every failure along the way is a debug log and a None, never an exception that +reaches the caller: stage 2 must not be able to disturb stage 1, and for most +protocols "no specification yet" is simply the normal state. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Optional + +from ..types import DecodedMessage, SensorEvent +from . import crc as crc_module +from .dsl import FieldError, evaluate_field +from .spec import DecoderSpec + + +class DecodeError(ValueError): + """The message does not decode with this specification.""" + + +def hex_to_bits(raw_hex: str) -> str: + """Expands a hex payload into its bit string. + + Needed for MN telegrams, which arrive as hex and never carry a bit string + of their own, while the field rules address bit positions. + """ + try: + return "".join(f"{int(char, 16):04b}" for char in raw_hex) + except ValueError as error: + raise DecodeError(f"Payload is not hex: {raw_hex!r}") from error + + +def strip_preamble(payload: str, preamble: str) -> str: + """Removes the routing preamble, leaving the raw hex.""" + if preamble and payload.startswith(preamble): + return payload[len(preamble):] + if "#" in payload: + return payload.split("#", 1)[1] + return payload + + +def _hex_range(raw_hex: str, bounds: dict) -> bytes: + start, end = bounds["from"], bounds["to"] + chunk = raw_hex[start:end + 1] + if len(chunk) != end - start + 1: + raise DecodeError(f"Range {start}..{end} exceeds the payload") + if len(chunk) % 2: + raise DecodeError(f"Range {start}..{end} is not a whole number of bytes") + try: + return bytes.fromhex(chunk) + except ValueError as error: + raise DecodeError(f"Range {start}..{end} is not hex") from error + + +def verify_checksum(spec: DecoderSpec, raw_hex: str) -> None: + """Checks the specification's checksum, if it defines one. + + Raises: + DecodeError: if the checksum does not match. + """ + if not spec.crc: + return + + definition = spec.crc + data = _hex_range(raw_hex, definition["data"]) + expected_bytes = _hex_range(raw_hex, definition["check"]) + expected = int.from_bytes(expected_bytes, "big") + actual = crc_module.compute(definition["algorithm"], data, **definition.get("params", {})) + + if actual != expected: + raise DecodeError( + f"{definition['algorithm']} mismatch: computed {actual:#x}, message says {expected:#x}" + ) + + +def _check_limits(values: dict[str, Any], limits: dict[str, list]) -> None: + for name, bounds in limits.items(): + if name not in values: + continue + value = values[name] + if not isinstance(value, (int, float)): + continue + low, high = bounds + if not low <= value <= high: + raise DecodeError(f"{name} {value} is outside {low}..{high}") + + +def decode_with_spec(spec: DecoderSpec, message: DecodedMessage, + preamble: str = "") -> SensorEvent: + """Applies one specification to one message. + + Raises: + DecodeError: if the message does not match the specification. + """ + raw_hex = strip_preamble(message.payload, preamble).upper() + if not raw_hex: + raise DecodeError("Empty payload") + + if spec.prematch and not re.search(spec.prematch, raw_hex): + raise DecodeError(f"Prematch {spec.prematch!r} does not match {raw_hex}") + + verify_checksum(spec, raw_hex) + + bits = message.metadata.get("bits") or hex_to_bits(raw_hex) + + variant_key: Optional[str] = None + if spec.variant_selector: + try: + variant_key = str(evaluate_field(spec.variant_selector, bits, raw_hex)) + except FieldError as error: + raise DecodeError(f"Variant selector failed: {error}") from error + if variant_key not in spec.variants: + raise DecodeError(f"No variant '{variant_key}' in {sorted(spec.variants)}") + + values: dict[str, Any] = {} + units: dict[str, str] = {} + for name, rule in spec.fields_for(variant_key).items(): + try: + values[name] = evaluate_field(rule, bits, raw_hex, values) + except FieldError as error: + raise DecodeError(f"Field '{name}': {error}") from error + if "unit" in rule: + units[name] = rule["unit"] + + _check_limits(values, spec.limits_for(variant_key)) + + model = spec.model + sensor_type = spec.sensor_type + if variant_key is not None: + block = spec.variants[variant_key] + model = block.get("model", model) + sensor_type = block.get("sensor_type", sensor_type) + + sensor_id = str(values.get(spec.id_field, "")) + channel = values.get(spec.channel_field) + channel = int(channel) if isinstance(channel, (int, float)) else None + + device_id = f"{model}_{sensor_id}" if sensor_id else model + if channel is not None: + device_id = f"{device_id}_{channel}" + + return SensorEvent( + protocol_id=spec.protocol_id, + model=model, + sensor_type=sensor_type, + device_id=device_id, + sensor_id=sensor_id, + values=values, + units=units, + channel=channel, + raw_hex=raw_hex, + dmsg=message.payload, + rssi=message.metadata.get("rssi"), + ) + + +class SensorDecoder: + """Decodes messages using whatever decoders a registry provides.""" + + def __init__(self, registry=None, logger: Optional[logging.Logger] = None): + from .registry import DecoderRegistry # circular at module level + + self.registry = registry if registry is not None else DecoderRegistry() + self.logger = logger or logging.getLogger(__name__) + + def decode(self, message: DecodedMessage) -> Optional[SensorEvent]: + """Returns the sensor values of a message, or None. + + Never raises. A missing decoder, a failing checksum and a broken + specification all end up as None so that stage 1 keeps working. + """ + try: + decoder = self.registry.get(message.protocol_id) + if decoder is None: + return None + return decoder(message) + except DecodeError as error: + self.logger.debug( + "Protocol %s not decoded: %s", message.protocol_id, error + ) + return None + except Exception: # noqa: BLE001 - stage 2 must never break stage 1 + self.logger.exception( + "Decoder for protocol %s raised unexpectedly", message.protocol_id + ) + return None + + def attach(self, message: DecodedMessage) -> DecodedMessage: + """Decodes and stores the result on the message.""" + message.sensor = self.decode(message) + return message diff --git a/signalduino/decoders/registry.py b/signalduino/decoders/registry.py new file mode 100644 index 0000000..a4c276b --- /dev/null +++ b/signalduino/decoders/registry.py @@ -0,0 +1,151 @@ +"""Finds the decoder for a protocol (ADR-006). + +Two kinds of decoder end up here: specifications from specs/*.json, and Python +decoders registered from custom/ for the protocols whose logic does not fit a +declarative description. Custom decoders win over a specification with the same +id, so a protocol can be moved from one to the other without deleting anything. + +Routing is by protocol id, which the message already carries. The preamble and +modulematch from protocols.json are used for stripping and as the default +prematch, but never to find the decoder. +""" + +from __future__ import annotations + +import importlib +import logging +from pathlib import Path +from typing import Callable, Optional + +from ..types import DecodedMessage, SensorEvent +from .pipeline import decode_with_spec +from .spec import DecoderSpec, SpecError, load_file + +SPECS_DIR = Path(__file__).parent / "specs" + +Decoder = Callable[[DecodedMessage], SensorEvent] + +_CUSTOM: dict[str, Decoder] = {} + + +def register_decoder(protocol_id: str) -> Callable[[Decoder], Decoder]: + """Registers a Python decoder for a protocol id. + + Used by modules under custom/ for protocols that a specification cannot + express, for example when fields depend on each other or the checksum is + computed over a reordered payload. + """ + def wrapper(func: Decoder) -> Decoder: + _CUSTOM[str(protocol_id)] = func + return func + return wrapper + + +def registered_custom() -> dict[str, Decoder]: + """The custom decorators registered so far.""" + return dict(_CUSTOM) + + +class DecoderRegistry: + """Holds the decoders available at runtime.""" + + def __init__(self, specs_dir: Optional[Path] = None, + protocols=None, + logger: Optional[logging.Logger] = None, + load: bool = True): + self.specs_dir = specs_dir if specs_dir is not None else SPECS_DIR + self.protocols = protocols + self.logger = logger or logging.getLogger(__name__) + self.specs: dict[str, DecoderSpec] = {} + self.custom: dict[str, Decoder] = {} + self.errors: list[str] = [] + if load: + self.load() + + def load(self) -> None: + """Loads all specifications and picks up the custom decoders.""" + self.specs.clear() + self.errors.clear() + + # Importing the package runs the @register_decoder decorators in it. + try: + importlib.import_module(f"{__package__}.custom") + except ImportError as error: + self.errors.append(f"custom decoders not loaded: {error}") + self.logger.error("Could not import custom decoders: %s", error) + + if self.specs_dir.is_dir(): + for path in sorted(self.specs_dir.glob("*.json")): + try: + spec = load_file(path) + except SpecError as error: + # A broken specification must not take the others down. + self.errors.append(str(error)) + self.logger.error("Ignoring decoder specification: %s", error) + continue + if spec.protocol_id in self.specs: + self.errors.append( + f"{path.name}: protocol {spec.protocol_id} already defined by " + f"{self.specs[spec.protocol_id].source}" + ) + self.logger.warning("%s", self.errors[-1]) + continue + self.specs[spec.protocol_id] = spec + + self.custom = registered_custom() + for protocol_id in sorted(set(self.custom) & set(self.specs)): + self.logger.info( + "Protocol %s has both a specification and a custom decoder, using the custom one", + protocol_id, + ) + + def _preamble(self, protocol_id: str) -> str: + if self.protocols is None: + return "" + try: + return self.protocols.check_property(protocol_id, "preamble", "") or "" + except Exception: # noqa: BLE001 - protocol data must not break decoding + self.logger.debug("No preamble for protocol %s", protocol_id) + return "" + + def _default_prematch(self, protocol_id: str) -> Optional[str]: + if self.protocols is None: + return None + try: + return self.protocols.check_property(protocol_id, "modulematch", None) + except Exception: # noqa: BLE001 + return None + + def get(self, protocol_id: str) -> Optional[Decoder]: + """The decoder for a protocol, or None if there is none.""" + protocol_id = str(protocol_id) + + custom = self.custom.get(protocol_id) + if custom is not None: + return custom + + spec = self.specs.get(protocol_id) + if spec is None: + return None + + preamble = self._preamble(protocol_id) + + def decode(message: DecodedMessage) -> SensorEvent: + return decode_with_spec(spec, message, preamble) + + return decode + + @property + def protocol_ids(self) -> list[str]: + """All protocol ids that can be decoded, specifications and custom.""" + return sorted(set(self.specs) | set(self.custom), key=int) + + def coverage(self, total: Optional[int] = None) -> str: + """A short 'x of y protocols' line for logs and reports.""" + covered = len(self.protocol_ids) + if total is None and self.protocols is not None: + try: + total = len(self.protocols.protocols) + except Exception: # noqa: BLE001 + total = None + return f"{covered} of {total} protocols" if total else f"{covered} protocols" diff --git a/signalduino/decoders/spec.py b/signalduino/decoders/spec.py new file mode 100644 index 0000000..707c719 --- /dev/null +++ b/signalduino/decoders/spec.py @@ -0,0 +1,110 @@ +"""Loading and validation of decoder specifications (ADR-006). + +A specification is JSON validated against spec_schema.json. Validating at load +time rather than at decode time means a typo surfaces once at startup with the +offending path named, instead of silently dropping a field of every telegram. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field as dataclass_field +from functools import lru_cache +from pathlib import Path +from typing import Any, Optional + +import jsonschema + +SCHEMA_PATH = Path(__file__).parent / "spec_schema.json" + + +class SpecError(ValueError): + """A specification is malformed.""" + + +@lru_cache(maxsize=1) +def _schema() -> dict: + with SCHEMA_PATH.open(encoding="utf-8") as handle: + return json.load(handle) + + +@dataclass(frozen=True) +class DecoderSpec: + """One protocol's decoding rules.""" + + protocol_id: str + model: str + fields: dict[str, dict] + sensor_type: str = "" + prematch: Optional[str] = None + crc: Optional[dict] = None + variant_selector: Optional[dict] = None + variants: dict[str, dict] = dataclass_field(default_factory=dict) + limits: dict[str, list] = dataclass_field(default_factory=dict) + id_field: str = "id" + channel_field: str = "channel" + source: str = "" + + def variant(self, key: str) -> Optional[dict]: + """The variant block for a selector value, if one is defined.""" + return self.variants.get(key) + + def fields_for(self, variant_key: Optional[str]) -> dict[str, dict]: + """Common fields, overlaid with the selected variant's fields.""" + merged = dict(self.fields) + if variant_key is not None: + block = self.variants.get(variant_key) + if block: + merged.update(block.get("fields", {})) + return merged + + def limits_for(self, variant_key: Optional[str]) -> dict[str, list]: + merged = dict(self.limits) + if variant_key is not None: + block = self.variants.get(variant_key) + if block: + merged.update(block.get("limits", {})) + return merged + + +def validate(document: dict, source: str = "") -> None: + """Validates a raw specification document. + + Raises: + SpecError: with the failing property path. + """ + try: + jsonschema.validate(instance=document, schema=_schema()) + except jsonschema.ValidationError as error: + location = "/".join(str(part) for part in error.absolute_path) or "" + raise SpecError(f"{source}: invalid at '{location}': {error.message}") from error + + if "variants" in document and "variant_selector" not in document: + raise SpecError(f"{source}: variants require a variant_selector") + + +def from_dict(document: dict, source: str = "") -> DecoderSpec: + """Validates and converts a raw document into a DecoderSpec.""" + validate(document, source) + known = { + "protocol_id", "model", "sensor_type", "prematch", "crc", + "variant_selector", "variants", "fields", "limits", + "id_field", "channel_field", + } + payload: dict[str, Any] = {k: v for k, v in document.items() if k in known} + payload.setdefault("sensor_type", "") + return DecoderSpec(source=source, **payload) + + +def load_file(path: Path) -> DecoderSpec: + """Loads one specification file. + + Raises: + SpecError: if the file is not readable JSON or fails validation. + """ + try: + with path.open(encoding="utf-8") as handle: + document = json.load(handle) + except json.JSONDecodeError as error: + raise SpecError(f"{path.name}: not valid JSON: {error}") from error + return from_dict(document, source=path.name) diff --git a/signalduino/decoders/spec_schema.json b/signalduino/decoders/spec_schema.json new file mode 100644 index 0000000..61ce969 --- /dev/null +++ b/signalduino/decoders/spec_schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/RFD-FHEM/PySignalduino/decoders/spec_schema.json", + "title": "Decoder specification", + "description": "Declarative description of how one protocol's payload becomes sensor values (ADR-006).", + "type": "object", + "required": ["protocol_id", "model", "fields"], + "additionalProperties": false, + "properties": { + "protocol_id": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Protocol id as used in protocols.json." + }, + "model": { + "type": "string", + "description": "Model name, kept identical to the FHEM one so device ids match." + }, + "sensor_type": { + "type": "string", + "description": "Human readable list of devices this covers." + }, + "comment": {"type": "string"}, + "prematch": { + "type": "string", + "format": "regex", + "description": "Regex the raw hex must match. Defaults to modulematch from protocols.json." + }, + "id_field": { + "type": "string", + "default": "id", + "description": "Which decoded field identifies the physical sensor." + }, + "channel_field": { + "type": "string", + "default": "channel", + "description": "Which decoded field carries the channel, if any." + }, + "crc": {"$ref": "#/definitions/crc"}, + "variant_selector": {"$ref": "#/definitions/field"}, + "variants": { + "type": "object", + "description": "Keyed by the variant selector's value; each holds additional fields.", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "model": {"type": "string"}, + "sensor_type": {"type": "string"}, + "comment": {"type": "string"}, + "fields": {"$ref": "#/definitions/fields"}, + "limits": {"$ref": "#/definitions/limits"} + } + } + }, + "fields": {"$ref": "#/definitions/fields"}, + "limits": {"$ref": "#/definitions/limits"} + }, + "definitions": { + "fields": { + "type": "object", + "description": "Reading name to extraction rule. Reading names follow the FHEM ones.", + "minProperties": 1, + "additionalProperties": {"$ref": "#/definitions/field"} + }, + "field": { + "type": "object", + "required": ["source", "from", "to"], + "additionalProperties": false, + "properties": { + "source": { + "enum": ["bits", "hex"], + "description": "bits indexes the demodulated bit string, hex the payload characters." + }, + "from": {"type": "integer", "minimum": 0, "description": "First index, inclusive."}, + "to": {"type": "integer", "minimum": 0, "description": "Last index, inclusive."}, + "type": { + "enum": ["int", "float", "str", "bool"], + "default": "int", + "description": "str keeps the raw slice unless the rule also asks for arithmetic (scale, offset, sign_bit, round, bcd, map, derive), in which case the computed value is stringified." + }, + "bcd": { + "type": "boolean", + "default": false, + "description": "Read the slice as binary coded decimal." + }, + "sign_bit": { + "type": "integer", + "minimum": 0, + "description": "Index of a separate sign bit, in the same source as the value." + }, + "sign_style": { + "enum": ["negate", "offset", "twos_complement"], + "default": "negate", + "description": "How the sign bit turns the value negative." + }, + "sign_offset": { + "type": "integer", + "description": "Subtracted from the value when sign_style is offset, e.g. 1024." + }, + "sign_value": { + "enum": ["0", "1"], + "default": "1", + "description": "Which sign bit value means negative." + }, + "offset": {"type": "number", "default": 0, "description": "Added before scaling."}, + "scale": {"type": "number", "default": 1, "description": "Applied after the offset."}, + "round": {"type": "integer", "minimum": 0, "description": "Decimal places of the result."}, + "map": { + "type": "object", + "description": "Maps the raw value, as a string, onto a final value.", + "additionalProperties": true + }, + "derive": { + "type": "string", + "description": "Name of a derivation applied after extraction, e.g. wind_dir_text." + }, + "derive_from": { + "type": "string", + "description": "Field the derivation reads instead of this one's own slice." + }, + "unit": {"type": "string", "description": "Unit reported alongside the value."} + } + }, + "crc": { + "type": "object", + "required": ["algorithm", "data", "check"], + "additionalProperties": false, + "properties": { + "algorithm": { + "enum": [ + "crc8", "crc16", "crc16lsb", "sum8", "xor8", + "lfsr_digest8", "lfsr_digest8_reflect" + ] + }, + "data": {"$ref": "#/definitions/hexRange"}, + "check": {"$ref": "#/definitions/hexRange"}, + "params": { + "type": "object", + "description": "Algorithm parameters such as poly, init, gen or key.", + "additionalProperties": {"type": ["integer", "boolean"]} + } + } + }, + "hexRange": { + "type": "object", + "required": ["from", "to"], + "additionalProperties": false, + "description": "Inclusive character range in the raw hex payload.", + "properties": { + "from": {"type": "integer", "minimum": 0}, + "to": {"type": "integer", "minimum": 0} + } + }, + "limits": { + "type": "object", + "description": "Plausibility range per field. A violation discards the whole telegram.", + "additionalProperties": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2 + } + } + } +} diff --git a/signalduino/decoders/specs/.gitkeep b/signalduino/decoders/specs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_decoder_crc.py b/tests/test_decoder_crc.py new file mode 100644 index 0000000..93e7858 --- /dev/null +++ b/tests/test_decoder_crc.py @@ -0,0 +1,117 @@ +"""Checksum algorithms of the decoding layer (ADR-006).""" + +from __future__ import annotations + +import pytest + +from signalduino.decoders import crc + + +class TestCrc8: + """CRC-8, the most common check across the SD_WS protocols.""" + + def test_nrsc5_check_value(self): + """CRC-8/NRSC-5: poly 0x31, init 0xFF - catalogue check value 0xF7.""" + assert crc.crc8(b"123456789", poly=0x31, init=0xFF) == 0xF7 + + def test_maxim_check_value(self): + """CRC-8/MAXIM: poly 0x31 reflected in and out - catalogue check value 0xA1.""" + assert crc.crc8( + b"123456789", poly=0x31, init=0x00, reflect_in=True, reflect_out=True + ) == 0xA1 + + def test_default_parameters_match_the_sensor_variant(self): + """The default (poly 0x31, init 0, unreflected) is what the sensors use.""" + assert crc.crc8(b"123456789") == 0xA2 + + def test_matches_the_existing_helper_implementation(self): + """Same result as the hand rolled loop in helpers.py ConvLaCrosse.""" + data = bytes.fromhex("9E1A1837") + expected = 0x00 + for byte in data: + expected ^= byte + for _ in range(8): + expected = ((expected << 1) ^ 0x31) & 0xFF if expected & 0x80 else (expected << 1) & 0xFF + assert crc.crc8(data) == expected + + def test_empty_data_returns_init(self): + assert crc.crc8(b"", init=0x2A) == 0x2A + + def test_xor_out_is_applied(self): + plain = crc.crc8(b"\x01\x02") + assert crc.crc8(b"\x01\x02", xor_out=0xFF) == plain ^ 0xFF + + def test_reflection_changes_the_result(self): + data = bytes.fromhex("0123") + assert crc.crc8(data, reflect_in=True) != crc.crc8(data) + + +class TestSumAndXor: + + def test_sum8_truncates_to_one_byte(self): + assert crc.sum8(bytes([0xFF, 0x02])) == 0x01 + + def test_sum8_honours_init(self): + assert crc.sum8(b"\x10", init=0x05) == 0x15 + + def test_xor8_of_a_value_with_itself_is_zero(self): + assert crc.xor8(bytes([0xA5, 0xA5])) == 0x00 + + def test_xor8_single_byte(self): + assert crc.xor8(b"\x5A") == 0x5A + + +class TestLfsrDigest: + + def test_digest_is_deterministic(self): + data = bytes.fromhex("AABBCC") + assert crc.lfsr_digest8(data) == crc.lfsr_digest8(data) + + def test_zero_data_gives_zero(self): + assert crc.lfsr_digest8(b"\x00\x00") == 0 + + def test_reflect_variant_differs_from_plain(self): + data = bytes.fromhex("A1B2C3") + assert crc.lfsr_digest8(data) != crc.lfsr_digest8_reflect(data) + + def test_key_changes_the_digest(self): + data = bytes.fromhex("A1B2") + assert crc.lfsr_digest8(data, key=0xF4) != crc.lfsr_digest8(data, key=0x31) + + +class TestCrc16: + + def test_ccitt_false_check_value(self): + """CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF - check value 0x29B1.""" + assert crc.crc16(b"123456789", poly=0x1021, init=0xFFFF) == 0x29B1 + + def test_arc_check_value(self): + """CRC-16/ARC: poly 0x8005, init 0, reflected - check value 0xBB3D.""" + assert crc.crc16( + b"123456789", poly=0x8005, init=0x0000, reflect_in=True, reflect_out=True + ) == 0xBB3D + + def test_lsb_variant_is_deterministic(self): + data = bytes.fromhex("DEADBEEF") + assert crc.crc16lsb(data) == crc.crc16lsb(data) + + def test_result_stays_within_16_bits(self): + assert 0 <= crc.crc16lsb(bytes(range(16))) <= 0xFFFF + + +class TestCompute: + + def test_dispatches_by_name(self): + assert crc.compute("crc8", b"123456789", poly=0x31) == crc.crc8(b"123456789") + + def test_passes_parameters_through(self): + assert crc.compute("sum8", b"\x10", init=0x05) == 0x15 + + def test_unknown_algorithm_names_the_known_ones(self): + with pytest.raises(KeyError) as excinfo: + crc.compute("md5", b"") + assert "crc8" in str(excinfo.value) + + def test_every_algorithm_is_callable(self): + for name in crc.ALGORITHMS: + assert isinstance(crc.compute(name, b"\x01\x02"), int) diff --git a/tests/test_decoder_dsl.py b/tests/test_decoder_dsl.py new file mode 100644 index 0000000..757c265 --- /dev/null +++ b/tests/test_decoder_dsl.py @@ -0,0 +1,185 @@ +"""Field rule evaluation of the decoding layer (ADR-006). + +Indices are inclusive and zero based, matching SD_WS_binaryToNumber in FHEM. +""" + +from __future__ import annotations + +import pytest + +from signalduino.decoders.dsl import FieldError, evaluate_field + +# 0x2A = 0010 1010, 0xF0 = 1111 0000 +BITS = "0010101011110000" +HEX = "2AF0" + + +def field(**kwargs): + kwargs.setdefault("source", "bits") + return kwargs + + +class TestExtraction: + + def test_reads_a_bit_range_inclusively(self): + # bits 0..7 are 0x2A + assert evaluate_field(field(**{"from": 0, "to": 7}), BITS, HEX) == 0x2A + + def test_single_bit(self): + assert evaluate_field(field(**{"from": 2, "to": 2}), BITS, HEX) == 1 + + def test_reads_hex_characters(self): + rule = field(source="hex", **{"from": 0, "to": 1}) + assert evaluate_field(rule, BITS, HEX) == 0x2A + + def test_hex_as_string_keeps_the_slice(self): + rule = field(source="hex", type="str", **{"from": 2, "to": 3}) + assert evaluate_field(rule, BITS, HEX) == "F0" + + def test_range_beyond_the_message_is_an_error(self): + with pytest.raises(FieldError, match="exceeds"): + evaluate_field(field(**{"from": 0, "to": 99}), BITS, HEX) + + def test_reversed_range_is_an_error(self): + with pytest.raises(FieldError, match="behind"): + evaluate_field(field(**{"from": 8, "to": 2}), BITS, HEX) + + +class TestScalingAndOffset: + + def test_offset_is_applied_before_scale(self): + # (0x2A + (-40)) * 0.5 = 1.0 + rule = field(type="float", offset=-40, scale=0.5, **{"from": 0, "to": 7}) + assert evaluate_field(rule, BITS, HEX) == 1.0 + + def test_scale_alone(self): + rule = field(type="float", scale=0.1, **{"from": 0, "to": 7}) + assert evaluate_field(rule, BITS, HEX) == pytest.approx(4.2) + + def test_scaling_does_not_leave_binary_noise(self): + """0.1 scaling must produce 21.0, not 21.000000000000004.""" + bits = f"{210:016b}" + rule = field(type="float", scale=0.1, **{"from": 0, "to": 15}) + assert str(evaluate_field(rule, bits, "")) == "21.0" + + def test_round_limits_the_decimals(self): + rule = field(type="float", scale=1 / 3, round=2, **{"from": 0, "to": 7}) + assert evaluate_field(rule, BITS, HEX) == 14.0 + + def test_non_integer_value_rejected_for_int_type(self): + rule = field(type="int", scale=0.3, **{"from": 0, "to": 7}) + with pytest.raises(FieldError, match="not an integer"): + evaluate_field(rule, BITS, HEX) + + +class TestSign: + + def test_offset_style_subtracts_when_the_sign_bit_is_set(self): + """The FHEM idiom: value - 1024 when the sign bit says negative.""" + bits = "1" + f"{1000:010b}" # sign bit set, value 1000 + rule = field( + type="float", scale=0.1, sign_bit=0, sign_style="offset", + sign_offset=1024, **{"from": 1, "to": 10}, + ) + assert evaluate_field(rule, bits, "") == pytest.approx(-2.4) + + def test_offset_style_leaves_positive_values_alone(self): + bits = "0" + f"{210:010b}" + rule = field( + type="float", scale=0.1, sign_bit=0, sign_style="offset", + sign_offset=1024, **{"from": 1, "to": 10}, + ) + assert evaluate_field(rule, bits, "") == pytest.approx(21.0) + + def test_negate_style(self): + bits = "1" + f"{50:08b}" + rule = field(sign_bit=0, sign_style="negate", **{"from": 1, "to": 8}) + assert evaluate_field(rule, bits, "") == -50 + + def test_twos_complement(self): + bits = f"{0b11111011:08b}" # -5 in eight bit two's complement + rule = field(sign_bit=0, sign_style="twos_complement", **{"from": 0, "to": 7}) + assert evaluate_field(rule, bits, "") == -5 + + def test_sign_value_zero_means_negative(self): + bits = "0" + f"{50:08b}" + rule = field(sign_bit=0, sign_value="0", **{"from": 1, "to": 8}) + assert evaluate_field(rule, bits, "") == -50 + + def test_offset_style_without_sign_offset_is_an_error(self): + bits = "1" + f"{50:08b}" + rule = field(sign_bit=0, sign_style="offset", **{"from": 1, "to": 8}) + with pytest.raises(FieldError, match="sign_offset"): + evaluate_field(rule, bits, "") + + def test_sign_bit_on_hex_source_is_rejected(self): + rule = field(source="hex", sign_bit=0, **{"from": 0, "to": 1}) + with pytest.raises(FieldError, match="only meaningful on bits"): + evaluate_field(rule, BITS, HEX) + + +class TestBcd: + + def test_bcd_on_bits(self): + bits = "0101" + "0101" # 55 in BCD + rule = field(bcd=True, **{"from": 0, "to": 7}) + assert evaluate_field(rule, bits, "") == 55 + + def test_bcd_on_hex(self): + rule = field(source="hex", bcd=True, **{"from": 0, "to": 1}) + assert evaluate_field(rule, "", "25") == 25 + + def test_invalid_bcd_digit_is_an_error(self): + rule = field(source="hex", bcd=True, **{"from": 0, "to": 1}) + with pytest.raises(FieldError, match="valid BCD"): + evaluate_field(rule, "", "2F") + + def test_bcd_on_bits_needs_whole_nibbles(self): + rule = field(bcd=True, **{"from": 0, "to": 5}) + with pytest.raises(FieldError, match="multiple of four"): + evaluate_field(rule, BITS, HEX) + + +class TestMapping: + + def test_maps_the_raw_value(self): + rule = field(map={"0": "ok", "1": "low"}, **{"from": 2, "to": 2}) + assert evaluate_field(rule, BITS, HEX) == "low" + + def test_unmapped_value_is_an_error(self): + rule = field(map={"7": "seven"}, **{"from": 2, "to": 2}) + with pytest.raises(FieldError, match="No mapping"): + evaluate_field(rule, BITS, HEX) + + +class TestDerivations: + + @pytest.mark.parametrize("degrees,expected", [ + (0, "N"), (90, "E"), (180, "S"), (270, "W"), + (22.5, "NNE"), (350, "N"), (359, "N"), + ]) + def test_wind_direction_text(self, degrees, expected): + rule = {"derive": "wind_dir_text", "derive_from": "windDirectionDegree"} + decoded = {"windDirectionDegree": degrees} + assert evaluate_field(rule, "", "", decoded) == expected + + def test_derive_from_an_unknown_field_is_an_error(self): + rule = {"derive": "wind_dir_text", "derive_from": "missing"} + with pytest.raises(FieldError, match="unknown field"): + evaluate_field(rule, "", "", {}) + + def test_unknown_derivation_is_an_error(self): + rule = {"derive": "moon_phase", "derive_from": "value"} + with pytest.raises(FieldError, match="Unknown derivation"): + evaluate_field(rule, "", "", {"value": 1}) + + +class TestTypes: + + def test_bool_type(self): + rule = field(type="bool", **{"from": 2, "to": 2}) + assert evaluate_field(rule, BITS, HEX) is True + + def test_str_type_of_a_computed_value(self): + rule = field(type="str", scale=2, **{"from": 0, "to": 7}) + assert evaluate_field(rule, BITS, HEX) == "84" diff --git a/tests/test_decoder_pipeline.py b/tests/test_decoder_pipeline.py new file mode 100644 index 0000000..23e7f7a --- /dev/null +++ b/tests/test_decoder_pipeline.py @@ -0,0 +1,214 @@ +"""The decoding pipeline from message to SensorEvent (ADR-006). + +These tests use a synthetic protocol rather than a real one: phase 2 delivers +the machinery, and the first real specification follows in phase 3. The point +here is that the steps happen in the right order and that a failure anywhere +returns None instead of reaching the caller. +""" + +from __future__ import annotations + +import logging + +import pytest + +from signalduino.decoders import spec as spec_module +from signalduino.decoders.crc import crc8 +from signalduino.decoders.pipeline import ( + DecodeError, + SensorDecoder, + decode_with_spec, + hex_to_bits, + strip_preamble, +) +from signalduino.decoders.registry import DecoderRegistry +from signalduino.types import DecodedMessage, RawFrame + +# Synthetic payload: 2 bytes id, 1 byte value, 1 byte CRC8 over the first three. +PAYLOAD_BODY = "1234A0" +PAYLOAD = PAYLOAD_BODY + f"{crc8(bytes.fromhex(PAYLOAD_BODY)):02X}" + +SPEC_DOCUMENT = { + "protocol_id": "900", + "model": "TEST_900", + "sensor_type": "Synthetic test device", + "prematch": "^12", + "crc": { + "algorithm": "crc8", + "data": {"from": 0, "to": 5}, + "check": {"from": 6, "to": 7}, + }, + "fields": { + "id": {"source": "hex", "from": 0, "to": 1, "type": "str"}, + "channel": {"source": "hex", "from": 2, "to": 2}, + "temperature": { + "source": "hex", "from": 4, "to": 5, + "type": "float", "scale": 0.5, "unit": "°C", + }, + }, + "limits": {"temperature": [-40, 100]}, +} + + +def message(payload=None, bits=None, protocol_id="900"): + return DecodedMessage( + protocol_id=protocol_id, + payload=payload if payload is not None else f"W900#{PAYLOAD}", + raw=RawFrame(line="raw"), + metadata={"bits": bits, "rssi": -60.5} if bits else {"rssi": -60.5}, + ) + + +@pytest.fixture +def spec(): + return spec_module.from_dict(SPEC_DOCUMENT, "test") + + +class TestHelpers: + + def test_strip_preamble_removes_a_known_preamble(self): + assert strip_preamble("W125#ABCD", "W125#") == "ABCD" + + def test_strip_preamble_falls_back_to_the_hash(self): + assert strip_preamble("W125#ABCD", "") == "ABCD" + + def test_strip_preamble_leaves_a_plain_payload_alone(self): + assert strip_preamble("ABCD", "") == "ABCD" + + def test_hex_to_bits_expands_every_nibble(self): + assert hex_to_bits("2A") == "00101010" + + def test_hex_to_bits_rejects_non_hex(self): + with pytest.raises(DecodeError, match="not hex"): + hex_to_bits("XY") + + +class TestDecodeWithSpec: + + def test_decodes_all_fields(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.values == {"id": "12", "channel": 3, "temperature": 80.0} + + def test_reports_units(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.units == {"temperature": "°C"} + + def test_builds_the_device_id_from_model_id_and_channel(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.device_id == "TEST_900_12_3" + + def test_carries_metadata_through(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.protocol_id == "900" + assert event.sensor_type == "Synthetic test device" + assert event.raw_hex == PAYLOAD + assert event.dmsg == f"W900#{PAYLOAD}" + assert event.rssi == -60.5 + + def test_uses_the_bit_string_when_present(self, spec): + document = dict(SPEC_DOCUMENT, fields={ + "id": {"source": "bits", "from": 0, "to": 7}, + }) + bit_spec = spec_module.from_dict(document, "test") + event = decode_with_spec(bit_spec, message(bits="11110000"), "W900#") + assert event.values["id"] == 240 + + def test_falls_back_to_bits_derived_from_hex(self, spec): + """MN telegrams arrive as hex and carry no bit string of their own.""" + document = dict(SPEC_DOCUMENT, crc=None, fields={ + "id": {"source": "bits", "from": 0, "to": 7}, + }) + document.pop("crc") + bit_spec = spec_module.from_dict(document, "test") + event = decode_with_spec(bit_spec, message(), "W900#") + assert event.values["id"] == 0x12 + + def test_failing_prematch_is_rejected(self, spec): + with pytest.raises(DecodeError, match="Prematch"): + decode_with_spec(spec, message(payload="W900#9934A0FF"), "W900#") + + def test_wrong_checksum_is_rejected(self, spec): + broken = f"W900#{PAYLOAD_BODY}FF" + with pytest.raises(DecodeError, match="mismatch"): + decode_with_spec(spec, message(payload=broken), "W900#") + + def test_value_outside_its_limits_discards_the_telegram(self): + document = dict(SPEC_DOCUMENT, limits={"temperature": [-40, 20]}) + narrow = spec_module.from_dict(document, "test") + with pytest.raises(DecodeError, match="outside"): + decode_with_spec(narrow, message(), "W900#") + + def test_empty_payload_is_rejected(self, spec): + with pytest.raises(DecodeError, match="Empty"): + decode_with_spec(spec, message(payload="W900#"), "W900#") + + +class TestVariants: + + @pytest.fixture + def spec(self): + document = dict( + SPEC_DOCUMENT, + variant_selector={"source": "hex", "from": 2, "to": 2}, + variants={ + "3": { + "model": "TEST_900_TH", + "fields": {"humidity": {"source": "hex", "from": 4, "to": 5}}, + }, + }, + ) + return spec_module.from_dict(document, "test") + + def test_variant_fields_are_decoded(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.values["humidity"] == 0xA0 + + def test_variant_overrides_the_model(self, spec): + event = decode_with_spec(spec, message(), "W900#") + assert event.model == "TEST_900_TH" + assert event.device_id.startswith("TEST_900_TH_") + + def test_unknown_variant_is_rejected(self, spec): + payload = "1274A0" + full = f"W900#{payload}{crc8(bytes.fromhex(payload)):02X}" + with pytest.raises(DecodeError, match="No variant"): + decode_with_spec(spec, message(payload=full), "W900#") + + +class TestSensorDecoder: + """The outer layer, which must never raise.""" + + @pytest.fixture + def decoder(self, tmp_path): + import json + (tmp_path / "test_900.json").write_text(json.dumps(SPEC_DOCUMENT), encoding="utf-8") + return SensorDecoder(registry=DecoderRegistry(specs_dir=tmp_path)) + + def test_decodes_a_known_protocol(self, decoder): + event = decoder.decode(message(payload=PAYLOAD)) + assert event is not None + assert event.values["temperature"] == 80.0 + + def test_unknown_protocol_yields_none(self, decoder): + assert decoder.decode(message(protocol_id="4711")) is None + + def test_bad_checksum_yields_none_instead_of_raising(self, decoder): + assert decoder.decode(message(payload=f"{PAYLOAD_BODY}FF")) is None + + def test_a_raising_decoder_is_contained(self, decoder, caplog): + def explode(_message): + raise RuntimeError("decoder is broken") + + decoder.registry.custom["900"] = explode + with caplog.at_level(logging.ERROR): + assert decoder.decode(message()) is None + assert "raised unexpectedly" in caplog.text + + def test_attach_stores_the_result_on_the_message(self, decoder): + result = decoder.attach(message(payload=PAYLOAD)) + assert result.sensor is not None + assert result.sensor.model == "TEST_900" + + def test_attach_sets_none_for_an_unknown_protocol(self, decoder): + result = decoder.attach(message(protocol_id="4711")) + assert result.sensor is None diff --git a/tests/test_decoder_registry.py b/tests/test_decoder_registry.py new file mode 100644 index 0000000..38d9658 --- /dev/null +++ b/tests/test_decoder_registry.py @@ -0,0 +1,187 @@ +"""Specification loading and decoder lookup (ADR-006).""" + +from __future__ import annotations + +import json + +import pytest + +from signalduino.decoders import spec as spec_module +from signalduino.decoders.registry import DecoderRegistry, register_decoder, registered_custom +from signalduino.decoders.spec import SpecError + +MINIMAL_SPEC = { + "protocol_id": "999", + "model": "TEST_999", + "fields": {"id": {"source": "hex", "from": 0, "to": 1, "type": "str"}}, +} + + +def write_spec(directory, name, document): + path = directory / name + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +class TestSpecValidation: + + def test_minimal_specification_is_accepted(self): + result = spec_module.from_dict(MINIMAL_SPEC, "test") + assert result.protocol_id == "999" + assert result.model == "TEST_999" + + def test_missing_required_property_names_it(self): + broken = {k: v for k, v in MINIMAL_SPEC.items() if k != "model"} + with pytest.raises(SpecError, match="model"): + spec_module.from_dict(broken, "broken") + + def test_unknown_property_is_rejected(self): + broken = dict(MINIMAL_SPEC, colour="blue") + with pytest.raises(SpecError): + spec_module.from_dict(broken, "broken") + + def test_error_names_the_failing_path(self): + broken = json.loads(json.dumps(MINIMAL_SPEC)) + broken["fields"]["id"]["source"] = "runes" + with pytest.raises(SpecError, match="fields/id/source"): + spec_module.from_dict(broken, "broken") + + def test_unknown_checksum_algorithm_is_rejected(self): + broken = dict( + MINIMAL_SPEC, + crc={"algorithm": "md5", "data": {"from": 0, "to": 1}, "check": {"from": 2, "to": 3}}, + ) + with pytest.raises(SpecError): + spec_module.from_dict(broken, "broken") + + def test_variants_without_a_selector_are_rejected(self): + """Otherwise the variants would silently never be reached.""" + broken = dict(MINIMAL_SPEC, variants={ + "1": {"fields": {"temperature": {"source": "bits", "from": 0, "to": 7}}} + }) + with pytest.raises(SpecError, match="variant_selector"): + spec_module.from_dict(broken, "broken") + + def test_invalid_json_is_reported_with_the_file_name(self, tmp_path): + path = tmp_path / "broken.json" + path.write_text("{not json", encoding="utf-8") + with pytest.raises(SpecError, match="broken.json"): + spec_module.load_file(path) + + +class TestVariantMerging: + + @pytest.fixture + def spec(self): + document = dict( + MINIMAL_SPEC, + variant_selector={"source": "hex", "from": 0, "to": 1}, + variants={ + "48": { + "model": "TEST_999_TH", + "fields": {"temperature": {"source": "bits", "from": 0, "to": 7}}, + "limits": {"temperature": [-40, 60]}, + } + }, + limits={"id": [0, 255]}, + ) + return spec_module.from_dict(document, "test") + + def test_common_fields_are_kept(self, spec): + assert "id" in spec.fields_for("48") + + def test_variant_fields_are_added(self, spec): + assert "temperature" in spec.fields_for("48") + + def test_without_a_variant_only_common_fields_remain(self, spec): + assert set(spec.fields_for(None)) == {"id"} + + def test_limits_are_merged(self, spec): + merged = spec.limits_for("48") + assert merged["id"] == [0, 255] + assert merged["temperature"] == [-40, 60] + + def test_unknown_variant_adds_nothing(self, spec): + assert set(spec.fields_for("99")) == {"id"} + + +class TestRegistryLoading: + + def test_loads_specifications_from_a_directory(self, tmp_path): + write_spec(tmp_path, "test_999.json", MINIMAL_SPEC) + registry = DecoderRegistry(specs_dir=tmp_path) + assert "999" in registry.specs + assert registry.get("999") is not None + + def test_missing_directory_is_not_an_error(self, tmp_path): + registry = DecoderRegistry(specs_dir=tmp_path / "absent") + assert registry.specs == {} + assert registry.errors == [] + + def test_unknown_protocol_returns_none(self, tmp_path): + registry = DecoderRegistry(specs_dir=tmp_path) + assert registry.get("4711") is None + + def test_a_broken_specification_does_not_hide_the_others(self, tmp_path): + write_spec(tmp_path, "good.json", MINIMAL_SPEC) + (tmp_path / "bad.json").write_text("{", encoding="utf-8") + registry = DecoderRegistry(specs_dir=tmp_path) + assert "999" in registry.specs + assert any("bad.json" in message for message in registry.errors) + + def test_duplicate_protocol_id_is_reported(self, tmp_path): + write_spec(tmp_path, "a.json", MINIMAL_SPEC) + write_spec(tmp_path, "b.json", dict(MINIMAL_SPEC, model="OTHER")) + registry = DecoderRegistry(specs_dir=tmp_path) + assert registry.specs["999"].model == "TEST_999" + assert any("already defined" in message for message in registry.errors) + + def test_protocol_ids_are_sorted_numerically(self, tmp_path): + write_spec(tmp_path, "a.json", dict(MINIMAL_SPEC, protocol_id="9")) + write_spec(tmp_path, "b.json", dict(MINIMAL_SPEC, protocol_id="125")) + registry = DecoderRegistry(specs_dir=tmp_path) + assert registry.protocol_ids == ["9", "125"] + + def test_coverage_reports_the_ratio(self, tmp_path): + write_spec(tmp_path, "a.json", MINIMAL_SPEC) + registry = DecoderRegistry(specs_dir=tmp_path) + assert registry.coverage(total=160) == "1 of 160 protocols" + + +class TestCustomDecoders: + + def test_a_custom_decoder_wins_over_a_specification(self, tmp_path): + write_spec(tmp_path, "test_999.json", MINIMAL_SPEC) + + marker = object() + + @register_decoder("999") + def _decode(message): + return marker + + try: + registry = DecoderRegistry(specs_dir=tmp_path) + assert registry.get("999")(None) is marker + finally: + registered_custom().pop("999", None) + from signalduino.decoders import registry as registry_module + registry_module._CUSTOM.pop("999", None) + + def test_custom_registration_is_keyed_by_string(self): + @register_decoder(998) + def _decode(message): + return None + + try: + assert "998" in registered_custom() + finally: + from signalduino.decoders import registry as registry_module + registry_module._CUSTOM.pop("998", None) + + +class TestShippedSpecifications: + """Whatever ends up in specs/ has to stay loadable.""" + + def test_the_shipped_directory_loads_without_errors(self): + registry = DecoderRegistry() + assert registry.errors == []