Skip to content

Commit d782083

Browse files
author
sidey79
committed
feat(decoders): add the decoding core for sensor values
Implements the machinery ADR-006 describes, without any protocol yet: the first specification follows separately, so the core can be reviewed on its own merits. * crc.py holds the checksums once instead of once per client module, as pure functions over bytes. Verified against the published check values for CRC-8/NRSC-5, CRC-8/MAXIM, CRC-16/CCITT-FALSE and CRC-16/ARC. * dsl.py evaluates a field rule - bit or hex range, BCD, sign handling, offset, scale, rounding, value maps, derivations. Indices are inclusive and zero based so they can be copied straight from the FHEM comments. * spec.py and spec_schema.json define and validate the specification language. Validating at load time means a typo is reported once with its path, not silently dropped on every telegram. * registry.py resolves a protocol id to a decoder, preferring a registered Python decoder over a specification so a protocol can move between the two. A broken specification is logged and skipped instead of taking the others down with it. * pipeline.py runs the sequence from SD_WS_Parse: strip preamble, get bits, prematch, checksum, variant, fields, limits. SensorDecoder wraps it so that no failure can reach stage 1. Documented in docs/02_developer_guide/decoder_specs.adoc, including how to add a protocol and when to reach for a Python decoder instead.
1 parent 79502f7 commit d782083

16 files changed

