|
| 1 | += ADR 006: Sensorwert-Dekodierschicht (Stufe 2) |
| 2 | +:doctype: article :encoding: utf-8 :lang: de :status: Accepted :decided-at: 2026-09-16 :decided-by: Architecture Owner |
| 3 | + |
| 4 | +[NOTE] |
| 5 | +==== |
| 6 | +Die Nummer 007 ist auf dem Branch `feat/extendRAWmqtt` bereits für das MQTT-Raw-Kommando vergeben |
| 7 | +und bleibt reserviert. Dieses ADR füllt bewusst die Lücke 006, damit die Nummerierung lückenlos |
| 8 | +bleibt. |
| 9 | +==== |
| 10 | + |
| 11 | +[#adr-context] |
| 12 | +== Kontext |
| 13 | + |
| 14 | +PySignalduino deckt heute nur die erste Hälfte der Verarbeitungskette ab, die FHEM zweistufig löst: |
| 15 | + |
| 16 | +. *Stufe 1 -- Demodulation:* Pulse und Pausen werden zu einer Bitfolge und daraus zu einem |
| 17 | + Hex-Payload. Das leistet PySignalduino vollständig über `signalduino/parser/` und `sd_protocols/`. |
| 18 | +. *Stufe 2 -- Interpretation:* Aus dem Hex-Payload werden konkrete Messwerte -- Temperatur, |
| 19 | + Luftfeuchte, Batteriezustand, Sensor-ID, Kanal. Diese Stufe fehlt vollständig; in FHEM liegt sie |
| 20 | + in den Client-Modulen wie `14_SD_WS.pm`. |
| 21 | + |
| 22 | +Das Ergebnis der Verarbeitung ist heute `DecodedMessage(protocol_id, payload, raw, metadata)` aus |
| 23 | +`signalduino/types.py`, wobei `payload` ein Hex-String ist und `metadata` lediglich `bit_length`, |
| 24 | +`rssi` und `clock` enthält. Ein Feld für Messwerte existiert nicht. |
| 25 | +`sd_protocols/postdemodulation.py` prüft zwar Prüfsummen und Parität, liefert aber weiterhin nur |
| 26 | +bereinigte Bits -- das ist Validierung, keine Interpretation. |
| 27 | + |
| 28 | +Daraus folgt die praktische Einschränkung, die dieses ADR auflöst: PySignalduino ist faktisch nur |
| 29 | +mit FHEM nutzbar, weil erst FHEM aus den Rohdaten Messwerte macht. Andere Konsumenten wie Home |
| 30 | +Assistant, Node-RED oder ioBroker müssten die Dekodierlogik jeweils selbst nachbauen. |
| 31 | + |
| 32 | +Vorgearbeitet wurde bereits an drei Stellen: |
| 33 | + |
| 34 | +* `sd_protocols/protocols.json` enthält für 160 Protokolle die aus FHEM übernommenen Felder |
| 35 | + `clientmodule`, `preamble`, `modulematch` und `postamble` -- die Datengrundlage für das Routing |
| 36 | + einer zweiten Stufe ist also vorhanden. |
| 37 | +* `sd_protocols/helpers.py` berechnet in `ConvLaCrosse()` bereits echte Messwerte, serialisiert sie |
| 38 | + aber sofort wieder in einen FHEM-Legacy-String, sodass sie verloren gehen. |
| 39 | +* `docs/architecture/proposals/fhem_mqtt_integration.adoc` beschreibt dieses Vorhaben als |
| 40 | + "Option 4" und bewertet es als Gold-Standard mit sehr hohem Aufwand, ohne es zu entscheiden. |
| 41 | +
|
| 42 | +[#adr-decision] |
| 43 | +== Entscheidung |
| 44 | + |
| 45 | +Wir führen eine eigene Dekodierschicht `signalduino/decoders/` ein, die zwischen Parser und Ausgabe |
| 46 | +sitzt, sowie eine Adapterschicht `signalduino/output/`: |
| 47 | + |
| 48 | +---- |
| 49 | +transport -> parser (Stufe 1: Bits/Hex) -> decoders (Stufe 2: Werte) -> output (Adapter) -> mqtt |
| 50 | +---- |
| 51 | + |
| 52 | +*Ort der Schicht.* Die Dekodierung erfolgt weder in `sd_protocols/` noch im MQTT-Publisher. |
| 53 | +`sd_protocols/` ist die mechanische Portierung der Perl-Quellen und wird über `tools/convert.pl` |
| 54 | +regeneriert; dort abgelegter Code ginge beim nächsten Lauf verloren oder erzwänge Konfliktlösung. |
| 55 | +Der MQTT-Publisher scheidet aus, weil die Messwerte auch für `message_callback` und künftige |
| 56 | +Nicht-MQTT-Senken verfügbar sein müssen. Aufgerufen wird die Schicht in `signalduino/controller.py` |
| 57 | +innerhalb desselben `asyncio.to_thread`-Aufrufs wie der Parser, da es sich um CPU-gebundene |
| 58 | +Bitarithmetik handelt. |
| 59 | + |
| 60 | +*Datenmodell.* Eine neue Dataclass `SensorEvent` nimmt Modell, Sensortyp, Geräte-ID, Kanal, die |
| 61 | +Messwerte und deren Einheiten auf. `DecodedMessage` erhält genau ein zusätzliches, optionales Feld |
| 62 | +`sensor`. Damit bleiben alle bestehenden Signaturen und der bestehende Nachrichtenfluss unverändert. |
| 63 | +Die Schlüssel in `SensorEvent.values` sind die FHEM-Readingnamen (`temperature`, `humidity`, |
| 64 | +`batteryState`, `channel`, ...). Diese Namen sind für rund 150 Protokolle ein De-facto-Standard; |
| 65 | +sie als kanonische Schlüssel zu verwenden macht den Paritätstest gegen die FHEM-Testdaten zu einem |
| 66 | +direkten Vergleich und die Mapping-Tabellen der Ausgabeadapter wiederverwendbar. |
| 67 | + |
| 68 | +*Skalierung.* Die Dekodiervorschriften werden als validierte JSON-Spezifikationen abgelegt und von |
| 69 | +einem gemeinsamen Evaluator ausgewertet, statt pro Protokoll ein Python-Modul zu schreiben. Das |
| 70 | +Muster der FHEM-Client-Module ist überwiegend mechanisch -- Bitbereich, Skalierung, Offset, |
| 71 | +Vorzeichen, Wertetabelle -- und damit deklarativ abbildbar. JSON und nicht YAML, weil `jsonschema` |
| 72 | +bereits Abhängigkeit des Projekts ist. Für Protokolle, die sich nicht deklarativ beschreiben lassen, |
| 73 | +existiert eine Registrierung von Python-Dekodern als Ausweg. |
| 74 | + |
| 75 | +*Routing.* Primärschlüssel ist die `protocol_id`, die in `DecodedMessage` bereits vorliegt. FHEM |
| 76 | +muss den Umweg über Präambel-Strings gehen, weil seine `Dispatch()`-Funktion nur einen String kennt; |
| 77 | +in Python wäre das ein unnötiger Informationsverlust. Die Felder aus `protocols.json` werden |
| 78 | +weiterhin genutzt: `preamble` zum Abtrennen des Hex-Teils und zum Aufbau des Legacy-Strings, |
| 79 | +`modulematch` als Vorabprüfung, `clientmodule` zur Gruppierung. |
| 80 | + |
| 81 | +*Robustheit.* Stufe 2 darf Stufe 1 niemals beeinträchtigen. Der Dekodieraufruf ist vollständig |
| 82 | +gekapselt; schlägt er fehl, bleibt `sensor` leer und die bisherige Ausgabe unverändert. Für die |
| 83 | +überwiegende Mehrheit der Protokolle ist genau das zunächst der Normalfall. |
| 84 | + |
| 85 | +*Ausgabeformate.* Drei Adapter rendern dasselbe interne Modell in unterschiedliche Zielformate: |
| 86 | +FHEM-Legacy-String, rtl_433-kompatibles JSON und Home-Assistant-MQTT-Discovery. Die Adapter sind |
| 87 | +reine Funktionen ohne MQTT-Kenntnis und einzeln über Umgebungsvariablen zuschaltbar. |
| 88 | + |
| 89 | +*Topic-Struktur.* Die Topics trennen Ereignis von Zustand auf oberster Ebene: |
| 90 | + |
| 91 | +[cols="2,3,1", options="header"] |
| 92 | +|=== |
| 93 | +| Zweck | Topic | Retain |
| 94 | + |
| 95 | +| Stufe 1 (unverändert) |
| 96 | +| `signalduino/v1/state/messages` |
| 97 | +| nein |
| 98 | + |
| 99 | +| FHEM-Legacy (Ereignis) |
| 100 | +| `signalduino/v1/fhem/<clientmodule>/<protocol_id>` |
| 101 | +| nein |
| 102 | + |
| 103 | +| rtl_433 (Ereignis) |
| 104 | +| `signalduino/v1/rtl433/<model>/<sensor_id>` |
| 105 | +| nein |
| 106 | + |
| 107 | +| Kanonischer Zustand |
| 108 | +| `signalduino/v1/sensors/<device_id>` |
| 109 | +| ja |
| 110 | + |
| 111 | +| Home-Assistant-Discovery |
| 112 | +| `homeassistant/<component>/pysd_<device_id>/<field>/config` |
| 113 | +| ja |
| 114 | + |
| 115 | +| Verfügbarkeit (LWT) |
| 116 | +| `signalduino/v1/status` |
| 117 | +| ja |
| 118 | +|=== |
| 119 | + |
| 120 | +Ereignis-Topics tragen ein einzelnes Telegramm und werden nicht vorgehalten; Zustands-Topics tragen |
| 121 | +den letzten bekannten Wert eines Geräts und werden vorgehalten, damit ein neu verbundener Konsument |
| 122 | +sofort einen Wert sieht, statt auf das nächste Funktelegramm zu warten. Das bestehende |
| 123 | +`state/messages` bleibt bewusst unverändert, obwohl es nach dieser Einteilung ein Ereignis ist -- |
| 124 | +eine Umbenennung wäre ein Bruch für bestehende Konsumenten und bleibt einer Version 2 des |
| 125 | +Topic-Schemas vorbehalten. |
| 126 | + |
| 127 | +[#adr-consequences] |
| 128 | +== Konsequenzen |
| 129 | + |
| 130 | +=== Positive Konsequenzen |
| 131 | + |
| 132 | +* PySignalduino wird eigenständig nutzbar. Konsumenten erhalten unmittelbar verwertbare |
| 133 | + Schlüssel-Wert-Paare, ohne proprietäre Dekodierlogik im Zielsystem. |
| 134 | +* Home Assistant erkennt Sensoren über MQTT-Discovery automatisch; FHEM bleibt über den |
| 135 | + Legacy-Adapter vollständig bedienbar und verliert keine Funktion. |
| 136 | +* Ein neues Protokoll ist im Regelfall eine JSON-Datei plus ein Testvektor, kein Python-Code. Das |
| 137 | + ist die Voraussetzung dafür, perspektivisch Parität zu den FHEM-Client-Modulen zu erreichen. |
| 138 | +* Die FHEM-Testdaten unter `t/FHEM/<modul>/testData.json` enthalten neben den Rohtelegrammen auch |
| 139 | + die erwarteten Readings und dienen damit als unmittelbare Paritätsreferenz. |
| 140 | +* Der bestehende Nachrichtenfluss bleibt unangetastet; der FHEM-Adapter funktioniert ab dem ersten |
| 141 | + Tag für alle 160 Protokolle, weil er nicht von Stufe 2 abhängt. |
| 142 | + |
| 143 | +=== Negative Konsequenzen |
| 144 | + |
| 145 | +* Der Gesamtaufwand bis zur vollen Parität ist hoch; die Dekodierlogik von über 150 Protokollen |
| 146 | + wandert schrittweise in dieses Projekt und muss dort gepflegt werden. |
| 147 | +* Mit der Spezifikationssprache entsteht ein projekteigenes Format, das dokumentiert, versioniert |
| 148 | + und gegen ein Schema validiert werden muss. |
| 149 | +* Die Entscheidung, FHEM-Readingnamen als kanonische Schlüssel zu verwenden, bindet das Datenmodell |
| 150 | + an eine fremde Namenskonvention. Der Nutzen für Paritätstests und Migration überwiegt, die |
| 151 | + Abbildung auf andere Konventionen erfolgt in den Adaptern. |
| 152 | +* Solange für ein Protokoll keine Spezifikation existiert, liefert die Schicht keine Messwerte. Die |
| 153 | + Abdeckung wächst schrittweise und muss als Kennzahl sichtbar gemacht werden. |
| 154 | +* Zusätzliche Topics und vorgehaltene Discovery-Nachrichten erhöhen die Last auf dem Broker. |
| 155 | + |
| 156 | +[#adr-alternatives] |
| 157 | +== Alternativen |
| 158 | + |
| 159 | +* *Dekodierung in `sd_protocols/` (abgelehnt):* Naheliegend, weil dort bereits Protokollwissen |
| 160 | + liegt. Das Verzeichnis wird jedoch aus den Perl-Quellen generiert; neu geschriebener Code würde |
| 161 | + bei jeder Neugenerierung zum Konflikt. |
| 162 | +* *Dekodierung im MQTT-Publisher (abgelehnt):* Die Messwerte stünden dann ausschließlich dem |
| 163 | + MQTT-Pfad zur Verfügung und weder `message_callback` noch künftigen Senken. |
| 164 | +* *Ein Python-Modul pro Protokoll (abgelehnt):* Skaliert nicht auf über 150 Protokolle und führt zu |
| 165 | + massiver Codeverdopplung, da sich die Dekodierung überwiegend in Bitbereichen und Skalierungen |
| 166 | + erschöpft. |
| 167 | +* *Beibehaltung der FHEM-Bridge (Option 2 des Proposals, zurückgestellt):* Ein FHEM-Perl-Modul, das |
| 168 | + das JSON zurück in Dispatch-Strings übersetzt, wäre deutlich billiger, löst aber die |
| 169 | + Grundabhängigkeit von FHEM nicht auf und hilft anderen Zielsystemen nicht. Der Legacy-Adapter |
| 170 | + dieses ADR deckt den Kompatibilitätsbedarf ohne zusätzliches Perl-Modul ab. |
| 171 | +* *Neue Topic-Wurzel `events/` mit Migration von `state/messages` (zurückgestellt):* Semantisch |
| 172 | + sauberer, wäre aber ein Bruch für bestehende Konsumenten innerhalb von Version 1 des |
| 173 | + Topic-Schemas. Vorgesehen für Version 2. |
0 commit comments