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 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/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/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 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/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/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/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 -} 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/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_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 == [] 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())