Lines changed: 1824 additions & 1 deletion
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
= Decoder-Spezifikationen (Stufe 2)
2+
:sectlinks:
3+
4+
Der Empfang läuft in zwei Stufen, wie in link:../architecture/decisions/ADR-006-sensor-decoding-layer.adoc[ADR-006] beschrieben:
5+
6+
. *Stufe 1* — `signalduino/parser` und `sd_protocols` machen aus Pulsen eine Bitfolge und daraus einen Hex-Payload.
7+
. *Stufe 2* — `signalduino/decoders` macht daraus Messwerte: Temperatur, Luftfeuchte, Batteriezustand, Sensor-ID, Kanal.
8+
9+
Dieses Kapitel beschreibt, wie man Stufe 2 um ein Protokoll erweitert.
10+
11+
== Der Regelfall: eine JSON-Datei
12+
13+
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.
14+
15+
[source,json]
16+
----
17+
{
18+
"protocol_id": "900",
19+
"model": "TEST_900",
20+
"sensor_type": "Beispielgerät",
21+
"prematch": "^12",
22+
"crc": {
23+
"algorithm": "crc8",
24+
"data": { "from": 0, "to": 5 },
25+
"check": { "from": 6, "to": 7 }
26+
},
27+
"fields": {
28+
"id": { "source": "hex", "from": 0, "to": 1, "type": "str" },
29+
"channel": { "source": "bits", "from": 17, "to": 19, "offset": 1 },
30+
"temperature": { "source": "bits", "from": 22, "to": 31,
31+
"type": "float", "offset": -400, "scale": 0.1, "unit": "°C" }
32+
},
33+
"limits": { "temperature": [-40, 60] }
34+
}
35+
----
36+
37+
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.
38+
39+
== Indizes
40+
41+
`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.
42+
43+
`source` wählt die Datenquelle:
44+
45+
* `bits` — die demodulierte Bitfolge aus `metadata["bits"]`. Fehlt sie, etwa bei MN-Telegrammen, wird sie aus dem Hex-Payload erzeugt.
46+
* `hex` — die Zeichen des Hex-Payloads, ohne Präambel.
47+
48+
== Rechenschritte eines Feldes
49+
50+
Die Reihenfolge liegt fest und ist so gewählt, dass eine Regel sich liest wie die FHEM-Zeile, die sie ersetzt:
51+
52+
. Bereich ausschneiden
53+
. `bcd` — als binär codierte Dezimalzahl lesen
54+
. `sign_bit` mit `sign_style` anwenden
55+
. `offset` addieren
56+
. `scale` multiplizieren
57+
. `round` auf Nachkommastellen kürzen
58+
. `map` — Rohwert über eine Wertetabelle abbilden
59+
. `derive` — abgeleiteten Wert berechnen
60+
61+
[cols="1,3", options="header"]
62+
|===
63+
| Schlüssel | Bedeutung
64+
65+
| `type`
66+
| `int` (Vorgabe), `float`, `str`, `bool`. `str` liefert den Rohausschnitt, solange die Regel keine Rechenoperation enthält — typisch für Sensor-IDs.
67+
68+
| `sign_style`
69+
| `offset` zieht `sign_offset` ab (das FHEM-Idiom `wert - 1024`), `negate` kehrt das Vorzeichen um, `twos_complement` liest den Bereich im Zweierkomplement.
70+
71+
| `sign_value`
72+
| Welcher Wert des Vorzeichenbits negativ bedeutet, Vorgabe `"1"`.
73+
74+
| `map`
75+
| Bildet den Rohwert ab, z. B. `{"0": "ok", "1": "low"}` für den Batteriezustand.
76+
77+
| `derive`
78+
| Abgeleiteter Wert, derzeit `wind_dir_text`. Mit `derive_from` rechnet die Regel auf einem bereits dekodierten Feld statt auf einem eigenen Bereich.
79+
80+
| `unit`
81+
| Einheit, die im `SensorEvent` neben dem Wert geführt wird.
82+
|===
83+
84+
== Prüfsummen
85+
86+
`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.
87+
88+
Verfügbar sind `crc8`, `crc16`, `crc16lsb`, `sum8`, `xor8`, `lfsr_digest8` und `lfsr_digest8_reflect`. Abweichende Parameter stehen unter `params`, etwa `{"poly": 49, "init": 255}`.
89+
90+
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.
91+
92+
== Varianten
93+
94+
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.
95+
96+
[source,json]
97+
----
98+
{
99+
"variant_selector": { "source": "hex", "from": 0, "to": 1, "type": "str" },
100+
"fields": { "id": { "source": "hex", "from": 2, "to": 3, "type": "str" } },
101+
"variants": {
102+
"30": { "model": "SD_WS_125_TH", "fields": { "temperature": { "…": "…" } } },
103+
"52": { "model": "SD_WS_125_T", "fields": { "…": "…" } }
104+
}
105+
}
106+
----
107+
108+
== Der Ausnahmefall: ein Python-Decoder
109+
110+
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/`:
111+
112+
[source,python]
113+
----
114+
from ..registry import register_decoder
115+
116+
@register_decoder("115")
117+
def decode_bresser_5in1(message):
118+
...
119+
return SensorEvent(...)
120+
----
121+
122+
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.
123+
124+
== Routing
125+
126+
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.
127+
128+
== Neues Protokoll hinzufügen
129+
130+
. Bitlayout und Prüfsumme im zugehörigen FHEM-Client-Modul nachlesen, etwa in `14_SD_WS.pm`.
131+
. Spezifikation unter `signalduino/decoders/specs/` anlegen.
132+
. Testvektoren vorhanden? `tests/data/fhem/` enthält die FHEM-Vektoren samt erwarteter Readings; ergänzt werden sie mit `tools/fhem_testdata_import.py`.
133+
. Tests ausführen (siehe `AGENTS.md`, Abschnitt zur Testausführung im Devcontainer).
134+
135+
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.

‎docs/02_developer_guide/index.adoc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
Dieser Abschnitt beschreibt die Architektur, wie man zur Entwicklung beitragen kann (Contributing) und wie man Tests durchführt.
55

6-
include::architecture.adoc[] include::contribution.adoc[]
6+
include::architecture.adoc[] include::contribution.adoc[] include::decoder_specs.adoc[]
77

88
== Weitere Ressourcen
99

‎docs/index.adoc‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,6 @@ include::02_developer_guide/architecture.adoc[]
127127

128128
include::02_developer_guide/contribution.adoc[]
129129

130+
include::02_developer_guide/decoder_specs.adoc[]
131+
130132
include::03_protocol_reference/protocol_details.adoc[]

‎signalduino/decoders/__init__.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Sensor value decoding, the second stage of the receive chain (ADR-006).
2+
3+
Stage 1, in signalduino/parser and sd_protocols, turns pulses into a payload.
4+
This package turns that payload into measurements: temperature, humidity,
5+
battery state, sensor id, channel.
6+
7+
Most protocols are described declaratively in specs/*.json and evaluated by
8+
dsl.py; the irregular ones register a Python decoder in custom/. Either way
9+
the result is a SensorEvent, which the output adapters render into the FHEM,
10+
rtl_433 and Home Assistant formats.
11+
"""
12+
13+
from .pipeline import DecodeError, SensorDecoder, decode_with_spec
14+
from .registry import DecoderRegistry, register_decoder
15+
from .spec import DecoderSpec, SpecError
16+
17+
__all__ = [
18+
"DecodeError",
19+
"DecoderRegistry",
20+
"DecoderSpec",
21+
"SensorDecoder",
22+
"SpecError",
23+
"decode_with_spec",
24+
"register_decoder",
25+
]

‎signalduino/decoders/crc.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Checksum algorithms used by the decoder specifications (ADR-006).
2+
3+
FHEM implements its checks inside each client module, so the same CRC-8 turns
4+
up several times in slightly different spellings. Here they live once, as pure
5+
functions over bytes, addressed by name from a specification.
6+
7+
Adding an algorithm means adding a function and one ALGORITHMS entry. Anything
8+
irregular enough that it cannot be expressed as "digest over a byte range,
9+
compared against another byte range" belongs in a custom decoder instead.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Callable
15+
16+
ChecksumFunc = Callable[..., int]
17+
18+
19+
def _reflect(value: int, width: int) -> int:
20+
"""Reverses the bit order of ``value`` within ``width`` bits."""
21+
result = 0
22+
for _ in range(width):
23+
result = (result << 1) | (value & 1)
24+
value >>= 1
25+
return result
26+
27+
28+
def crc8(data: bytes, poly: int = 0x31, init: int = 0x00,
29+
reflect_in: bool = False, reflect_out: bool = False,
30+
xor_out: int = 0x00) -> int:
31+
"""CRC-8 with configurable parameters.
32+
33+
The default is polynomial 0x31 with initial value 0, neither input nor
34+
output reflected, which is what the Fine Offset and EuroChron sensors use
35+
and what Digest::CRC produces for ``width => 8, poly => 0x31`` in FHEM.
36+
"""
37+
crc = init
38+
for byte in data:
39+
if reflect_in:
40+
byte = _reflect(byte, 8)
41+
crc ^= byte
42+
for _ in range(8):
43+
crc = ((crc << 1) ^ poly) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
44+
if reflect_out:
45+
crc = _reflect(crc, 8)
46+
return crc ^ xor_out
47+
48+
49+
def crc16(data: bytes, poly: int = 0x8005, init: int = 0xFFFF,
50+
reflect_in: bool = False, reflect_out: bool = False,
51+
xor_out: int = 0x0000) -> int:
52+
"""CRC-16 with configurable parameters."""
53+
crc = init
54+
for byte in data:
55+
if reflect_in:
56+
byte = _reflect(byte, 8)
57+
crc ^= byte << 8
58+
for _ in range(8):
59+
crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
60+
if reflect_out:
61+
crc = _reflect(crc, 16)
62+
return crc ^ xor_out
63+
64+
65+
def crc16lsb(data: bytes, poly: int = 0x8810, init: int = 0x0000) -> int:
66+
"""CRC-16 processed least significant bit first.
67+
68+
Mirrors SD_WS_crc16lsb from 14_SD_WS.pm, used by several weather sensors.
69+
"""
70+
crc = init
71+
for byte in data:
72+
crc ^= byte
73+
for _ in range(8):
74+
crc = (crc >> 1) ^ poly if crc & 1 else crc >> 1
75+
return crc & 0xFFFF
76+
77+
78+
def sum8(data: bytes, init: int = 0x00) -> int:
79+
"""Sum of all bytes, truncated to 8 bits."""
80+
return (init + sum(data)) & 0xFF
81+
82+
83+
def xor8(data: bytes, init: int = 0x00) -> int:
84+
"""XOR over all bytes."""
85+
result = init
86+
for byte in data:
87+
result ^= byte
88+
return result & 0xFF
89+
90+
91+
def lfsr_digest8(data: bytes, gen: int = 0x31, key: int = 0xF4) -> int:
92+
"""Galois LFSR digest, bits processed most significant first.
93+
94+
Mirrors the digest used by Bresser style sensors: for every set bit the
95+
current key is XORed into the sum, and the key advances through the LFSR
96+
on each bit.
97+
"""
98+
result = 0
99+
current = key
100+
for byte in data:
101+
for bit in range(7, -1, -1):
102+
if (byte >> bit) & 1:
103+
result ^= current
104+
current = (current >> 1) ^ gen if current & 1 else current >> 1
105+
return result & 0xFF
106+
107+
108+
def lfsr_digest8_reflect(data: bytes, gen: int = 0x31, key: int = 0xF4) -> int:
109+
"""Galois LFSR digest over the bytes in reverse order, bits least first.
110+
111+
Mirrors SD_WS_LFSR_digest8_reflect from 14_SD_WS.pm.
112+
"""
113+
result = 0
114+
current = key
115+
for byte in reversed(data):
116+
for bit in range(8):
117+
if (byte >> bit) & 1:
118+
result ^= current
119+
current = ((current << 1) ^ gen) & 0xFF if current & 0x80 else (current << 1) & 0xFF
120+
return result & 0xFF
121+
122+
123+
ALGORITHMS: dict[str, ChecksumFunc] = {
124+
"crc8": crc8,
125+
"crc16": crc16,
126+
"crc16lsb": crc16lsb,
127+
"sum8": sum8,
128+
"xor8": xor8,
129+
"lfsr_digest8": lfsr_digest8,
130+
"lfsr_digest8_reflect": lfsr_digest8_reflect,
131+
}
132+
133+
134+
def compute(algorithm: str, data: bytes, **params) -> int:
135+
"""Runs a named algorithm over ``data``.
136+
137+
Raises:
138+
KeyError: if the algorithm is unknown. Specifications are validated
139+
against the schema, so this only happens for custom decoders.
140+
"""
141+
try:
142+
func = ALGORITHMS[algorithm]
143+
except KeyError:
144+
raise KeyError(
145+
f"Unknown checksum algorithm '{algorithm}'. Known: {', '.join(sorted(ALGORITHMS))}"
146+
) from None
147+
return func(data, **params)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Python decoders for protocols a specification cannot express (ADR-006).
2+
3+
A protocol belongs here when its fields depend on each other, when the
4+
checksum runs over a reordered payload, or when the model name itself is
5+
derived from the data. Everything else belongs in specs/*.json.
6+
7+
A module in this package registers itself:
8+
9+
from ..registry import register_decoder
10+
11+
@register_decoder("115")
12+
def decode_bresser_5in1(message):
13+
...
14+
return SensorEvent(...)
15+
16+
and is imported here so the registry picks it up. A custom decoder takes
17+
precedence over a specification with the same protocol id.
18+
"""

0 commit comments

Comments
 (0)