Skip to content

Commit a330cb6

Browse files
committed
refactor: extract decoders into separate modules
1 parent b6cd251 commit a330cb6

9 files changed

Lines changed: 953 additions & 846 deletions

File tree

ruuvitag_sensor/decoder.py

Lines changed: 40 additions & 846 deletions
Large diffs are not rendered by default.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Decoder modules for different RuuviTag and Ruuvi Air data formats.
3+
4+
This package contains individual decoder modules for each data format:
5+
- url_decoder: DF2/DF4 (URL-encoded formats, deprecated)
6+
- df3_decoder: DF3 (raw sensor data, deprecated)
7+
- df5_decoder: DF5 (extended raw data, primary RuuviTag format)
8+
- df6_decoder: DF6 (Ruuvi Air quality data)
9+
- dfe1_decoder: DFE1 (extended Ruuvi Air quality data)
10+
- history_decoder: RuuviTag history data decoder
11+
- air_history_decoder: Ruuvi Air history data decoder
12+
"""
13+
14+
from ruuvitag_sensor.decoders.air_history_decoder import AirHistoryDecoder
15+
from ruuvitag_sensor.decoders.df3_decoder import Df3Decoder
16+
from ruuvitag_sensor.decoders.df5_decoder import Df5Decoder
17+
from ruuvitag_sensor.decoders.df6_decoder import Df6Decoder
18+
from ruuvitag_sensor.decoders.dfe1_decoder import DfE1Decoder
19+
from ruuvitag_sensor.decoders.history_decoder import HistoryDecoder
20+
from ruuvitag_sensor.decoders.url_decoder import UrlDecoder
21+
22+
__all__ = [
23+
"AirHistoryDecoder",
24+
"Df3Decoder",
25+
"Df5Decoder",
26+
"Df6Decoder",
27+
"DfE1Decoder",
28+
"HistoryDecoder",
29+
"UrlDecoder",
30+
]
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from ruuvitag_sensor.ruuvi_types import SensorAirHistoryData
6+
7+
log = logging.getLogger(__name__)
8+
9+
10+
class AirHistoryDecoder:
11+
"""
12+
Decodes history data from Ruuvi Air
13+
Protocol specification:
14+
https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read-air.md
15+
16+
Multi-record response packet format:
17+
- Byte 0: Destination (source index from request)
18+
- Byte 1: Source (0x3B = Air Quality endpoint)
19+
- Byte 2: Operation (0x20 = Multi-record log write)
20+
- Byte 3: Num records (number of records in this packet)
21+
- Byte 4: Record length (38 bytes)
22+
- Bytes 5+: Packed record data
23+
24+
Each record (38 bytes):
25+
- Bytes 0-3: Timestamp (uint32_t BE, Unix timestamp in seconds)
26+
- Byte 4: Data format (0xE1)
27+
- Bytes 5-6: Temperature (int16_t BE, x 200)
28+
- Bytes 7-8: Humidity (uint16_t BE, x 400)
29+
- Bytes 9-10: Pressure (uint16_t BE, (Pressure - 50000) Pa)
30+
- Bytes 11-12: PM1.0 (uint16_t BE, x 10)
31+
- Bytes 13-14: PM2.5 (uint16_t BE, x 10)
32+
- Bytes 15-16: PM4.0 (uint16_t BE, x 10)
33+
- Bytes 17-18: PM10.0 (uint16_t BE, x 10)
34+
- Bytes 19-20: CO₂ (uint16_t BE, ppm)
35+
- Byte 21: VOC (uint8_t, bit 9 in flags)
36+
- Byte 22: NOx (uint8_t, bit 9 in flags)
37+
- Bytes 23-28: Reserved
38+
- Bytes 29-31: Sequence counter (uint24_t BE)
39+
- Byte 32: Flags (extended bits for 9-bit values)
40+
- Bytes 33-37: Reserved
41+
"""
42+
43+
def _is_end_marker(self, data: bytearray) -> bool:
44+
"""Check if this is an end marker packet (num_records = 0)."""
45+
if len(data) < 5:
46+
return False
47+
# End marker: destination=0x3B, source=0x3B, operation=0x20, num_records=0, record_length=38
48+
return data[0] == 0x3B and data[1] == 0x3B and data[2] == 0x20 and data[3] == 0x00 and data[4] == 0x26
49+
50+
def _get_timestamp(self, record_data: bytearray) -> int:
51+
"""Extract timestamp from record (bytes 0-3, big-endian)."""
52+
return int.from_bytes(record_data[0:4], byteorder="big")
53+
54+
def _get_temperature(self, record_data: bytearray) -> float | None:
55+
"""Extract temperature from record (bytes 5-6, int16_t BE, x 200)."""
56+
temp_raw = int.from_bytes(record_data[5:7], byteorder="big", signed=False)
57+
if temp_raw == 0x8000:
58+
return None
59+
if temp_raw >= 0x8000:
60+
temp_raw = temp_raw - 0x10000
61+
return round(temp_raw / 200.0, 2)
62+
63+
def _get_humidity(self, record_data: bytearray) -> float | None:
64+
"""Extract humidity from record (bytes 7-8, uint16_t BE, x 400)."""
65+
humidity_raw = int.from_bytes(record_data[7:9], byteorder="big")
66+
return None if humidity_raw == 0xFFFF else round(humidity_raw / 400.0, 3)
67+
68+
def _get_pressure(self, record_data: bytearray) -> float | None:
69+
"""Extract pressure from record (bytes 9-10, uint16_t BE)."""
70+
pressure_raw = int.from_bytes(record_data[9:11], byteorder="big")
71+
return None if pressure_raw == 0xFFFF else round((pressure_raw + 50000) / 100.0, 2)
72+
73+
def _get_pm_value(self, record_data: bytearray, offset: int) -> float | None:
74+
"""Extract PM value from record (uint16_t BE, x 10)."""
75+
pm_raw = int.from_bytes(record_data[offset : offset + 2], byteorder="big")
76+
return None if pm_raw == 0xFFFF else round(pm_raw / 10.0, 1)
77+
78+
def _get_co2(self, record_data: bytearray) -> int | None:
79+
"""Extract CO2 from record (bytes 19-20, uint16_t BE)."""
80+
co2_raw = int.from_bytes(record_data[19:21], byteorder="big")
81+
return None if co2_raw == 0xFFFF else co2_raw
82+
83+
def _get_9bit_value(self, record_data: bytearray, byte_offset: int, flag_bit: int) -> int | None:
84+
"""Extract 9-bit value from record (8 bits in byte + 1 bit in flags)."""
85+
flags = record_data[32] if len(record_data) > 32 else 0
86+
value_byte = record_data[byte_offset]
87+
value_bit9 = (flags >> flag_bit) & 0x01
88+
value = value_byte | (value_bit9 << 8)
89+
return None if value == 0x1FF else value
90+
91+
def _get_sequence(self, record_data: bytearray) -> int | None:
92+
"""Extract sequence counter from record (bytes 29-31, uint24_t BE)."""
93+
seq_raw = int.from_bytes(record_data[29:32], byteorder="big")
94+
return None if seq_raw == 0xFFFFFF else seq_raw
95+
96+
def _decode_record(self, record_data: bytearray) -> SensorAirHistoryData | None:
97+
"""
98+
Decode a single 38-byte record.
99+
100+
Args:
101+
record_data: 38-byte record data
102+
103+
Returns:
104+
SensorAirHistoryData or None if invalid
105+
"""
106+
if len(record_data) < 38:
107+
log.debug("Record too short: %d bytes", len(record_data))
108+
return None
109+
110+
try:
111+
if record_data[4] != 0xE1:
112+
log.debug("Invalid data format: 0x%02X (expected 0xE1)", record_data[4])
113+
return None
114+
115+
return {
116+
"timestamp": self._get_timestamp(record_data),
117+
"temperature": self._get_temperature(record_data),
118+
"humidity": self._get_humidity(record_data),
119+
"pressure": self._get_pressure(record_data),
120+
"pm_1": self._get_pm_value(record_data, 11),
121+
"pm_2_5": self._get_pm_value(record_data, 13),
122+
"pm_4": self._get_pm_value(record_data, 15),
123+
"pm_10": self._get_pm_value(record_data, 17),
124+
"co2": self._get_co2(record_data),
125+
"voc": self._get_9bit_value(record_data, 21, 6),
126+
"nox": self._get_9bit_value(record_data, 22, 7),
127+
"measurement_sequence_number": self._get_sequence(record_data),
128+
}
129+
130+
except Exception:
131+
log.exception("Failed to decode record")
132+
return None
133+
134+
def decode_data(self, data: bytearray) -> list[SensorAirHistoryData]:
135+
"""
136+
Decode a multi-record response packet.
137+
138+
Args:
139+
data: Raw packet data from BLE notification
140+
141+
Returns:
142+
List of decoded records (empty list if end marker or invalid packet)
143+
"""
144+
if len(data) < 5:
145+
log.debug("Packet too short: %d bytes", len(data))
146+
return []
147+
148+
if self._is_end_marker(data):
149+
log.debug("End marker received")
150+
return []
151+
152+
if data[0] != 0x3B or data[1] != 0x3B or data[2] != 0x20:
153+
log.debug("Invalid packet header: 0x%02X 0x%02X 0x%02X", data[0], data[1], data[2])
154+
return []
155+
156+
num_records = data[3]
157+
record_length = data[4]
158+
159+
if record_length != 38:
160+
log.debug("Unexpected record length: %d (expected 38)", record_length)
161+
return []
162+
163+
if num_records == 0:
164+
log.debug("No records in packet")
165+
return []
166+
167+
records = []
168+
header_size = 5
169+
for i in range(num_records):
170+
record_start = header_size + (i * record_length)
171+
record_end = record_start + record_length
172+
if record_end > len(data):
173+
log.debug("Not enough data for record %d", i)
174+
break
175+
record_data = data[record_start:record_end]
176+
if decoded := self._decode_record(record_data):
177+
records.append(decoded)
178+
179+
return records
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import math
5+
import struct
6+
7+
from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
class Df3Decoder:
13+
"""
14+
Decodes data from RuuviTag with Data Format 3
15+
Protocol specification:
16+
https://github.com/ruuvi/ruuvi-sensor-protocols
17+
"""
18+
19+
def _get_temperature(self, data: ByteData) -> float:
20+
"""Return temperature in celsius"""
21+
22+
# The temperature is in two fields, one for the integer part,
23+
# one for the fraction
24+
#
25+
# The integer part was decoded as a signed two's complement number,
26+
# but this isn't how it's really stored. The MSB is a sign, the lower
27+
# 7 bits are the unsigned temperature value.
28+
#
29+
# To convert from the decoded value we have to add 128 and then negate,
30+
# if the decoded value was negative
31+
frac = data[3] / 100
32+
if data[2] < 0:
33+
return -(data[2] + 128 + frac)
34+
35+
return data[2] + frac
36+
37+
def _get_humidity(self, data: ByteData) -> float:
38+
"""Return humidity %"""
39+
return data[1] * 0.5
40+
41+
def _get_pressure(self, data: ByteData) -> float:
42+
"""Return air pressure hPa"""
43+
return (data[4] + 50000) / 100
44+
45+
def _get_acceleration(self, data: ByteData) -> tuple[int, int, int]:
46+
"""Return acceleration mG"""
47+
return data[5:8] # type: ignore
48+
49+
def _get_battery(self, data: ByteData) -> int:
50+
"""Return battery mV"""
51+
return data[8]
52+
53+
def decode_data(self, data: str) -> SensorData3 | None:
54+
"""
55+
Decode sensor data.
56+
57+
Returns:
58+
dict: Sensor values
59+
"""
60+
try:
61+
byte_data: ByteData = struct.unpack(">BBbBHhhhH", bytearray.fromhex(data[:28]))
62+
acc_x, acc_y, acc_z = self._get_acceleration(byte_data)
63+
return {
64+
"data_format": 3,
65+
"humidity": self._get_humidity(byte_data),
66+
"temperature": self._get_temperature(byte_data),
67+
"pressure": self._get_pressure(byte_data),
68+
"acceleration": math.sqrt(acc_x * acc_x + acc_y * acc_y + acc_z * acc_z),
69+
"acceleration_x": acc_x,
70+
"acceleration_y": acc_y,
71+
"acceleration_z": acc_z,
72+
"battery": self._get_battery(byte_data),
73+
}
74+
except Exception:
75+
log.exception("Value: %s not valid", data)
76+
return None

0 commit comments

Comments
 (0)