From e2aaae2918078767288089bf9856ba049e2aeac6 Mon Sep 17 00:00:00 2001 From: ttu Date: Wed, 27 Nov 2024 18:18:07 +0200 Subject: [PATCH 01/22] feat: download history --- examples/download_history.py | 18 ++++ ruuvitag_sensor/adapters/bleak_ble.py | 143 +++++++++++++++++++++++++- ruuvitag_sensor/ruuvi.py | 53 ++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 examples/download_history.py diff --git a/examples/download_history.py b/examples/download_history.py new file mode 100644 index 0000000..ed639d7 --- /dev/null +++ b/examples/download_history.py @@ -0,0 +1,18 @@ +import asyncio + +import ruuvitag_sensor.log +from ruuvitag_sensor.ruuvi import RuuviTagSensor + +ruuvitag_sensor.log.enable_console() + + +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) + print(data) + + +if __name__ == "__main__": + asyncio.get_event_loop().run_until_complete(main()) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index a82e9bd..2cd123d 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -2,10 +2,12 @@ import logging import os import re +import struct import sys -from typing import AsyncGenerator, List, Tuple +from datetime import datetime +from typing import AsyncGenerator, List, Optional, Tuple -from bleak import BleakScanner +from bleak import BleakClient, BleakScanner from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback, BLEDevice from ruuvitag_sensor.adapters import BleCommunicationAsync @@ -13,6 +15,9 @@ from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData MAC_REGEX = "[0-9a-f]{2}([:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$" +RUUVI_HISTORY_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +RUUVI_HISTORY_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" +RUUVI_HISTORY_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" def _get_scanner(detection_callback: AdvertisementDataCallback, bt_device: str = ""): @@ -120,3 +125,137 @@ async def get_first_data(mac: str, bt_device: str = "") -> RawData: await data_iter.aclose() return data or "" + + async def get_history_data(self, mac: str, start_time: Optional[datetime] = None) -> List[dict]: + """ + Get history data from a RuuviTag using GATT connection. + + Args: + mac (str): MAC address of the RuuviTag + start_time (datetime, optional): Start time for history data + + Returns: + List[dict]: List of historical sensor readings + + Raises: + RuntimeError: If connection fails or required services not found + """ + history_data: List[bytearray] = [] + client = None + + try: + # Connect to device + client = await self._connect_gatt(mac) + log.debug("Connected to device %s", mac) + + # Get the history service + # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus + services = await client.get_services() + history_service = next( + (service for service in services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), + None, + ) + if not history_service: + raise RuntimeError(f"History service not found - device {mac} may not support history") + + # Get characteristics + tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) + rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) + + if not tx_char or not rx_char: + raise RuntimeError("Required characteristics not found") + + # Set up notification handler + notification_received = asyncio.Event() + + def notification_handler(_, data: bytearray): + history_data.append(data) + notification_received.set() + + # Enable notifications + await client.start_notify(tx_char, notification_handler) + + # Request history data + command = bytearray([0x26]) # Get logged history command + if start_time: + timestamp = int(start_time.timestamp()) + command.extend(struct.pack(" BleakClient: + """ + Connect to a BLE device using GATT. + + NOTE: On macOS, the device address is not a MAC address, but a system specific ID + + Args: + mac (str): MAC address of the device to connect to + + Returns: + BleakClient: Connected BLE client + """ + client = BleakClient(mac) + # TODO: Implement retry logic. connect fails for some reason pretty often. + await client.connect() + return client + + def _parse_history_data(self, data: bytes) -> 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(" List[SensorData]: + """ + Get history data from a RuuviTag that supports it (firmware 3.30.0+) + + Args: + mac (str): MAC address of the RuuviTag + start_time (datetime, optional): Start time for history data. If None, gets all available data + + Returns: + List[SensorData]: List of historical sensor readings + """ + throw_if_not_async_adapter(ble) + return await ble.get_history_data(mac, start_time) + + @staticmethod + async def download_history(mac: str, start_time: Optional[datetime] = None, timeout: int = 300) -> List[SensorData]: + """ + Download history data from a RuuviTag. Requires firmware version 3.30.0 or newer. + + Args: + mac (str): MAC address of the RuuviTag. On macOS use UUID instead. + start_time (Optional[datetime]): If provided, only get data from this time onwards + timeout (int): Maximum time in seconds to wait for history download (default: 30) + + Returns: + List[SensorData]: List of historical measurements, ordered by timestamp + + Raises: + RuntimeError: If connection fails or device doesn't support history + TimeoutError: If download takes longer than timeout + """ + throw_if_not_async_adapter(ble) + + # if not re.match("[0-9A-F]{2}(:[0-9A-F]{2}){5}$", mac.upper()): + # raise ValueError(f"Invalid MAC address: {mac}") + + try: + history = await asyncio.wait_for(ble.get_history_data(mac, start_time), timeout=timeout) + + # Sort by timestamp if present + if history and "timestamp" in history[0]: + history.sort(key=lambda x: x["timestamp"]) + + return history + + except asyncio.TimeoutError: + raise TimeoutError(f"History download timed out after {timeout} seconds") + except Exception as e: + raise RuntimeError(f"Failed to download history: {str(e)}") from e From 4283e47cf1c442162e1697e72453d4656f58dd9d Mon Sep 17 00:00:00 2001 From: ttu Date: Thu, 23 Jan 2025 21:27:44 +0200 Subject: [PATCH 02/22] Use recommended service property --- ruuvitag_sensor/adapters/bleak_ble.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 2cd123d..e873dfc 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -150,9 +150,8 @@ async def get_history_data(self, mac: str, start_time: Optional[datetime] = None # Get the history service # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus - services = await client.get_services() history_service = next( - (service for service in services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), + (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), None, ) if not history_service: From eae67ed3df800bed1392c8dd34d14783d9dd2dc8 Mon Sep 17 00:00:00 2001 From: ttu Date: Thu, 23 Jan 2025 21:41:09 +0200 Subject: [PATCH 03/22] Add maximum number of items to fetch --- ruuvitag_sensor/adapters/bleak_ble.py | 13 ++++++++++--- ruuvitag_sensor/ruuvi.py | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index e873dfc..7c64c0e 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -126,13 +126,16 @@ async def get_first_data(mac: str, bt_device: str = "") -> RawData: return data or "" - async def get_history_data(self, mac: str, start_time: Optional[datetime] = None) -> List[dict]: + async def get_history_data( + self, mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None + ) -> List[dict]: """ Get history data from a RuuviTag using GATT connection. Args: mac (str): MAC address of the RuuviTag start_time (datetime, optional): Start time for history data + max_items (int, optional): Maximum number of history entries to fetch Returns: List[dict]: List of historical sensor readings @@ -184,13 +187,17 @@ def notification_handler(_, data: bytearray): log.debug("Requested history data from device %s", mac) # Wait for initial notification - await asyncio.wait_for(notification_received.wait(), timeout=5.0) + await asyncio.wait_for(notification_received.wait(), timeout=10.0) # Wait for more data try: while True: notification_received.clear() - await asyncio.wait_for(notification_received.wait(), timeout=1.0) + await asyncio.wait_for(notification_received.wait(), timeout=5.0) + # Check if we've reached the maximum number of items + if max_items and len(history_data) >= max_items: + log.debug("Reached maximum number of items (%d)", max_items) + break except asyncio.TimeoutError: # No more data received for 1 second - assume transfer complete pass diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 5f961d0..c598a67 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -346,29 +346,35 @@ def _parse_data( return (mac_to_send, decoded) @staticmethod - async def get_history_async(mac: str, start_time: Optional[datetime] = None) -> List[SensorData]: + async def get_history_async( + mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None + ) -> List[SensorData]: """ Get history data from a RuuviTag that supports it (firmware 3.30.0+) Args: mac (str): MAC address of the RuuviTag start_time (datetime, optional): Start time for history data. If None, gets all available data + max_items (int, optional): Maximum number of history entries to fetch. If None, gets all available data Returns: List[SensorData]: List of historical sensor readings """ throw_if_not_async_adapter(ble) - return await ble.get_history_data(mac, start_time) + return await ble.get_history_data(mac, start_time, max_items) @staticmethod - async def download_history(mac: str, start_time: Optional[datetime] = None, timeout: int = 300) -> List[SensorData]: + async def download_history( + mac: str, start_time: Optional[datetime] = None, timeout: int = 300, max_items: Optional[int] = None + ) -> List[SensorData]: """ Download history data from a RuuviTag. Requires firmware version 3.30.0 or newer. Args: mac (str): MAC address of the RuuviTag. On macOS use UUID instead. start_time (Optional[datetime]): If provided, only get data from this time onwards - timeout (int): Maximum time in seconds to wait for history download (default: 30) + timeout (int): Maximum time in seconds to wait for history download (default: 300) + 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 @@ -379,11 +385,8 @@ async def download_history(mac: str, start_time: Optional[datetime] = None, time """ throw_if_not_async_adapter(ble) - # if not re.match("[0-9A-F]{2}(:[0-9A-F]{2}){5}$", mac.upper()): - # raise ValueError(f"Invalid MAC address: {mac}") - try: - history = await asyncio.wait_for(ble.get_history_data(mac, start_time), timeout=timeout) + 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]: From 892bbcc254f5a2731da268e541a88e1af2d93537 Mon Sep 17 00:00:00 2001 From: ttu Date: Sun, 26 Jan 2025 15:29:18 +0200 Subject: [PATCH 04/22] Refactor functionality to collect and parse --- ruuvitag_sensor/adapters/bleak_ble.py | 131 +++++++++++++------------- ruuvitag_sensor/ruuvi.py | 2 +- 2 files changed, 68 insertions(+), 65 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 7c64c0e..475397f 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -153,71 +153,11 @@ async def get_history_data( # Get the history service # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus - history_service = next( - (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), - None, - ) - if not history_service: - raise RuntimeError(f"History service not found - device {mac} may not support history") - - # Get characteristics - tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) - rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) - - if not tx_char or not rx_char: - raise RuntimeError("Required characteristics not found") - - # Set up notification handler - notification_received = asyncio.Event() - - def notification_handler(_, data: bytearray): - history_data.append(data) - notification_received.set() - - # Enable notifications - await client.start_notify(tx_char, notification_handler) - - # Request history data - command = bytearray([0x26]) # Get logged history command - if start_time: - timestamp = int(start_time.timestamp()) - command.extend(struct.pack("= max_items: - log.debug("Reached maximum number of items (%d)", max_items) - break - except asyncio.TimeoutError: - # No more data received for 1 second - assume transfer complete - pass - - # Parse collected data - parsed_data = [] - for data_point in history_data: - if len(data_point) < 10: # Minimum valid data length - continue - - timestamp = struct.unpack(" BleakClient: await client.connect() return client + async def _collect_history_data( + self, client: BleakClient, start_time: Optional[datetime], max_items: Optional[int] + ) -> List[bytearray]: + history_data: List[bytearray] = [] + + history_service = next( + (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), + None, + ) + if not history_service: + raise RuntimeError("History service not found - device may not support history") + + tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) + rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) + + if not tx_char or not rx_char: + raise RuntimeError("Required characteristics not found") + + notification_received = asyncio.Event() + + def notification_handler(_, data: bytearray): + history_data.append(data) + notification_received.set() + + await client.start_notify(tx_char, notification_handler) + + command = bytearray([0x26]) + if start_time: + timestamp = int(start_time.timestamp()) + command.extend(struct.pack("= max_items: + log.debug("Reached maximum number of items (%d)", max_items) + break + except asyncio.TimeoutError: + 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 @@ -263,5 +266,5 @@ def _parse_history_data(self, data: bytes) -> Optional[dict]: "data_format": 5, # History data uses similar format to data format 5 } except Exception as e: - log.error(f"Failed to parse history data: {e}") + log.error("Failed to parse history data: %s", e) return None diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index c598a67..3a5283f 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -397,4 +397,4 @@ async def download_history( except asyncio.TimeoutError: raise TimeoutError(f"History download timed out after {timeout} seconds") except Exception as e: - raise RuntimeError(f"Failed to download history: {str(e)}") from e + raise RuntimeError(f"Failed to download history: {e!s}") from e From b4a0e018bf6856350ec5d2aace46ac3b6f02ce21 Mon Sep 17 00:00:00 2001 From: ttu Date: Mon, 27 Jan 2025 18:48:30 +0200 Subject: [PATCH 05/22] 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 From 8ea31fded8cb90742b0a76eef1c8de571e239e3b Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 08:14:02 +0200 Subject: [PATCH 06/22] Handle different notified data types --- ruuvitag_sensor/adapters/bleak_ble.py | 60 ++++++++++++++++++--------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index e7fcfb0..0e82665 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -2,7 +2,6 @@ import logging import os import re -import struct import sys from datetime import datetime from typing import AsyncGenerator, List, Optional, Tuple @@ -16,8 +15,8 @@ MAC_REGEX = "[0-9a-f]{2}([:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$" RUUVI_HISTORY_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" -RUUVI_HISTORY_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" -RUUVI_HISTORY_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" +RUUVI_HISTORY_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" # Write +RUUVI_HISTORY_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" # Read and notify def _get_scanner(detection_callback: AdvertisementDataCallback, bt_device: str = ""): @@ -156,7 +155,7 @@ async def get_history_data( history_data = await self._collect_history_data(client, start_time, max_items) return history_data except Exception as e: - log.error("Failed to get history data from device %s: %s", mac, e) + log.error("Failed to get history data from device %s: %r", mac, e) finally: if client: await client.disconnect() @@ -201,29 +200,50 @@ async def _collect_history_data( notification_received = asyncio.Event() def notification_handler(_, data: bytearray): + # Ignore heartbeat data that starts with 0x05 + if data and data[0] == 0x05: + log.debug("Ignoring heartbeat data") + return + log.debug("Received data: %s", data) + # Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF...) + if len(data) >= 3 and all(b == 0xFF for b in data[3:]): + log.debug("Received end-of-logs marker") + notification_received.set() + return + # Check for error message (0x30 30 F0 FF FF FF FF FF FF FF FF) + if len(data) >= 11 and data[0:2] == b"00" and data[2] == 0xF0: + log.error("Device reported error in log reading") + notification_received.set() + return history_data.append(data) - notification_received.set() await client.start_notify(tx_char, notification_handler) - command = bytearray([0x26]) - if start_time: - timestamp = int(start_time.timestamp()) - command.extend(struct.pack("> 24) & 0xFF, # End timestamp byte 1 (most significant) + (end_time >> 16) & 0xFF, # End timestamp byte 2 + (end_time >> 8) & 0xFF, # End timestamp byte 3 + end_time & 0xFF, # End timestamp byte 4 + (start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant) + (start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2 + (start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3 + start_time_to_use & 0xFF, # Start timestamp byte 4 + ] + ) + + log.debug("Sending command: %s", command) await client.write_gatt_char(rx_char, command) log.debug("Sent history command to device") - await asyncio.wait_for(notification_received.wait(), timeout=10.0) - - try: - while True: - notification_received.clear() - await asyncio.wait_for(notification_received.wait(), timeout=5.0) - if max_items and len(history_data) >= max_items: - log.debug("Reached maximum number of items (%d)", max_items) - break - except asyncio.TimeoutError: - pass + await asyncio.wait_for(notification_received.wait(), timeout=60.0) + notification_received.clear() return history_data From 560bb0c9808bebed3f54189044e2188d1ec0416c Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 08:20:28 +0200 Subject: [PATCH 07/22] Fix decoding and use clean hex values --- examples/download_history.py | 6 +- ruuvitag_sensor/decoder.py | 137 +++++++++++++++++++++++---------- ruuvitag_sensor/ruuvi_types.py | 6 +- tests/test_decoder.py | 92 ++++++++++++---------- 4 files changed, 155 insertions(+), 86 deletions(-) diff --git a/examples/download_history.py b/examples/download_history.py index af48c21..59877d5 100644 --- a/examples/download_history.py +++ b/examples/download_history.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timedelta import ruuvitag_sensor.log from ruuvitag_sensor.ruuvi import RuuviTagSensor @@ -10,9 +11,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, max_items=5) + start_time = datetime.now() - timedelta(minutes=10) + data = await RuuviTagSensor.download_history(mac, start_time=start_time) print(data) if __name__ == "__main__": - asyncio.get_event_loop().run_until_complete(main()) + asyncio.run(main()) diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index d5f8f73..69e8b6b 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Optional, Tuple, Union -from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorHistoryData, SensorDataUrl +from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl, SensorHistoryData log = logging.getLogger(__name__) @@ -292,66 +292,125 @@ class HistoryDecoder: 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 + - First byte: Command byte (0x3A) + - Second byte: Packet type (0x30 = temperature, 0x31 = humidity, 0x32 = pressure) + - Third byte: Header byte (skipped or error) + - Next 4 bytes: Clock time (seconds since unix epoch) + - Next 2 bytes: Reserved (always 0x00) + - Next 2 bytes: Sensor data (uint16, little-endian) + Temperature: 0.01°C units + Humidity: 0.01% units + Pressure: Raw value in hPa + + Special case: + - End marker packet has command byte 0x3A followed by 0x3A """ - def _get_temperature(self, data: int) -> float: + def _is_error_packet(self, data: list[str]) -> bool: + """Check if this is an error packet""" + return data[2] == "10" and all(b == "FF" for b in data[3:]) + + def _is_end_marker(self, data: list[str]) -> bool: + """Check if this is an end marker packet""" + # Check for command byte 0x3A, type 0x3A, and remaining bytes are 0xFF + return data[0] == "3a" and data[1] == "3a" and all(b == "FF" for b in data[3:]) + + def _get_timestamp(self, data: list[str]) -> datetime: + """Return timestamp""" + # The timestamp is a 4-byte value after the header byte, in seconds since Unix epoch + timestamp_bytes = bytes.fromhex("".join(data[3:7])) + timestamp = int.from_bytes(timestamp_bytes, "big") + return datetime.fromtimestamp(timestamp) + + def _get_temperature(self, data: list[str]) -> Optional[float]: """Return temperature in celsius""" - return round(data * 0.01, 2) + if data[1] != "30": # '0' for temperature + return None + # Temperature is in 0.01°C units, little-endian + temp_bytes = bytes.fromhex("".join(data[9:11])) + temp_raw = int.from_bytes(temp_bytes, "big") + return round(temp_raw * 0.01, 2) - def _get_humidity(self, data: int) -> float: + def _get_humidity(self, data: list[str]) -> Optional[float]: """Return humidity %""" - return round(data * 0.01, 2) + if data[1] != "31": # '1' for humidity + return None + # Humidity is in 0.01% units, little-endian + humidity_bytes = bytes.fromhex("".join(data[9:11])) + humidity_raw = int.from_bytes(humidity_bytes, "big") + return round(humidity_raw * 0.01, 2) - def _get_pressure(self, data: int) -> float: + def _get_pressure(self, data: list[str]) -> Optional[float]: """Return air pressure hPa""" - # Data is already in Pa, convert to hPa - return round(data / 100, 2) + if data[1] != "32": # '2' for pressure + return None + # Pressure is in hPa units, little-endian + pressure_bytes = bytes.fromhex("".join(data[9:11])) + pressure_raw = int.from_bytes(pressure_bytes, "big") + return float(pressure_raw) 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) + The data format follows the NUS log format. Args: - data: Raw history data bytearray (16 bytes) + data: Raw history data bytearray Returns: SensorDataHistory: Decoded sensor values with timestamp, or None if decoding fails + Returns None for both invalid data and end marker packets """ 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)) + hex_values = [format(x, "02x") for x in data] + + if len(hex_values) != 11: + log.error("History data too short: %d bytes", len(hex_values)) return None - # Use correct formats: - # I = unsigned int (uint32_t) - # i = signed int (int32_t) - # < = little-endian - ts, temp, hum, press = struct.unpack(" Date: Sat, 1 Feb 2025 10:28:51 +0200 Subject: [PATCH 08/22] Update test cases with data from documentation --- tests/test_decoder.py | 45 +++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 05d2993..ea0e72d 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -164,46 +164,71 @@ def test_parse_df5_mac(self): parsed = parse_mac(5, mac_payload) assert parsed == mac - @pytest.mark.skip("Ignored test") def test_history_decode_docs_temperature(self): # Data from: https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read # 0x3A 30 10 5D57FEAD 000000098D - sample_clean = ["3a", "30", "10", "5D", "57", "FE", "AD", "00", "00", "00", "09", "8D"] + sample_clean = ["3a", "30", "10", "5D", "57", "FE", "AD", "00", "00", "09", "8D"] sample_bytes = bytearray.fromhex("".join(sample_clean)) decoder = HistoryDecoder() data = decoder.decode_data(sample_bytes) + # 2019-08-13 13:18 24.45 C“ assert data["temperature"] == 24.45 - assert data["timestamp"] == datetime.fromisoformat("2019-08-13 13:18") + # TODO: Check datetime if it is correct in docs + assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) + + def test_history_decode_docs_humidity(self): + # Data from: https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read + # 0x3A 31 10 5D57FEAD 000000098D + sample_clean = ["3a", "31", "10", "5D", "57", "FE", "AD", "00", "00", "09", "8D"] + sample_bytes = bytearray.fromhex("".join(sample_clean)) + decoder = HistoryDecoder() + data = decoder.decode_data(sample_bytes) + # 2019-08-13 13:18 24.45 RH-% + assert data["humidity"] == 24.45 + # TODO: Check datetime if it is correct in docs + assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) + + def test_history_decode_docs_pressure(self): + # Data from: https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read + # 0x3A 32 10 5D57FEAD 000000098D + sample_clean = ["3a", "32", "10", "5D", "57", "FE", "AD", "00", "00", "09", "8D"] + sample_bytes = bytearray.fromhex("".join(sample_clean)) + decoder = HistoryDecoder() + data = decoder.decode_data(sample_bytes) + # 2019-08-13 13:18 2445 Pa + assert data["pressure"] == 2445 + # TODO: Check datetime if it is correct in docs + assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_real_samples(self): decoder = HistoryDecoder() - data = bytearray(b':0\x10g\x9d\xb5"\x00\x00\x08\xe3') # Test temperature data + data = bytearray(b':0\x10g\x9d\xb5"\x00\x00\x08\xe3') result = decoder.decode_data(data) assert result is not None - assert result["temperature"] is not None + assert result["temperature"] == 22.75 assert result["humidity"] is None assert result["pressure"] is None - # assert result["timestamp"] == datetime.fromtimestamp(2946838375) + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) # Test humidity data data = bytearray(b':1\x10g\x9d\xb5"\x00\x00\x10\x90') result = decoder.decode_data(data) assert result is not None - assert result["humidity"] is not None # 0x100A = 4106, so 41.14% + assert result["humidity"] == 42.4 assert result["temperature"] is None assert result["pressure"] is None - # assert result["timestamp"] == datetime.fromtimestamp(2946838375) + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) # Test pressure data data = bytearray(b':2\x10g\x9d\xb5"\x00\x01\x8b@') result = decoder.decode_data(data) assert result is not None - assert result["pressure"] is not None # 0x18775 = 99861, so 998.61 hPa + assert result["pressure"] == 35648 assert result["temperature"] is None assert result["humidity"] is None - # assert result["timestamp"] == datetime.fromtimestamp(2946838375) + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) def test_history_decode_is_error(self): decoder = HistoryDecoder() From 330b6ca0c5b535cafb31072fd2bacf19cd47cbd5 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 11:16:03 +0200 Subject: [PATCH 09/22] Change get_history_data to return async generator --- examples/get_history_async.py | 20 ++++ ruuvitag_sensor/adapters/bleak_ble.py | 155 +++++++++++++------------- ruuvitag_sensor/decoder.py | 8 +- ruuvitag_sensor/ruuvi.py | 26 ++++- tests/test_decoder.py | 6 + 5 files changed, 129 insertions(+), 86 deletions(-) create mode 100644 examples/get_history_async.py diff --git a/examples/get_history_async.py b/examples/get_history_async.py new file mode 100644 index 0000000..fdf7cbf --- /dev/null +++ b/examples/get_history_async.py @@ -0,0 +1,20 @@ +import asyncio +from datetime import datetime, timedelta + +import ruuvitag_sensor.log +from ruuvitag_sensor.ruuvi import RuuviTagSensor + +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" + start_time = datetime.now() - timedelta(minutes=60) + async for data in RuuviTagSensor.get_history_async(mac, start_time=start_time): + print(data) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 0e82665..413d815 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -127,7 +127,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[bytearray]: + ) -> AsyncGenerator[bytearray, None]: """ Get history data from a RuuviTag using GATT connection. @@ -136,15 +136,13 @@ async def get_history_data( start_time (datetime, optional): Start time for history data max_items (int, optional): Maximum number of history entries to fetch - Returns: - List[dict]: List of historical sensor readings + Yields: + bytearray: Raw history data entries Raises: RuntimeError: If connection fails or required services not found """ - history_data: List[bytearray] = [] client = None - try: # Connect to device client = await self._connect_gatt(mac) @@ -152,15 +150,87 @@ 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) - return history_data + history_service = next( + (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), + None, + ) + if not history_service: + raise RuntimeError("History service not found - device may not support history") + + tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) + rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) + + if not tx_char or not rx_char: + raise RuntimeError("Required characteristics not found") + + data_queue: asyncio.Queue[Optional[bytearray]] = asyncio.Queue() + + def notification_handler(_, data: bytearray): + # Ignore heartbeat data that starts with 0x05 + if data and data[0] == 0x05: + log.debug("Ignoring heartbeat data") + return + log.debug("Received data: %s", data) + # Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF...) + if len(data) >= 3 and all(b == 0xFF for b in data[3:]): + log.debug("Received end-of-logs marker") + data_queue.put_nowait(data) + data_queue.put_nowait(None) + return + # Check for error message (0x30 30 F0 FF FF FF FF FF FF FF FF) + if len(data) >= 11 and data[0:2] == b"00" and data[2] == 0xF0: + log.error("Device reported error in log reading") + data_queue.put_nowait(data) + data_queue.put_nowait(None) + return + data_queue.put_nowait(data) + + await client.start_notify(tx_char, notification_handler) + + end_time = int(datetime.now().timestamp()) + start_time_to_use = int(start_time.timestamp()) if start_time else 0 + + command = bytearray( + [ + 0x3A, + 0x3A, + 0x11, # Header for temperature query + (end_time >> 24) & 0xFF, # End timestamp byte 1 (most significant) + (end_time >> 16) & 0xFF, # End timestamp byte 2 + (end_time >> 8) & 0xFF, # End timestamp byte 3 + end_time & 0xFF, # End timestamp byte 4 + (start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant) + (start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2 + (start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3 + start_time_to_use & 0xFF, # Start timestamp byte 4 + ] + ) + + log.debug("Sending command: %s", command) + await client.write_gatt_char(rx_char, command) + log.debug("Sent history command to device") + + items_received = 0 + while True: + try: + data = await asyncio.wait_for(data_queue.get(), timeout=10.0) + if data is None: + break + yield data + items_received += 1 + if max_items and items_received >= max_items: + break + except asyncio.TimeoutError: + log.error("Timeout waiting for history data") + break + except Exception as e: log.error("Failed to get history data from device %s: %r", mac, e) + raise finally: if client: await client.disconnect() log.debug("Disconnected from device %s", mac) - return [] async def _connect_gatt(self, mac: str) -> BleakClient: """ @@ -178,72 +248,3 @@ async def _connect_gatt(self, mac: str) -> BleakClient: # TODO: Implement retry logic. connect fails for some reason pretty often. await client.connect() return client - - async def _collect_history_data( - self, client: BleakClient, start_time: Optional[datetime], max_items: Optional[int] - ) -> List[bytearray]: - history_data: List[bytearray] = [] - - history_service = next( - (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), - None, - ) - if not history_service: - raise RuntimeError("History service not found - device may not support history") - - tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) - rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) - - if not tx_char or not rx_char: - raise RuntimeError("Required characteristics not found") - - notification_received = asyncio.Event() - - def notification_handler(_, data: bytearray): - # Ignore heartbeat data that starts with 0x05 - if data and data[0] == 0x05: - log.debug("Ignoring heartbeat data") - return - log.debug("Received data: %s", data) - # Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF...) - if len(data) >= 3 and all(b == 0xFF for b in data[3:]): - log.debug("Received end-of-logs marker") - notification_received.set() - return - # Check for error message (0x30 30 F0 FF FF FF FF FF FF FF FF) - if len(data) >= 11 and data[0:2] == b"00" and data[2] == 0xF0: - log.error("Device reported error in log reading") - notification_received.set() - return - history_data.append(data) - - await client.start_notify(tx_char, notification_handler) - - end_time = int(datetime.now().timestamp()) - start_time_to_use = int(start_time.timestamp()) if start_time else 0 - - command = bytearray( - [ - 0x3A, - 0x3A, - 0x11, # Header for temperature query - (end_time >> 24) & 0xFF, # End timestamp byte 1 (most significant) - (end_time >> 16) & 0xFF, # End timestamp byte 2 - (end_time >> 8) & 0xFF, # End timestamp byte 3 - end_time & 0xFF, # End timestamp byte 4 - (start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant) - (start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2 - (start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3 - start_time_to_use & 0xFF, # Start timestamp byte 4 - ] - ) - - log.debug("Sending command: %s", command) - - await client.write_gatt_char(rx_char, command) - log.debug("Sent history command to device") - - await asyncio.wait_for(notification_received.wait(), timeout=60.0) - notification_received.clear() - - return history_data diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 69e8b6b..5acfea0 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -308,12 +308,12 @@ class HistoryDecoder: def _is_error_packet(self, data: list[str]) -> bool: """Check if this is an error packet""" - return data[2] == "10" and all(b == "FF" for b in data[3:]) + return data[2] == "10" and all(b == "ff" for b in data[3:]) def _is_end_marker(self, data: list[str]) -> bool: """Check if this is an end marker packet""" # Check for command byte 0x3A, type 0x3A, and remaining bytes are 0xFF - return data[0] == "3a" and data[1] == "3a" and all(b == "FF" for b in data[3:]) + return data[0] == "3a" and data[1] == "3a" and all(b == "ff" for b in data[3:]) def _get_timestamp(self, data: list[str]) -> datetime: """Return timestamp""" @@ -408,9 +408,9 @@ def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: "timestamp": self._get_timestamp(hex_values), } else: - log.error("Invalid packet type: %d", packet_type) + log.error("Invalid packet type: %d - %s", packet_type, data) return None except Exception: - log.exception("Value: %s not valid", data) + log.exception("Value not valid: %s", data) return None diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 7cb3a81..83d2da5 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -355,7 +355,7 @@ def _parse_data( @staticmethod async def get_history_async( mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None - ) -> List[SensorHistoryData]: + ) -> AsyncGenerator[SensorHistoryData, None]: """ Get history data from a RuuviTag that supports it (firmware 3.30.0+) @@ -365,10 +365,19 @@ async def get_history_async( max_items (int, optional): Maximum number of history entries to fetch. If None, gets all available data Returns: - List[SensorData]: List of historical sensor readings + AsyncGenerator[SensorHistoryData, None]: List of historical sensor readings """ throw_if_not_async_adapter(ble) - return await ble.get_history_data(mac, start_time, max_items) + + decoder = HistoryDecoder() + data_iter = ble.get_history_data(mac, start_time, max_items) + try: + async for data in data_iter: + history_data = decoder.decode_data(data) + if history_data: + yield history_data + finally: + await data_iter.aclose() @staticmethod async def download_history( @@ -393,9 +402,16 @@ async def download_history( throw_if_not_async_adapter(ble) try: - history = await asyncio.wait_for(ble.get_history_data(mac, start_time, max_items), timeout=timeout) + history_data: List[SensorHistoryData] = [] decoder = HistoryDecoder() - return [d for h in history if (d := decoder.decode_data(h)) is not None] + + async def collect_history(): + async for data in ble.get_history_data(mac, start_time, max_items): + if decoded := decoder.decode_data(data): + history_data.append(decoded) + + await asyncio.wait_for(collect_history(), timeout=timeout) + return history_data except asyncio.TimeoutError: raise TimeoutError(f"History download timed out after {timeout} seconds") diff --git a/tests/test_decoder.py b/tests/test_decoder.py index ea0e72d..fd8e474 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -230,6 +230,12 @@ def test_history_decode_real_samples(self): assert result["humidity"] is None assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + def test_history_end_marker(self): + decoder = HistoryDecoder() + data = bytearray(b"::\x10\xff\xff\xff\xff\xff\xff\xff\xff") + result = decoder.decode_data(data) + assert result is None + def test_history_decode_is_error(self): decoder = HistoryDecoder() From 84886918a7c21f5a003bfa1673f003952c305528 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 11:18:54 +0200 Subject: [PATCH 10/22] Update function documentation --- ruuvitag_sensor/ruuvi.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 83d2da5..d2e1c1b 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -357,15 +357,20 @@ async def get_history_async( mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None ) -> AsyncGenerator[SensorHistoryData, None]: """ - Get history data from a RuuviTag that supports it (firmware 3.30.0+) + Get history data from a RuuviTag as an async stream. Requires firmware version 3.30.0 or newer. + Each history entry contains one measurement type (temperature, humidity, or pressure). + Use download_history() if you want to get all measurements combined by timestamp. Args: - mac (str): MAC address of the RuuviTag - start_time (datetime, optional): Start time for history data. If None, gets all available data - max_items (int, optional): Maximum number of history entries to fetch. If None, gets all available data + mac (str): MAC address of the RuuviTag. On macOS use UUID instead. + start_time (Optional[datetime]): If provided, only get data from this time onwards + max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data - Returns: - AsyncGenerator[SensorHistoryData, None]: List of historical sensor readings + Yields: + SensorHistoryData: Individual history measurements with timestamp. Each entry contains one measurement type. + + Raises: + RuntimeError: If connection fails or device doesn't support history """ throw_if_not_async_adapter(ble) @@ -384,7 +389,12 @@ async def download_history( mac: str, start_time: Optional[datetime] = None, timeout: int = 300, max_items: Optional[int] = None ) -> List[SensorHistoryData]: """ - Download history data from a RuuviTag. Requires firmware version 3.30.0 or newer. + Download complete history data from a RuuviTag. Requires firmware version 3.30.0 or newer. + This method collects all history entries and returns them as a list. + Each history entry contains one measurement type (temperature, humidity, or pressure). + + Note: The RuuviTag sends each measurement type (temperature, humidity, pressure) as separate entries. + If you need to combine measurements by timestamp, you'll need to post-process the data. Args: mac (str): MAC address of the RuuviTag. On macOS use UUID instead. @@ -393,7 +403,8 @@ async def download_history( max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Returns: - List[HistoryDecoder]: List of historical measurements, ordered by timestamp + List[SensorHistoryData]: List of historical measurements, ordered by timestamp. + Each entry contains one measurement type. Raises: RuntimeError: If connection fails or device doesn't support history From 2313de645658c73e7b709e5f66179371f5d2d43a Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 11:35:21 +0200 Subject: [PATCH 11/22] Ruff fixes --- ruuvitag_sensor/adapters/bleak_ble.py | 80 +++++++++++++++------------ ruuvitag_sensor/decoder.py | 2 +- ruuvitag_sensor/ruuvi.py | 2 +- tests/test_decoder.py | 2 - 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 413d815..0ab69e0 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import AsyncGenerator, List, Optional, Tuple -from bleak import BleakClient, BleakScanner +from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback, BLEDevice from ruuvitag_sensor.adapters import BleCommunicationAsync @@ -144,24 +144,11 @@ async def get_history_data( """ client = None try: - # Connect to device + log.debug("Connecting to device %s", mac) client = await self._connect_gatt(mac) log.debug("Connected to device %s", mac) - # Get the history service - # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus - history_service = next( - (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), - None, - ) - if not history_service: - raise RuntimeError("History service not found - device may not support history") - - tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) - rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) - - if not tx_char or not rx_char: - raise RuntimeError("Required characteristics not found") + tx_char, rx_char = self._get_history_service_characteristics(client) data_queue: asyncio.Queue[Optional[bytearray]] = asyncio.Queue() @@ -187,24 +174,7 @@ def notification_handler(_, data: bytearray): await client.start_notify(tx_char, notification_handler) - end_time = int(datetime.now().timestamp()) - start_time_to_use = int(start_time.timestamp()) if start_time else 0 - - command = bytearray( - [ - 0x3A, - 0x3A, - 0x11, # Header for temperature query - (end_time >> 24) & 0xFF, # End timestamp byte 1 (most significant) - (end_time >> 16) & 0xFF, # End timestamp byte 2 - (end_time >> 8) & 0xFF, # End timestamp byte 3 - end_time & 0xFF, # End timestamp byte 4 - (start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant) - (start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2 - (start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3 - start_time_to_use & 0xFF, # Start timestamp byte 4 - ] - ) + command = self._create_send_history_command(start_time) log.debug("Sending command: %s", command) await client.write_gatt_char(rx_char, command) @@ -248,3 +218,45 @@ async def _connect_gatt(self, mac: str) -> BleakClient: # TODO: Implement retry logic. connect fails for some reason pretty often. await client.connect() return client + + def _get_history_service_characteristics( + self, client: BleakClient + ) -> Tuple[BleakGATTCharacteristic, BleakGATTCharacteristic]: + # Get the history service + # https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus + history_service = next( + (service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()), + None, + ) + if not history_service: + raise RuntimeError("History service not found - device may not support history") + + tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID) + rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID) + + if not tx_char or not rx_char: + raise RuntimeError("Required characteristics not found") + + return tx_char, rx_char + + def _create_send_history_command(self, start_time): + end_time = int(datetime.now().timestamp()) + start_time_to_use = int(start_time.timestamp()) if start_time else 0 + + command = bytearray( + [ + 0x3A, + 0x3A, + 0x11, # Header for temperature query + (end_time >> 24) & 0xFF, # End timestamp byte 1 (most significant) + (end_time >> 16) & 0xFF, # End timestamp byte 2 + (end_time >> 8) & 0xFF, # End timestamp byte 3 + end_time & 0xFF, # End timestamp byte 4 + (start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant) + (start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2 + (start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3 + start_time_to_use & 0xFF, # Start timestamp byte 4 + ] + ) + + return command diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 5acfea0..8736da4 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -349,7 +349,7 @@ def _get_pressure(self, data: list[str]) -> Optional[float]: pressure_raw = int.from_bytes(pressure_bytes, "big") return float(pressure_raw) - def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: + def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: # noqa: PLR0911 """ Decode history data from RuuviTag. diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index d2e1c1b..7cb9031 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -419,7 +419,7 @@ async def download_history( async def collect_history(): async for data in ble.get_history_data(mac, start_time, max_items): if decoded := decoder.decode_data(data): - history_data.append(decoded) + history_data.extend(decoded) await asyncio.wait_for(collect_history(), timeout=timeout) return history_data diff --git a/tests/test_decoder.py b/tests/test_decoder.py index fd8e474..691954f 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -1,8 +1,6 @@ from datetime import datetime from unittest import TestCase -import pytest - from ruuvitag_sensor.decoder import Df3Decoder, Df5Decoder, HistoryDecoder, UrlDecoder, get_decoder, parse_mac From 07900a524c7a760159090de4febd4cd8bb547660 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 17:09:25 +0200 Subject: [PATCH 12/22] Comapre timestamps on test --- tests/test_decoder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 691954f..cac6743 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -208,7 +208,7 @@ def test_history_decode_real_samples(self): assert result["temperature"] == 22.75 assert result["humidity"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() # Test humidity data data = bytearray(b':1\x10g\x9d\xb5"\x00\x00\x10\x90') @@ -217,7 +217,7 @@ def test_history_decode_real_samples(self): assert result["humidity"] == 42.4 assert result["temperature"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() # Test pressure data data = bytearray(b':2\x10g\x9d\xb5"\x00\x01\x8b@') @@ -226,7 +226,7 @@ def test_history_decode_real_samples(self): assert result["pressure"] == 35648 assert result["temperature"] is None assert result["humidity"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() def test_history_end_marker(self): decoder = HistoryDecoder() From 36f6ca017b5015d5adf9c4abe3cc6365dcaa10da Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 17:19:27 +0200 Subject: [PATCH 13/22] Force timestamp timezone to None --- ruuvitag_sensor/decoder.py | 2 +- tests/test_decoder.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 8736da4..940c2e5 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -320,7 +320,7 @@ def _get_timestamp(self, data: list[str]) -> datetime: # The timestamp is a 4-byte value after the header byte, in seconds since Unix epoch timestamp_bytes = bytes.fromhex("".join(data[3:7])) timestamp = int.from_bytes(timestamp_bytes, "big") - return datetime.fromtimestamp(timestamp) + return datetime.fromtimestamp(timestamp, tz=None) def _get_temperature(self, data: list[str]) -> Optional[float]: """Return temperature in celsius""" diff --git a/tests/test_decoder.py b/tests/test_decoder.py index cac6743..691954f 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -208,7 +208,7 @@ def test_history_decode_real_samples(self): assert result["temperature"] == 22.75 assert result["humidity"] is None assert result["pressure"] is None - assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) # Test humidity data data = bytearray(b':1\x10g\x9d\xb5"\x00\x00\x10\x90') @@ -217,7 +217,7 @@ def test_history_decode_real_samples(self): assert result["humidity"] == 42.4 assert result["temperature"] is None assert result["pressure"] is None - assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) # Test pressure data data = bytearray(b':2\x10g\x9d\xb5"\x00\x01\x8b@') @@ -226,7 +226,7 @@ def test_history_decode_real_samples(self): assert result["pressure"] == 35648 assert result["temperature"] is None assert result["humidity"] is None - assert result["timestamp"].timestamp() == datetime(2025, 2, 1, 7, 46, 10).timestamp() + assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) def test_history_end_marker(self): decoder = HistoryDecoder() From 3f38a60807cbe829cd9bb645abb5ebcc7ec1a2a4 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 17:27:31 +0200 Subject: [PATCH 14/22] Force timestamp to UTC --- ruuvitag_sensor/decoder.py | 4 ++-- ruuvitag_sensor/ruuvi.py | 5 +++-- tests/test_decoder.py | 20 +++++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 940c2e5..0e5b0ef 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -2,7 +2,7 @@ import logging import math import struct -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, Tuple, Union from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl, SensorHistoryData @@ -320,7 +320,7 @@ def _get_timestamp(self, data: list[str]) -> datetime: # The timestamp is a 4-byte value after the header byte, in seconds since Unix epoch timestamp_bytes = bytes.fromhex("".join(data[3:7])) timestamp = int.from_bytes(timestamp_bytes, "big") - return datetime.fromtimestamp(timestamp, tz=None) + return datetime.fromtimestamp(timestamp, tz=timezone.utc) def _get_temperature(self, data: list[str]) -> Optional[float]: """Return temperature in celsius""" diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 7cb9031..823bc6d 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -367,7 +367,8 @@ async def get_history_async( max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Yields: - SensorHistoryData: Individual history measurements with timestamp. Each entry contains one measurement type. + SensorHistoryData: Individual history measurements with timestamp (UTC). + Each entry contains one measurement type. Raises: RuntimeError: If connection fails or device doesn't support history @@ -403,7 +404,7 @@ async def download_history( max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Returns: - List[SensorHistoryData]: List of historical measurements, ordered by timestamp. + List[SensorHistoryData]: List of historical measurements, ordered by timestamp (UTC). Each entry contains one measurement type. Raises: diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 691954f..0b9d88e 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from unittest import TestCase from ruuvitag_sensor.decoder import Df3Decoder, Df5Decoder, HistoryDecoder, UrlDecoder, get_decoder, parse_mac @@ -172,7 +172,9 @@ def test_history_decode_docs_temperature(self): # 2019-08-13 13:18 24.45 C“ assert data["temperature"] == 24.45 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) + assert data["timestamp"] == datetime( + 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_docs_humidity(self): # Data from: https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read @@ -184,7 +186,9 @@ def test_history_decode_docs_humidity(self): # 2019-08-13 13:18 24.45 RH-% assert data["humidity"] == 24.45 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) + assert data["timestamp"] == datetime( + 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_docs_pressure(self): # Data from: https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus/log-read @@ -196,7 +200,9 @@ def test_history_decode_docs_pressure(self): # 2019-08-13 13:18 2445 Pa assert data["pressure"] == 2445 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime(2019, 8, 17, 16, 18, 37) # datetime(2019, 8, 13, 13, 18, 37) + assert data["timestamp"] == datetime( + 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_real_samples(self): decoder = HistoryDecoder() @@ -208,7 +214,7 @@ def test_history_decode_real_samples(self): assert result["temperature"] == 22.75 assert result["humidity"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) # Test humidity data data = bytearray(b':1\x10g\x9d\xb5"\x00\x00\x10\x90') @@ -217,7 +223,7 @@ def test_history_decode_real_samples(self): assert result["humidity"] == 42.4 assert result["temperature"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) # Test pressure data data = bytearray(b':2\x10g\x9d\xb5"\x00\x01\x8b@') @@ -226,7 +232,7 @@ def test_history_decode_real_samples(self): assert result["pressure"] == 35648 assert result["temperature"] is None assert result["humidity"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 7, 46, 10) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) def test_history_end_marker(self): decoder = HistoryDecoder() From e7a301224442f9c5244af326eef82c13b5278236 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 21:52:00 +0200 Subject: [PATCH 15/22] Update README and CHANGELOG --- CHANGELOG.md | 2 +- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d936a..ab937eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Changelog ### [Unreleased] - +* ADD: Support for reading history data from RuuviTags ## [3.0.0] - 2025-01-13 * ADD: Install Bleak automatically on all platforms diff --git a/README.md b/README.md index 7ed04d8..7e6a974 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ RuuviTag Sensor Python Package * Bleak supports * [Async-methods](#usage) * [Observable streams](#usage) + * [Download history data](#usage) * [Install guide](#Bleak) * Bluez (Linux-only) * Bluez supports @@ -66,6 +67,7 @@ The package provides 3 ways to fetch data from sensors: 1. Asynchronously with async/await 2. Synchronously with callback 3. Observable streams with ReactiveX +4. Downloading history data with async/await RuuviTag sensors can be identified using MAC addresses. Methods return a tuple containing MAC and sensor data payload. @@ -202,6 +204,44 @@ More [samples](https://github.com/ttu/ruuvitag-sensor/blob/master/examples/react Check the official documentation of [ReactiveX](https://rxpy.readthedocs.io/en/latest/index.html) and the [list of operators](https://rxpy.readthedocs.io/en/latest/operators.html). +### 4. Downloading history data + +__NOTE:__ History data functionality works only with `Bleak`-adapter. + +RuuviTags with firmware version 3.30.0 or newer support retrieving historical measurements. The package provides two methods to access this data: + +1. `get_history_async`: Stream history entries as they arrive +2. `download_history`: Download all history entries at once + +Each history entry contains one measurement type (temperature, humidity, or pressure) with a UTC timestamp. The RuuviTag sends each measurement type as a separate entry. + +```py +import asyncio +from datetime import datetime, timedelta + +from ruuvitag_sensor.ruuvi import RuuviTagSensor + + +async def main(): + # Get history from the last 10 minutes + start_time = datetime.now() - timedelta(minutes=10) + + # Stream entries as they arrive + async for entry in RuuviTagSensor.get_history_async(mac="AA:BB:CC:DD:EE:FF", start_time=start_time): + print(f"Time: {entry['timestamp']} - {entry}") + + # Or download all entries at once + history = await RuuviTagSensor.download_history(mac="AA:BB:CC:DD:EE:FF", start_time=start_time) + for entry in history: + print(f"Time: {entry['timestamp']} - {entry}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +__NOTE:__ Due to the way macOS handles Bluetooth, methods uses UUIDs to identify RuuviTags instead of MAC addresses. + ### Other helper methods #### Get data for specified sensors for a specific duration From 71c2ca201a5a73d2eac6aaf9959cbf6243429c56 Mon Sep 17 00:00:00 2001 From: ttu Date: Sat, 1 Feb 2025 21:58:50 +0200 Subject: [PATCH 16/22] Update examples --- examples/download_history.py | 5 +++-- examples/get_history_async.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/download_history.py b/examples/download_history.py index 59877d5..0cc8ccc 100644 --- a/examples/download_history.py +++ b/examples/download_history.py @@ -4,13 +4,14 @@ import ruuvitag_sensor.log from ruuvitag_sensor.ruuvi import RuuviTagSensor +# Enable debug logging to see the raw data ruuvitag_sensor.log.enable_console(10) async def main(): + mac = "CA:F7:44:DE:EB:E1" # 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" + # mac = "873A13F5-ED14-AEE1-E446-6ACF31649A1D" start_time = datetime.now() - timedelta(minutes=10) data = await RuuviTagSensor.download_history(mac, start_time=start_time) print(data) diff --git a/examples/get_history_async.py b/examples/get_history_async.py index fdf7cbf..4737a5b 100644 --- a/examples/get_history_async.py +++ b/examples/get_history_async.py @@ -4,13 +4,14 @@ import ruuvitag_sensor.log from ruuvitag_sensor.ruuvi import RuuviTagSensor +# Enable debug logging to see the raw data ruuvitag_sensor.log.enable_console(10) async def main(): + mac = "CA:F7:44:DE:EB:E1" # 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" + # mac = "873A13F5-ED14-AEE1-E446-6ACF31649A1D" start_time = datetime.now() - timedelta(minutes=60) async for data in RuuviTagSensor.get_history_async(mac, start_time=start_time): print(data) From c6f7359be6a531bba635d40f1e5e8f5eb18fe9ee Mon Sep 17 00:00:00 2001 From: ttu Date: Sun, 2 Feb 2025 08:20:15 +0200 Subject: [PATCH 17/22] Change timestamp from datetime to int --- ruuvitag_sensor/decoder.py | 6 +++--- ruuvitag_sensor/ruuvi.py | 17 +++++++---------- ruuvitag_sensor/ruuvi_types.py | 3 +-- tests/test_decoder.py | 18 +++++++++--------- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 0e5b0ef..740baf4 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -2,7 +2,6 @@ import logging import math import struct -from datetime import datetime, timezone from typing import Optional, Tuple, Union from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl, SensorHistoryData @@ -315,12 +314,13 @@ def _is_end_marker(self, data: list[str]) -> bool: # Check for command byte 0x3A, type 0x3A, and remaining bytes are 0xFF return data[0] == "3a" and data[1] == "3a" and all(b == "ff" for b in data[3:]) - def _get_timestamp(self, data: list[str]) -> datetime: + def _get_timestamp(self, data: list[str]) -> int: """Return timestamp""" # The timestamp is a 4-byte value after the header byte, in seconds since Unix epoch timestamp_bytes = bytes.fromhex("".join(data[3:7])) timestamp = int.from_bytes(timestamp_bytes, "big") - return datetime.fromtimestamp(timestamp, tz=timezone.utc) + return timestamp + # return datetime.fromtimestamp(timestamp, tz=timezone.utc) def _get_temperature(self, data: list[str]) -> Optional[float]: """Return temperature in celsius""" diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 823bc6d..4e88194 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -358,17 +358,15 @@ async def get_history_async( ) -> AsyncGenerator[SensorHistoryData, None]: """ Get history data from a RuuviTag as an async stream. Requires firmware version 3.30.0 or newer. - Each history entry contains one measurement type (temperature, humidity, or pressure). - Use download_history() if you want to get all measurements combined by timestamp. + Each history entry contains one measurement type (temperature, humidity, or pressure) with Unix timestamp (integer). Args: mac (str): MAC address of the RuuviTag. On macOS use UUID instead. - start_time (Optional[datetime]): If provided, only get data from this time onwards + start_time (Optional[datetime]): If provided, only get data from this time onwards. Time should be in UTC. max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Yields: - SensorHistoryData: Individual history measurements with timestamp (UTC). - Each entry contains one measurement type. + SensorHistoryData: Individual history measurements Raises: RuntimeError: If connection fails or device doesn't support history @@ -392,20 +390,19 @@ async def download_history( """ Download complete history data from a RuuviTag. Requires firmware version 3.30.0 or newer. This method collects all history entries and returns them as a list. - Each history entry contains one measurement type (temperature, humidity, or pressure). + Each history entry contains one measurement type (temperature, humidity, or pressure) with Unix timestamp (integer). Note: The RuuviTag sends each measurement type (temperature, humidity, pressure) as separate entries. If you need to combine measurements by timestamp, you'll need to post-process the data. Args: mac (str): MAC address of the RuuviTag. On macOS use UUID instead. - start_time (Optional[datetime]): If provided, only get data from this time onwards + start_time (Optional[datetime]): If provided, only get data from this time onwards. timeout (int): Maximum time in seconds to wait for history download (default: 300) max_items (Optional[int]): Maximum number of history entries to fetch. If None, gets all available data Returns: - List[SensorHistoryData]: List of historical measurements, ordered by timestamp (UTC). - Each entry contains one measurement type. + List[SensorHistoryData]: List of historical measurements Raises: RuntimeError: If connection fails or device doesn't support history @@ -420,7 +417,7 @@ async def download_history( async def collect_history(): async for data in ble.get_history_data(mac, start_time, max_items): if decoded := decoder.decode_data(data): - history_data.extend(decoded) + history_data.append(decoded) # noqa: PERF401 await asyncio.wait_for(collect_history(), timeout=timeout) return history_data diff --git a/ruuvitag_sensor/ruuvi_types.py b/ruuvitag_sensor/ruuvi_types.py index 6cce5bf..9e95cd8 100644 --- a/ruuvitag_sensor/ruuvi_types.py +++ b/ruuvitag_sensor/ruuvi_types.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Optional, Tuple, TypedDict, Union @@ -44,7 +43,7 @@ class SensorHistoryData(TypedDict): humidity: Optional[float] temperature: Optional[float] pressure: Optional[float] - timestamp: datetime + timestamp: int SensorData = Union[SensorDataUrl, SensorData3, SensorData5] diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 0b9d88e..1361b6f 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -172,8 +172,8 @@ def test_history_decode_docs_temperature(self): # 2019-08-13 13:18 24.45 C“ assert data["temperature"] == 24.45 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime( - 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + assert ( + data["timestamp"] == datetime(2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc).timestamp() ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_docs_humidity(self): @@ -186,8 +186,8 @@ def test_history_decode_docs_humidity(self): # 2019-08-13 13:18 24.45 RH-% assert data["humidity"] == 24.45 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime( - 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + assert ( + data["timestamp"] == datetime(2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc).timestamp() ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_docs_pressure(self): @@ -200,8 +200,8 @@ def test_history_decode_docs_pressure(self): # 2019-08-13 13:18 2445 Pa assert data["pressure"] == 2445 # TODO: Check datetime if it is correct in docs - assert data["timestamp"] == datetime( - 2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc + assert ( + data["timestamp"] == datetime(2019, 8, 17, 13, 18, 37, tzinfo=timezone.utc).timestamp() ) # datetime(2019, 8, 13, 13, 18, 37) def test_history_decode_real_samples(self): @@ -214,7 +214,7 @@ def test_history_decode_real_samples(self): assert result["temperature"] == 22.75 assert result["humidity"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc).timestamp() # Test humidity data data = bytearray(b':1\x10g\x9d\xb5"\x00\x00\x10\x90') @@ -223,7 +223,7 @@ def test_history_decode_real_samples(self): assert result["humidity"] == 42.4 assert result["temperature"] is None assert result["pressure"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc).timestamp() # Test pressure data data = bytearray(b':2\x10g\x9d\xb5"\x00\x01\x8b@') @@ -232,7 +232,7 @@ def test_history_decode_real_samples(self): assert result["pressure"] == 35648 assert result["temperature"] is None assert result["humidity"] is None - assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc) + assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc).timestamp() def test_history_end_marker(self): decoder = HistoryDecoder() From aefe43257aceba79120330dbe452e883f60bff5d Mon Sep 17 00:00:00 2001 From: ttu Date: Sun, 2 Feb 2025 08:27:42 +0200 Subject: [PATCH 18/22] Update documentation --- README.md | 32 ++++++++++++++++++++++++++++---- ruuvitag_sensor/ruuvi.py | 6 ++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7e6a974..d4fda78 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,11 @@ Full installation guide for [Raspberry PI & Raspbian](https://github.com/ttu/ruu ## Usage -The package provides 3 ways to fetch data from sensors: +The package provides 3 ways to fetch broadcasted data from sensors: 1. Asynchronously with async/await 2. Synchronously with callback 3. Observable streams with ReactiveX -4. Downloading history data with async/await RuuviTag sensors can be identified using MAC addresses. Methods return a tuple containing MAC and sensor data payload. @@ -75,6 +74,21 @@ RuuviTag sensors can be identified using MAC addresses. Methods return a tuple c ('D2:A3:6E:C8:E0:25', {'data_format': 5, 'humidity': 47.62, 'temperature': 23.58, 'pressure': 1023.68, 'acceleration': 993.2331045630729, 'acceleration_x': -48, 'acceleration_y': -12, 'acceleration_z': 992, 'tx_power': 4, 'battery': 2197, 'movement_counter': 0, 'measurement_sequence_number': 88, 'mac': 'd2a36ec8e025', 'rssi': -80}) ``` +Functionality to fetch RuuviTags stored history data from RuuviTags internal memory. + +4. Download history data with async/await + +Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). The RuuviTag sends each measurement type as a separate entry. + +```py +[ + {'temperature': 22.22, 'humidity': None, 'pressure': None, 'timestamp': 1738476581} + {'temperature': None, 'humidity': 38.8, 'pressure': None, 'timestamp': 1738476581}, + {'temperature': None, 'humidity': None, 'pressure': 35755.0, 'timestamp': 1738476581}, +] +``` + + ### 1. Get sensor data asynchronously with async/await __NOTE:__ Asynchronous functionality works only with `Bleak`-adapter. @@ -204,7 +218,7 @@ More [samples](https://github.com/ttu/ruuvitag-sensor/blob/master/examples/react Check the official documentation of [ReactiveX](https://rxpy.readthedocs.io/en/latest/index.html) and the [list of operators](https://rxpy.readthedocs.io/en/latest/operators.html). -### 4. Downloading history data +### 4. Download history data __NOTE:__ History data functionality works only with `Bleak`-adapter. @@ -213,7 +227,17 @@ RuuviTags with firmware version 3.30.0 or newer support retrieving historical me 1. `get_history_async`: Stream history entries as they arrive 2. `download_history`: Download all history entries at once -Each history entry contains one measurement type (temperature, humidity, or pressure) with a UTC timestamp. The RuuviTag sends each measurement type as a separate entry. +Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). The RuuviTag sends each measurement type as a separate entry. + +Example history entry: +```py +{ + 'temperature': 22.22, # Only one measurement type per entry + 'humidity': None, + 'pressure': None, + 'timestamp': 1738476581 # Unix timestamp (integer) +} +``` ```py import asyncio diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py index 4e88194..71c3a7f 100644 --- a/ruuvitag_sensor/ruuvi.py +++ b/ruuvitag_sensor/ruuvi.py @@ -358,7 +358,8 @@ async def get_history_async( ) -> AsyncGenerator[SensorHistoryData, None]: """ Get history data from a RuuviTag as an async stream. Requires firmware version 3.30.0 or newer. - Each history entry contains one measurement type (temperature, humidity, or pressure) with Unix timestamp (integer). + Each history entry contains one measurement type (temperature, humidity, or pressure) with + Unix timestamp (integer). Args: mac (str): MAC address of the RuuviTag. On macOS use UUID instead. @@ -390,7 +391,8 @@ async def download_history( """ Download complete history data from a RuuviTag. Requires firmware version 3.30.0 or newer. This method collects all history entries and returns them as a list. - Each history entry contains one measurement type (temperature, humidity, or pressure) with Unix timestamp (integer). + Each history entry contains one measurement type (temperature, humidity, or pressure) with + Unix timestamp (integer). Note: The RuuviTag sends each measurement type (temperature, humidity, pressure) as separate entries. If you need to combine measurements by timestamp, you'll need to post-process the data. From b2c6d20c8e42f13d425d9998f21dcb174c6aac97 Mon Sep 17 00:00:00 2001 From: ttu Date: Mon, 3 Feb 2025 18:11:51 +0200 Subject: [PATCH 19/22] Update documentation --- CHANGELOG.md | 2 +- README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab937eb..b9ef26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Changelog ### [Unreleased] -* ADD: Support for reading history data from RuuviTags +* ADD: Support for downloading history data from RuuviTags ## [3.0.0] - 2025-01-13 * ADD: Install Bleak automatically on all platforms diff --git a/README.md b/README.md index d4fda78..24fcbac 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Full installation guide for [Raspberry PI & Raspbian](https://github.com/ttu/ruu ## Usage +### Fetch broadcast data from RuuviTags + The package provides 3 ways to fetch broadcasted data from sensors: 1. Asynchronously with async/await @@ -74,7 +76,7 @@ RuuviTag sensors can be identified using MAC addresses. Methods return a tuple c ('D2:A3:6E:C8:E0:25', {'data_format': 5, 'humidity': 47.62, 'temperature': 23.58, 'pressure': 1023.68, 'acceleration': 993.2331045630729, 'acceleration_x': -48, 'acceleration_y': -12, 'acceleration_z': 992, 'tx_power': 4, 'battery': 2197, 'movement_counter': 0, 'measurement_sequence_number': 88, 'mac': 'd2a36ec8e025', 'rssi': -80}) ``` -Functionality to fetch RuuviTags stored history data from RuuviTags internal memory. +### Download stored history data from RuuviTags internal memory 4. Download history data with async/await From 6de1b4b37cd4dc164a41e02f83698caef449ad4e Mon Sep 17 00:00:00 2001 From: ttu Date: Mon, 3 Feb 2025 21:37:40 +0200 Subject: [PATCH 20/22] Add retry to gatt connect --- ruuvitag_sensor/adapters/bleak_ble.py | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 0ab69e0..4db7097 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -202,22 +202,22 @@ def notification_handler(_, data: bytearray): await client.disconnect() log.debug("Disconnected from device %s", mac) - async def _connect_gatt(self, mac: str) -> BleakClient: - """ - Connect to a BLE device using GATT. - - NOTE: On macOS, the device address is not a MAC address, but a system specific ID - - Args: - mac (str): MAC address of the device to connect to - - Returns: - BleakClient: Connected BLE client - """ + async def _connect_gatt(self, mac: str, max_retries: int = 3) -> BleakClient: + # Connect to a BLE device using GATT. + # NOTE: On macOS, the device address is not a MAC address, but a system specific ID client = BleakClient(mac) - # TODO: Implement retry logic. connect fails for some reason pretty often. - await client.connect() - return client + + for attempt in range(max_retries): + try: + await client.connect() + return client + except Exception as e: # noqa: PERF203 + if attempt == max_retries - 1: + raise + log.debug("Connection attempt %s failed: %s - Retrying...", attempt + 1, str(e)) + await asyncio.sleep(1) + + return client # Satisfy linter - this line will never be reached def _get_history_service_characteristics( self, client: BleakClient From 085f9d1bb9c8d50943846b20fe48f1ce4928fb40 Mon Sep 17 00:00:00 2001 From: ttu Date: Tue, 4 Feb 2025 18:20:55 +0200 Subject: [PATCH 21/22] Update documentation --- CHANGELOG.md | 2 +- README.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ef26f..153e5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Changelog ### [Unreleased] -* ADD: Support for downloading history data from RuuviTags +* ADD: Support for fetching history data from RuuviTags ## [3.0.0] - 2025-01-13 * ADD: Install Bleak automatically on all platforms diff --git a/README.md b/README.md index 24fcbac..5bf707d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ RuuviTag Sensor Python Package * Bleak supports * [Async-methods](#usage) * [Observable streams](#usage) - * [Download history data](#usage) + * [Fetch history data](#usage) * [Install guide](#Bleak) * Bluez (Linux-only) * Bluez supports @@ -76,11 +76,11 @@ RuuviTag sensors can be identified using MAC addresses. Methods return a tuple c ('D2:A3:6E:C8:E0:25', {'data_format': 5, 'humidity': 47.62, 'temperature': 23.58, 'pressure': 1023.68, 'acceleration': 993.2331045630729, 'acceleration_x': -48, 'acceleration_y': -12, 'acceleration_z': 992, 'tx_power': 4, 'battery': 2197, 'movement_counter': 0, 'measurement_sequence_number': 88, 'mac': 'd2a36ec8e025', 'rssi': -80}) ``` -### Download stored history data from RuuviTags internal memory +### Fetch stored history data from RuuviTags internal memory -4. Download history data with async/await +4. Fetch history data with async/await -Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). The RuuviTag sends each measurement type as a separate entry. +Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). RuuviTag sends each measurement type as a separate entry. ```py [ @@ -220,7 +220,7 @@ More [samples](https://github.com/ttu/ruuvitag-sensor/blob/master/examples/react Check the official documentation of [ReactiveX](https://rxpy.readthedocs.io/en/latest/index.html) and the [list of operators](https://rxpy.readthedocs.io/en/latest/operators.html). -### 4. Download history data +### 4. Fetch history data __NOTE:__ History data functionality works only with `Bleak`-adapter. @@ -229,7 +229,7 @@ RuuviTags with firmware version 3.30.0 or newer support retrieving historical me 1. `get_history_async`: Stream history entries as they arrive 2. `download_history`: Download all history entries at once -Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). The RuuviTag sends each measurement type as a separate entry. +Each history entry contains one measurement type (temperature, humidity, or pressure) with a Unix timestamp (integer). RuuviTag sends each measurement type as a separate entry. Example history entry: ```py From 30b34424b92c84e074e882f5e346b97b1cdbca65 Mon Sep 17 00:00:00 2001 From: ttu Date: Tue, 4 Feb 2025 20:06:23 +0200 Subject: [PATCH 22/22] Fix error packet checks --- ruuvitag_sensor/adapters/bleak_ble.py | 8 ++++---- ruuvitag_sensor/decoder.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ruuvitag_sensor/adapters/bleak_ble.py b/ruuvitag_sensor/adapters/bleak_ble.py index 4db7097..36c33cf 100644 --- a/ruuvitag_sensor/adapters/bleak_ble.py +++ b/ruuvitag_sensor/adapters/bleak_ble.py @@ -158,15 +158,15 @@ def notification_handler(_, data: bytearray): log.debug("Ignoring heartbeat data") return log.debug("Received data: %s", data) - # Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF...) + # Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF ...) if len(data) >= 3 and all(b == 0xFF for b in data[3:]): log.debug("Received end-of-logs marker") data_queue.put_nowait(data) data_queue.put_nowait(None) return - # Check for error message (0x30 30 F0 FF FF FF FF FF FF FF FF) - if len(data) >= 11 and data[0:2] == b"00" and data[2] == 0xF0: - log.error("Device reported error in log reading") + # Check for error message. Header is 0xF0 (0x30 30 F0 FF FF FF FF FF FF FF FF) + if len(data) >= 11 and data[2] == 0xF0: + log.debug("Device reported error in log reading") data_queue.put_nowait(data) data_queue.put_nowait(None) return diff --git a/ruuvitag_sensor/decoder.py b/ruuvitag_sensor/decoder.py index 740baf4..5ccfbcb 100644 --- a/ruuvitag_sensor/decoder.py +++ b/ruuvitag_sensor/decoder.py @@ -307,7 +307,7 @@ class HistoryDecoder: def _is_error_packet(self, data: list[str]) -> bool: """Check if this is an error packet""" - return data[2] == "10" and all(b == "ff" for b in data[3:]) + return data[2] == "F0" and all(b == "ff" for b in data[3:]) def _is_end_marker(self, data: list[str]) -> bool: """Check if this is an end marker packet""" @@ -366,17 +366,17 @@ def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: # noqa: hex_values = [format(x, "02x") for x in data] if len(hex_values) != 11: - log.error("History data too short: %d bytes", len(hex_values)) + log.info("History data too short: %d bytes", len(hex_values)) return None # Verify this is a history log entry if hex_values[0] != "3a": # ':' - log.error("Invalid command byte: %d", data[0]) + log.info("Invalid command byte: %d", data[0]) return None # Check for error header if self._is_error_packet(hex_values): - log.error("Device reported error in log reading") + log.info("Device reported error in log reading") return None # Check for end marker packet @@ -408,7 +408,7 @@ def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: # noqa: "timestamp": self._get_timestamp(hex_values), } else: - log.error("Invalid packet type: %d - %s", packet_type, data) + log.info("Invalid packet type: %d - %s", packet_type, data) return None except Exception: