Skip to content

Commit

Permalink
Move decoder functionality to HistoryDecoder
Browse files Browse the repository at this point in the history
  • Loading branch information
ttu committed Jan 27, 2025
1 parent 892bbcc commit b4a0e01
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 58 deletions.
4 changes: 2 additions & 2 deletions examples/download_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
45 changes: 2 additions & 43 deletions ruuvitag_sensor/adapters/bleak_ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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("<I", data_point[0:4])[0]
measurement = self._parse_history_data(data_point[4:])
if measurement:
measurement["timestamp"] = datetime.fromtimestamp(timestamp)
parsed_data.append(measurement)

log.info("Downloaded %d history entries", len(parsed_data))
return parsed_data

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("<h", data[0:2])[0] * 0.005
humidity = struct.unpack("<H", data[2:4])[0] * 0.0025
pressure = struct.unpack("<H", data[4:6])[0] + 50000

return {
"temperature": temperature,
"humidity": humidity,
"pressure": pressure,
"data_format": 5, # History data uses similar format to data format 5
}
except Exception as e:
log.error("Failed to parse history data: %s", e)
return None
75 changes: 74 additions & 1 deletion ruuvitag_sensor/decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import logging
import math
import struct
from datetime import datetime
from typing import Optional, Tuple, Union

from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl
from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorHistoryData, SensorDataUrl

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -282,3 +283,75 @@ def decode_data(self, data: str) -> 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("<IiII", data[:16])
log.debug("Raw values - ts: %d, temp: %d, hum: %d, press: %d", ts, temp, hum, press)

result: SensorHistoryData = {
"temperature": self._get_temperature(temp),
"humidity": self._get_humidity(hum),
"pressure": self._get_pressure(press),
"timestamp": datetime.fromtimestamp(ts),
}

return result
except Exception:
log.exception("Value: %s not valid", data)
return None
25 changes: 14 additions & 11 deletions ruuvitag_sensor/ruuvi.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@

from ruuvitag_sensor.adapters import get_ble_adapter, throw_if_not_async_adapter, throw_if_not_sync_adapter
from ruuvitag_sensor.data_formats import DataFormats
from ruuvitag_sensor.decoder import get_decoder, parse_mac
from ruuvitag_sensor.ruuvi_types import DataFormatAndRawSensorData, Mac, MacAndRawData, MacAndSensorData, SensorData
from ruuvitag_sensor.decoder import HistoryDecoder, get_decoder, parse_mac
from ruuvitag_sensor.ruuvi_types import (
DataFormatAndRawSensorData,
Mac,
MacAndRawData,
MacAndSensorData,
SensorData,
SensorHistoryData,
)

log = logging.getLogger(__name__)
ble = get_ble_adapter()
Expand Down Expand Up @@ -348,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[SensorData]:
) -> List[SensorHistoryData]:
"""
Get history data from a RuuviTag that supports it (firmware 3.30.0+)
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions ruuvitag_sensor/ruuvi_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional, Tuple, TypedDict, Union


Expand Down Expand Up @@ -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]
Expand Down
52 changes: 51 additions & 1 deletion tests/test_decoder.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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

0 comments on commit b4a0e01

Please sign in to comment.