|
| 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 |
0 commit comments