Repository: ttu/ruuvitag-sensor

Issue:
Download logged Data (via NUS over GATT) from RuuviTags
The latest RuuviTag Firmware (3.29.0-RC5 at time of writing) supports logging environmental data in the nRF52832 internal flash and support for logging accelerometer data is planned for future releases. The mobile Ruuvi Station App allows the user to download the data. The same feature should be added to ruuvitag-sensor. 

Internally downloading the logged data is managed via the RuuviTag communication interface that uses the Nordic UART Service (NUS) over the BLE Generic Attribute Profile (GATT). There is an implementation of the NUS in Python as @ojousima  pointed out. The following libraries could serve as starting point:

- [https://github.com/aykevl/pynus](https://github.com/aykevl/pynus)
- [https://github.com/labapart/gattlib](https://github.com/labapart/gattlib)
- [https://github.com/peplin/pygatt](https://github.com/peplin/pygatt)
- [https://github.com/getsenic/gatt-python](https://github.com/getsenic/gatt-python)

Future firmware releases will also allow for configuration settings being send over the same service, so it may be a good idea to consider that already.

Discussion:
@ttu: Implemented in PR: https://github.com/ttu/ruuvitag-sensor/pull/264

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67d936a..153e5af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,7 @@
 ## Changelog
 
 ### [Unreleased]
-
+* 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 7ed04d8..5bf707d 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ RuuviTag Sensor Python Package
     * Bleak supports
       * [Async-methods](#usage)
       * [Observable streams](#usage)
+      * [Fetch history data](#usage)
     * [Install guide](#Bleak)
 * Bluez (Linux-only)
     * Bluez supports
@@ -61,7 +62,9 @@ Full installation guide for [Raspberry PI & Raspbian](https://github.com/ttu/ruu
 
 ## Usage
 
-The package provides 3 ways to fetch data from sensors:
+### Fetch broadcast data from RuuviTags
+
+The package provides 3 ways to fetch broadcasted data from sensors:
 
 1. Asynchronously with async/await
 2. Synchronously with callback
@@ -73,6 +76,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})
 ```
 
+### Fetch stored history data from RuuviTags internal memory
+
+4. Fetch history data with async/await
+
+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
+[
+    {'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.
@@ -202,6 +220,54 @@ 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. Fetch 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 Unix timestamp (integer). 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
+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
diff --git a/examples/download_history.py b/examples/download_history.py
new file mode 100644
index 0000000..0cc8ccc
--- /dev/null
+++ b/examples/download_history.py
@@ -0,0 +1,21 @@
+import asyncio
+from datetime import datetime, timedelta
+
+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 = "873A13F5-ED14-AEE1-E446-6ACF31649A1D"
+    start_time = datetime.now() - timedelta(minutes=10)
+    data = await RuuviTagSensor.download_history(mac, start_time=start_time)
+    print(data)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/examples/get_history_async.py b/examples/get_history_async.py
new file mode 100644
index 0000000..4737a5b
--- /dev/null
+++ b/examples/get_history_async.py
@@ -0,0 +1,21 @@
+import asyncio
+from datetime import datetime, timedelta
+
+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 = "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 a82e9bd..36c33cf 100644
--- a/ruuvitag_sensor/adapters/bleak_ble.py
+++ b/ruuvitag_sensor/adapters/bleak_ble.py
@@ -3,9 +3,10 @@ import logging
 import os
 import re
 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, BleakGATTCharacteristic, BleakScanner
 from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback, BLEDevice
 
 from ruuvitag_sensor.adapters import BleCommunicationAsync
@@ -13,6 +14,9 @@ from ruuvitag_sensor.adapters.utils import rssi_to_hex
 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_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 = ""):
@@ -120,3 +124,139 @@ class BleCommunicationBleak(BleCommunicationAsync):
                 await data_iter.aclose()
 
         return data or ""
+
+    async def get_history_data(
+        self, mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None
+    ) -> AsyncGenerator[bytearray, None]:
+        """
+        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
+
+        Yields:
+            bytearray: Raw history data entries
+
+        Raises:
+            RuntimeError: If connection fails or required services not found
+        """
+        client = None
+        try:
+            log.debug("Connecting to device %s", mac)
+            client = await self._connect_gatt(mac)
+            log.debug("Connected to device %s", mac)
+
+            tx_char, rx_char = self._get_history_service_characteristics(client)
+
+            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. 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
+                data_queue.put_nowait(data)
+
+            await client.start_notify(tx_char, notification_handler)
+
+            command = self._create_send_history_command(start_time)
+
+            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)
+
+    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)
+
+        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
+    ) -> 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 20f8817..5ccfbcb 100644
--- a/ruuvitag_sensor/decoder.py
+++ b/ruuvitag_sensor/decoder.py
@@ -4,7 +4,7 @@ import math
 import struct
 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, SensorDataUrl, SensorHistoryData
 
 log = logging.getLogger(__name__)
 
@@ -282,3 +282,135 @@ class Df5Decoder:
         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:
+    - 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 _is_error_packet(self, data: list[str]) -> bool:
+        """Check if this is an error packet"""
+        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"""
+        # 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]) -> 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 timestamp
+        # return datetime.fromtimestamp(timestamp, tz=timezone.utc)
+
+    def _get_temperature(self, data: list[str]) -> Optional[float]:
+        """Return temperature in celsius"""
+        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: list[str]) -> Optional[float]:
+        """Return humidity %"""
+        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: list[str]) -> Optional[float]:
+        """Return air pressure hPa"""
+        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]:  # noqa: PLR0911
+        """
+        Decode history data from RuuviTag.
+
+        The data format follows the NUS log format.
+
+        Args:
+            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:
+            hex_values = [format(x, "02x") for x in data]
+
+            if len(hex_values) != 11:
+                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.info("Invalid command byte: %d", data[0])
+                return None
+
+            # Check for error header
+            if self._is_error_packet(hex_values):
+                log.info("Device reported error in log reading")
+                return None
+
+            # Check for end marker packet
+            if self._is_end_marker(hex_values):
+                log.debug("End marker packet received")
+                return None
+
+            # Each packet type contains one measurement
+            packet_type = hex_values[1]
+            if packet_type == "30":  # '0' temperature
+                return {
+                    "temperature": self._get_temperature(hex_values),
+                    "humidity": None,
+                    "pressure": None,
+                    "timestamp": self._get_timestamp(hex_values),
+                }
+            elif packet_type == "31":  # '1' humidity
+                return {
+                    "temperature": None,
+                    "humidity": self._get_humidity(hex_values),
+                    "pressure": None,
+                    "timestamp": self._get_timestamp(hex_values),
+                }
+            elif packet_type == "32":  # '2' pressure
+                return {
+                    "temperature": None,
+                    "humidity": None,
+                    "pressure": self._get_pressure(hex_values),
+                    "timestamp": self._get_timestamp(hex_values),
+                }
+            else:
+                log.info("Invalid packet type: %d - %s", packet_type, data)
+                return None
+
+        except Exception:
+            log.exception("Value not valid: %s", data)
+            return None
diff --git a/ruuvitag_sensor/ruuvi.py b/ruuvitag_sensor/ruuvi.py
index cc2b8c1..71c3a7f 100644
--- a/ruuvitag_sensor/ruuvi.py
+++ b/ruuvitag_sensor/ruuvi.py
@@ -1,5 +1,7 @@
+import asyncio
 import logging
 import time
+from datetime import datetime
 from multiprocessing import Manager
 from multiprocessing.managers import ListProxy
 from typing import AsyncGenerator, Callable, Dict, Generator, List, Optional
@@ -7,8 +9,15 @@ from warnings import warn
 
 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()
@@ -342,3 +351,80 @@ class RuuviTagSensor:
             return None
 
         return (mac_to_send, decoded)
+
+    @staticmethod
+    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 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).
+
+        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. 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
+
+        Raises:
+            RuntimeError: If connection fails or device doesn't support history
+        """
+        throw_if_not_async_adapter(ble)
+
+        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(
+        mac: str, start_time: Optional[datetime] = None, timeout: int = 300, max_items: Optional[int] = None
+    ) -> List[SensorHistoryData]:
+        """
+        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).
+
+        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.
+            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
+
+        Raises:
+            RuntimeError: If connection fails or device doesn't support history
+            TimeoutError: If download takes longer than timeout
+        """
+        throw_if_not_async_adapter(ble)
+
+        try:
+            history_data: List[SensorHistoryData] = []
+            decoder = HistoryDecoder()
+
+            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)  # noqa: PERF401
+
+            await asyncio.wait_for(collect_history(), timeout=timeout)
+            return history_data
+
+        except asyncio.TimeoutError:
+            raise TimeoutError(f"History download timed out after {timeout} seconds")
+        except Exception as e:
+            raise RuntimeError(f"Failed to download history: {e!s}") from e
diff --git a/ruuvitag_sensor/ruuvi_types.py b/ruuvitag_sensor/ruuvi_types.py
index c7f9ca9..9e95cd8 100644
--- a/ruuvitag_sensor/ruuvi_types.py
+++ b/ruuvitag_sensor/ruuvi_types.py
@@ -39,6 +39,13 @@ class SensorData5(SensorDataBase):
     rssi: Optional[int]
 
 
+class SensorHistoryData(TypedDict):
+    humidity: Optional[float]
+    temperature: Optional[float]
+    pressure: Optional[float]
+    timestamp: int
+
+
 SensorData = Union[SensorDataUrl, SensorData3, SensorData5]
 
 DataFormat = Optional[int]


Test patch:
diff --git a/tests/test_decoder.py b/tests/test_decoder.py
index 8175430..1361b6f 100644
--- a/tests/test_decoder.py
+++ b/tests/test_decoder.py
@@ -1,6 +1,7 @@
+from datetime import datetime, timezone
 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,95 @@ class TestDecoder(TestCase):
 
         parsed = parse_mac(5, mac_payload)
         assert parsed == mac
+
+    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", "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
+        # TODO: Check datetime if it is correct in docs
+        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):
+        # 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, 13, 18, 37, tzinfo=timezone.utc).timestamp()
+        )  # 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, 13, 18, 37, tzinfo=timezone.utc).timestamp()
+        )  # datetime(2019, 8, 13, 13, 18, 37)
+
+    def test_history_decode_real_samples(self):
+        decoder = HistoryDecoder()
+
+        # 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"] == 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).timestamp()
+
+        # 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"] == 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).timestamp()
+
+        # 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"] == 35648
+        assert result["temperature"] is None
+        assert result["humidity"] is None
+        assert result["timestamp"] == datetime(2025, 2, 1, 5, 46, 10, tzinfo=timezone.utc).timestamp()
+
+    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()
+
+        error_cases = [
+            bytearray(b";\x10g\x9b\xb2\xaf\x00\x00\x08\xac"),  # 0x3B
+            bytearray(b"9\x10g\x9b\xb2\xaf\x00\x00\x08\xac"),  # 0x39
+            bytearray(b"?\x10g\x9b\xb2\xaf\x00\x00\x08\xac"),  # 0x3F
+            bytearray(b""),  # Empty data
+            bytearray(b":"),  # Too short
+            bytearray(b":\x10"),  # Incomplete data
+        ]
+        for error_data in error_cases:
+            assert decoder.decode_data(error_data) is None, f"Should be error for data: {error_data!r}"