From b4a0e018bf6856350ec5d2aace46ac3b6f02ce21 Mon Sep 17 00:00:00 2001 From: ttu Date: Mon, 27 Jan 2025 18:48:30 +0200 Subject: [PATCH] Move decoder functionality to HistoryDecoder --- examples/download_history.py | 4 +- ruuvitag_sensor/adapters/bleak_ble.py | 45 +--------------- ruuvitag_sensor/decoder.py | 75 ++++++++++++++++++++++++++- ruuvitag_sensor/ruuvi.py | 25 +++++---- ruuvitag_sensor/ruuvi_types.py | 8 +++ tests/test_decoder.py | 52 ++++++++++++++++++- 6 files changed, 151 insertions(+), 58 deletions(-) diff --git a/examples/download_history.py b/examples/download_history.py index ed639d7..af48c21 100644 --- a/examples/download_history.py +++ b/examples/download_history.py @@ -3,14 +3,14 @@ import ruuvitag_sensor.log from ruuvitag_sensor.ruuvi import RuuviTagSensor -ruuvitag_sensor.log.enable_console() +ruuvitag_sensor.log.enable_console(10) async def main(): # On macOS, the device address is not a MAC address, but a system specific ID # mac = "CA:F7:44:DE:EB:E1" mac = "873A13F5-ED14-AEE1-E446-6ACF31649A1D" - data = await RuuviTagSensor.download_history(mac) + data = await RuuviTagSensor.download_history(mac, max_items=5) print(data) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 475397f..e7fcfb0 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -128,7 +128,7 @@ async def get_first_data(mac: str, bt_device: str = "") -> RawData: async def get_history_data( self, mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None - ) -> List[dict]: + ) -> List[bytearray]: """ Get history data from a RuuviTag using GATT connection. @@ -154,8 +154,7 @@ async def get_history_data( # Get the history service # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus history_data = await self._collect_history_data(client, start_time, max_items) - parsed_data = self._parse_history_entries(history_data) - return parsed_data + return history_data except Exception as e: log.error("Failed to get history data from device %s: %s", mac, e) finally: @@ -228,43 +227,3 @@ def notification_handler(_, data: bytearray): pass return history_data - - def _parse_history_entries(self, history_data: List[bytearray]) -> List[dict]: - parsed_data = [] - for data_point in history_data: - if len(data_point) < 10: - continue - - timestamp = struct.unpack(" Optional[dict]: - """ - Parse history data point from RuuviTag - - Args: - data (bytes): Raw history data point - - Returns: - Optional[dict]: Parsed sensor data or None if parsing fails - """ - try: - temperature = struct.unpack(" Optional[SensorData5]: except Exception: log.exception("Value: %s not valid", data) return None + + +class HistoryDecoder: + """ + Decodes history data from RuuviTag + Protocol specification: + https://github.com/ruuvi/docs/blob/master/communication/bluetooth-connection/nordic-uart-service-nus/log-read.md + + Data format: + - Timestamp: uint32_t, Unix timestamp in seconds + - Temperature: int32_t, 0.01°C per LSB + - Humidity: uint32_t, 0.01 RH-% per LSB + - Pressure: uint32_t, 1 Pa per LSB + """ + + def _get_temperature(self, data: int) -> float: + """Return temperature in celsius""" + return round(data * 0.01, 2) + + def _get_humidity(self, data: int) -> float: + """Return humidity %""" + return round(data * 0.01, 2) + + def _get_pressure(self, data: int) -> float: + """Return air pressure hPa""" + # Data is already in Pa, convert to hPa + return round(data / 100, 2) + + def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: + """ + Decode history data from RuuviTag. + + The data format is: + - 4 bytes: Unix timestamp (uint32, little-endian) + - 4 bytes: Temperature in 0.01°C units (int32, little-endian) + - 4 bytes: Humidity in 0.01% units (uint32, little-endian) + - 4 bytes: Pressure in Pa (uint32, little-endian) + + Args: + data: Raw history data bytearray (16 bytes) + + Returns: + SensorDataHistory: Decoded sensor values with timestamp, or None if decoding fails + """ + try: + # Data format is always 16 bytes: + # - 4 bytes timestamp (uint32) + # - 4 bytes temperature (int32) + # - 4 bytes humidity (uint32) + # - 4 bytes pressure (uint32) + if len(data) < 16: + log.error("History data too short: %d bytes", len(data)) + return None + + # Use correct formats: + # I = unsigned int (uint32_t) + # i = signed int (int32_t) + # < = little-endian + ts, temp, hum, press = struct.unpack(" List[SensorData]: + ) -> List[SensorHistoryData]: """ Get history data from a RuuviTag that supports it (firmware 3.30.0+) @@ -366,7 +373,7 @@ async def get_history_async( @staticmethod async def download_history( mac: str, start_time: Optional[datetime] = None, timeout: int = 300, max_items: Optional[int] = None - ) -> List[SensorData]: + ) -> List[SensorHistoryData]: """ Download history data from a RuuviTag. Requires firmware version 3.30.0 or newer. @@ -377,7 +384,7 @@ async def download_history( max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Returns: - List[SensorData]: List of historical measurements, ordered by timestamp + List[HistoryDecoder]: List of historical measurements, ordered by timestamp Raises: RuntimeError: If connection fails or device doesn't support history @@ -387,12 +394,8 @@ async def download_history( try: history = await asyncio.wait_for(ble.get_history_data(mac, start_time, max_items), timeout=timeout) - - # Sort by timestamp if present - if history and "timestamp" in history[0]: - history.sort(key=lambda x: x["timestamp"]) - - return history + decoder = HistoryDecoder() + return [d for h in history if (d := decoder.decode_data(h)) is not None] except asyncio.TimeoutError: raise TimeoutError(f"History download timed out after {timeout} seconds") diff --git a/ruuvitag_sensor/ruuvi_types.py b/ruuvitag_sensor/ruuvi_types.py index c7f9ca9..b7c2c59 100644 --- a/ruuvitag_sensor/ruuvi_types.py +++ b/ruuvitag_sensor/ruuvi_types.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional, Tuple, TypedDict, Union @@ -39,6 +40,13 @@ class SensorData5(SensorDataBase): rssi: Optional[int] +class SensorHistoryData(TypedDict): + humidity: float + temperature: float + pressure: float + timestamp: datetime + + SensorData = Union[SensorDataUrl, SensorData3, SensorData5] DataFormat = Optional[int] diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 8175430..02610f4 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -1,6 +1,7 @@ +from datetime import datetime from unittest import TestCase -from ruuvitag_sensor.decoder import Df3Decoder, Df5Decoder, UrlDecoder, get_decoder, parse_mac +from ruuvitag_sensor.decoder import Df3Decoder, Df5Decoder, HistoryDecoder, UrlDecoder, get_decoder, parse_mac class TestDecoder(TestCase): @@ -160,3 +161,52 @@ def test_parse_df5_mac(self): parsed = parse_mac(5, mac_payload) assert parsed == mac + + def test_history_decode_is_valid(self): + decoder = HistoryDecoder() + timestamp = 1617184800 # 2021-03-31 12:00:00 UTC + # Create test data with: + # timestamp: 2021-03-31 12:00:00 UTC (0x60619960) + # temperature: 24.30°C = 2430 (0x0000097E) + # humidity: 53.49% = 5349 (0x000014E5) + # pressure: 100012 Pa = 1000.12 hPa (0x000186AC) + data = bytearray.fromhex("60996160" + "7E090000E514000000AC860100") # Note: values are little-endian + + data = decoder.decode_data(data) + + assert data["temperature"] == 24.30 + assert data["humidity"] == 53.49 + assert data["pressure"] == 1000.12 + assert data["timestamp"] == datetime.fromtimestamp(timestamp) + + def test_history_decode_negative_temperature(self): + decoder = HistoryDecoder() + timestamp = 1617184800 # 2021-03-31 12:00:00 UTC + # Create test data with: + # timestamp: 2021-03-31 12:00:00 UTC (0x60619960) + # temperature: -24.30°C = -2430 (0xFFFFF682) + # humidity: 53.49% = 5349 (0x000014E5) + # pressure: 100012 Pa = 1000.12 hPa (0x000186AC) + data = bytearray.fromhex("6099616082F6FFFFE514000000AC860100") # Note: values are little-endian + + data = decoder.decode_data(data) + + assert data["temperature"] == -24.30 + assert data["humidity"] == 53.49 + assert data["pressure"] == 1000.12 + assert data["timestamp"] == datetime.fromtimestamp(timestamp) + + def test_history_decode_invalid_short_data(self): + decoder = HistoryDecoder() + # Only 12 bytes instead of required 16 + data = bytearray.fromhex("7E090000E514000000AC860100") + + data = decoder.decode_data(data) + assert data is None + + def test_history_decode_invalid_data(self): + decoder = HistoryDecoder() + data = bytearray.fromhex("invalid") + + data = decoder.decode_data(data) + assert data is None