pax_global_header00006660000000000000000000000064151274212370014516gustar00rootroot0000000000000052 comment=732b2706730e68bdd1bb2e0748f14172af8daacd Bluetooth-Devices-ruuvitag-ble-e84c0ea/000077500000000000000000000000001512742123700201425ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/.github/000077500000000000000000000000001512742123700215025ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/.github/FUNDING.yml000066400000000000000000000000451512742123700233160ustar00rootroot00000000000000github: ["bluetooth-devices", "akx"] Bluetooth-Devices-ruuvitag-ble-e84c0ea/.github/dependabot.yml000066400000000000000000000001511512742123700243270ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: "weekly" Bluetooth-Devices-ruuvitag-ble-e84c0ea/.github/workflows/000077500000000000000000000000001512742123700235375ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/.github/workflows/ci.yml000066400000000000000000000056161512742123700246650ustar00rootroot00000000000000name: CI on: push: tags: - 'v*' branches: - main pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 # Vendored variation of https://raw.githubusercontent.com/tox-dev/action-pre-commit-uv/refs/heads/main/action.yml - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 with: enable-cache: true cache-dependency-glob: '.pre-commit-config.yaml' python-version: "3.14" - run: uv run --isolated --no-sync true && echo "pythonLocation=$(uv python find)" >>$GITHUB_ENV - uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - run: uv run --no-sync --with pre-commit-uv pre-commit run --show-diff-on-failure --color=always env: RUFF_OUTPUT_FORMAT: "github" test: strategy: fail-fast: false matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" os: - ubuntu-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 with: python-version: ${{ matrix.python-version }} - run: uv run pytest --cov . --cov-report=xml --cov-report=term-missing - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 with: python-version: "3.14" - run: uv run mypy --strict --install-types --non-interactive . build: runs-on: ubuntu-latest needs: [lint, test, mypy] steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - run: uv build - run: uvx twine check dist/* - name: Upload artifact uses: actions/upload-artifact@v5 with: name: dist path: dist publish: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') needs: - build name: Upload release to PyPI runs-on: ubuntu-latest environment: release permissions: id-token: write contents: write steps: - uses: actions/download-artifact@v5 with: name: dist path: dist/ - name: Publish distribution to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: attestations: true print-hash: true verbose: true Bluetooth-Devices-ruuvitag-ble-e84c0ea/.gitignore000066400000000000000000000000661512742123700221340ustar00rootroot00000000000000*.log *.py[cod] *cache .coverage* /coverage.xml /dist Bluetooth-Devices-ruuvitag-ble-e84c0ea/.pre-commit-config.yaml000066400000000000000000000013271512742123700244260ustar00rootroot00000000000000ci: autofix_prs: false autoupdate_schedule: quarterly repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-json - id: check-toml - id: check-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit rev: 3db93a2be6f214ed722bf7bce095ec1b1715422a # frozen: v0.14.2 hooks: - id: ruff-check - id: ruff-format Bluetooth-Devices-ruuvitag-ble-e84c0ea/LICENSE000066400000000000000000000020561512742123700211520ustar00rootroot00000000000000MIT License Copyright (c) 2022 Aarni Koskela Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Bluetooth-Devices-ruuvitag-ble-e84c0ea/README.md000066400000000000000000000054261512742123700214300ustar00rootroot00000000000000# ruuvitag-ble [Sans-IO](https://sans-io.readthedocs.io/) parser for [Ruuvi](https://ruuvi.com/) wireless sensor BLE devices. Mainly meant for interoperation with [Home Assistant](https://www.home-assistant.io/)'s Ruuvi integration, but could be useful in other contexts as well. ## Supported Devices - [**RuuviTag**](https://ruuvi.com/ruuvitag/) - Environmental sensor tags - [**RuuviTag Pro**](https://ruuvi.com/ruuvitag-pro/) - Heavy-duty environmental sensor tags - [**Ruuvi Air**](https://ruuvi.com/air/) - Air quality monitors ## Supported Data Formats This library supports the following Ruuvi BLE advertisement data formats: ### RuuviTag and RuuviTag Pro #### Data Format 3 (0x03) Legacy format supported by older RuuviTag firmware. **Measurements:** - Temperature (Celsius, range: -127.99 to 127.99°C, resolution: 0.01°C) - Humidity (%, range: 0 to 100%, resolution: 0.5%) - Pressure (hPa, range: 500 to 1155.35 hPa, resolution: 0.01 hPa) - Acceleration X/Y/Z (m/s², converted from mG) - Battery voltage (mV) #### Data Format 5 (0x05) Standard format used by RuuviTag firmware 2.x and later. **Measurements:** - Temperature (Celsius, range: -163.835 to 163.835°C, resolution: 0.005°C) - Humidity (%, range: 0 to 163.8350%, resolution: 0.0025%) - Pressure (hPa, range: 500 to 1155.35 hPa, resolution: 0.01 hPa) - Acceleration X/Y/Z (mG, converted to m/s²) - Battery voltage (mV, range: 1600 to 3647 mV) - TX power (dBm, range: -40 to 20 dBm) - Movement counter - Measurement sequence number - MAC address (6 bytes) ### Ruuvi Air #### Data Format 6 (0x06) Compact format for air quality monitoring, used by Ruuvi Air and similar devices. **Measurements:** - Temperature (Celsius, resolution: 0.005°C) - Humidity (%, resolution: 0.0025%) - Pressure (hPa, resolution: 0.01 hPa) - PM2.5 (μg/m³, resolution: 0.1 μg/m³) - CO2 (ppm) - VOC index (9-bit value, range: 0-500) - NOx index (9-bit value, range: 0-500) - Luminosity (lux, logarithmic scale) - Sound average (dBA, resolution: 0.2 dBA, range: 18-119.6 dBA) - Measurement sequence number - MAC address (3 bytes) #### Data Format E1 (0xE1) Extended format providing comprehensive environmental and air quality data with higher precision. **Measurements:** - Temperature (Celsius, range: -163.835 to 163.835°C, resolution: 0.005°C) - Humidity (%, range: 0 to 100%, resolution: 0.0025%) - Pressure (hPa, range: 500 to 1155.34 hPa, resolution: 0.01 hPa) - PM1.0 (μg/m³, resolution: 0.1 μg/m³) - PM2.5 (μg/m³, resolution: 0.1 μg/m³) - PM4.0 (μg/m³, resolution: 0.1 μg/m³) - PM10 (μg/m³, resolution: 0.1 μg/m³) - CO2 (ppm) - VOC index (9-bit value, range: 0-500) - NOx index (9-bit value, range: 0-500) - Luminosity (lux, resolution: 0.01 lux) - Measurement sequence number (24-bit) - Calibration status flag - MAC address (6 bytes) Bluetooth-Devices-ruuvitag-ble-e84c0ea/pyproject.toml000066400000000000000000000037031512742123700230610ustar00rootroot00000000000000[project] name = "ruuvitag-ble" description = "Manage Ruuvitag BLE devices" authors = [ { name = "Aarni Koskela", email = "akx@iki.fi" }, ] requires-python = ">=3.10" license = "MIT" readme = "README.md" repository = "https://github.com/bluetooth-devices/ruuvitag-ble" documentation = "https://ruuvitag-ble.readthedocs.io" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", ] packages = [ { include = "ruuvitag_ble", from = "src" }, ] dependencies = [ "bluetooth-data-tools>=1.0", "bluetooth-sensor-state-data>=1.6", "home-assistant-bluetooth>=1.6", "sensor-state-data>=2.20", ] dynamic = [ "version", ] [project.urls] "Bug Tracker" = "https://github.com/bluetooth-devices/ruuvitag-ble/issues" [tool.hatch.version] path = "src/ruuvitag_ble/__init__.py" [tool.pytest.ini_options] addopts = "-v -Wdefault --cov=ruuvitag_ble --cov-report=term-missing:skip-covered" pythonpath = ["src"] [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "@overload", "if TYPE_CHECKING", "raise NotImplementedError", ] [tool.ruff.lint] extend-select = [ "COM", "I", ] [tool.ruff.lint.isort] known-first-party = ["ruuvitag_ble", "tests"] [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true mypy_path = "src/" no_implicit_optional = true show_error_codes = true warn_unreachable = true warn_unused_ignores = true exclude = [ 'docs/.*', 'setup.py', ] [[tool.mypy.overrides]] module = "tests.*" allow_untyped_defs = true [[tool.mypy.overrides]] module = "docs.*" ignore_errors = true [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] dev = [ "mypy>=1.17.0", "pytest>=8.4.1", "pytest-cov>=6.2.1", ] Bluetooth-Devices-ruuvitag-ble-e84c0ea/setup.py000066400000000000000000000003611512742123700216540ustar00rootroot00000000000000#!/usr/bin/env python # This is a shim to allow GitHub to detect the package, build is done with hatch # Taken from https://github.com/Textualize/rich import setuptools if __name__ == "__main__": setuptools.setup(name="ruuvitag-ble") Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/000077500000000000000000000000001512742123700207315ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/000077500000000000000000000000001512742123700234215ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/__init__.py000066400000000000000000000001711512742123700255310ustar00rootroot00000000000000from .parser import RuuvitagBluetoothDeviceData __version__ = "0.4.0" __all__ = [ "RuuvitagBluetoothDeviceData", ] Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/df3_decoder.py000066400000000000000000000036521512742123700261420ustar00rootroot00000000000000""" Decoder for RuuviTag Data Format 3 data. Ruuvi Sensor Protocols: https://github.com/ruuvi/ruuvi-sensor-protocols/blob/master/dataformat_03.md """ from __future__ import annotations import math import struct class DataFormat3Decoder: def __init__(self, raw_data: bytes) -> None: if len(raw_data) < 14: raise ValueError("Data must be at least 14 bytes long for data format 3") self.data: tuple[int, ...] = struct.unpack(">BBbBHhhhH", raw_data) @property def humidity_percentage(self) -> float | None: if self.data[1] > 200: return None return round(self.data[1] / 2, 2) @property def temperature_celsius(self) -> float | None: frac_byte = self.data[3] if frac_byte >= 100: # pragma: no cover # Faulty reading; fractional part can't be >= 100 return None int_byte = self.data[2] # Handle MSB sign bit value = ((int_byte & 0x7F) + frac_byte / 100.0) * (-1 if int_byte & 0x80 else 1) return round(value, 2) @property def pressure_hpa(self) -> float | None: if self.data[3] == 0xFFFF: return None return round((self.data[4] + 50000) / 100, 2) @property def acceleration_vector_mg(self) -> tuple[int, int, int] | tuple[None, None, None]: ax = self.data[5] ay = self.data[6] az = self.data[7] if ax == -32768 or ay == -32768 or az == -32768: return (None, None, None) return (ax, ay, az) @property def acceleration_total_mg(self) -> float | None: ax, ay, az = self.acceleration_vector_mg if ax is None or ay is None or az is None: return None return math.hypot(ax, ay, az) @property def battery_voltage_mv(self) -> int | None: return self.data[8] @property def mac(self) -> str | None: return None # Not supported by this decoder Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/df5_decoder.py000066400000000000000000000043661512742123700261470ustar00rootroot00000000000000""" Decoder for RuuviTag Data Format 5 data. Based on https://github.com/ttu/ruuvitag-sensor/blob/23e6555/ruuvitag_sensor/decoder.py (MIT Licensed) Ruuvi Sensor Protocols: https://github.com/ruuvi/ruuvi-sensor-protocols/blob/master/dataformat_05.md """ from __future__ import annotations import math import struct class DataFormat5Decoder: def __init__(self, raw_data: bytes) -> None: if len(raw_data) < 24: raise ValueError("Data must be at least 24 bytes long for data format 5") self.data: tuple[int, ...] = struct.unpack(">BhHHhhhHBH6B", raw_data) @property def temperature_celsius(self) -> float | None: if self.data[1] == -32768: return None return round(self.data[1] / 200.0, 2) @property def humidity_percentage(self) -> float | None: if self.data[2] == 65535: return None return round(self.data[2] / 400, 2) @property def pressure_hpa(self) -> float | None: if self.data[3] == 0xFFFF: return None return round((self.data[3] + 50000) / 100, 2) @property def acceleration_vector_mg(self) -> tuple[int, int, int] | tuple[None, None, None]: ax = self.data[4] ay = self.data[5] az = self.data[6] if ax == -32768 or ay == -32768 or az == -32768: return (None, None, None) return (ax, ay, az) @property def acceleration_total_mg(self) -> float | None: ax, ay, az = self.acceleration_vector_mg if ax is None or ay is None or az is None: return None return math.hypot(ax, ay, az) @property def battery_voltage_mv(self) -> int | None: voltage = self.data[7] >> 5 if voltage == 0b11111111111: return None return voltage + 1600 @property def tx_power_dbm(self) -> int | None: tx_power = self.data[7] & 0x001F if tx_power == 0b11111: return None return -40 + (tx_power * 2) @property def movement_counter(self) -> int: return self.data[8] @property def measurement_sequence_number(self) -> int: return self.data[9] @property def mac(self) -> str: return ":".join(f"{x:02X}" for x in self.data[10:]) Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/df6_decoder.py000066400000000000000000000060201512742123700261350ustar00rootroot00000000000000""" Decoder for RuuviTag Data Format 6 data. Based on https://github.com/ruuvi/ruuvi.endpoints.c/blob/f16619cc2/src/ruuvi_endpoint_6.c https://github.com/ruuvi/ruuvi.endpoints.c/blob/f16619cc2/src/ruuvi_endpoint_6.h """ from __future__ import annotations import math import struct # See https://github.com/ruuvi/ruuvi.endpoints.c/blob/f16619cc2/src/ruuvi_endpoint_6.h#L58 LUX_LOG_SCALE = math.log(65536) / 254.0 class DataFormat6Decoder: def __init__(self, raw_data: bytes) -> None: if (data_len := len(raw_data)) < 20: raise ValueError( f"Data must be at least 20 bytes long for data format 6, got {data_len} bytes", ) # Format: header(B), temp(h), humidity(H), pressure(H), pm25(H), co2(H), voc(B), nox(B), lumi(B), sound(B), seq(B), flags(B), mac(3B) # Cutting to 20 bytes since the advertisement may contain more data, and `struct.unpack` expects a fixed size. self.data: tuple[int, ...] = struct.unpack(">BhHHHHBBBBBB3B", raw_data[:20]) if self.data[0] != 0x06: raise ValueError(f"Invalid data format: {self.data[0]} (expected 0x06)") @property def temperature_celsius(self) -> float | None: if self.data[1] == -32768: return None return round(self.data[1] / 200.0, 2) @property def humidity_percentage(self) -> float | None: if self.data[2] == 65535: return None return round(self.data[2] / 400.0, 2) @property def pressure_hpa(self) -> float | None: if self.data[3] == 0xFFFF: return None return round((self.data[3] + 50000) / 100, 2) @property def pm25_ug_m3(self) -> float | None: if self.data[4] == 0xFFFF: return None return round(self.data[4] / 10.0, 2) @property def co2_ppm(self) -> int | None: if self.data[5] == 0xFFFF: return None return self.data[5] @property def voc_index(self) -> int | None: val = self.data[6] << 1 if self.data[11] & 64: # (1 << 6) val |= 1 if val == 0x1FF: return None return int(val) @property def nox_index(self) -> int | None: val = self.data[7] << 1 if self.data[11] & 128: # (1 << 7) val |= 1 if val == 0x1FF: return None return int(val) @property def luminosity_lux(self) -> int | None: if self.data[8] == 0xFF: return None if self.data[8] == 0: return 0 return int(round(math.exp(self.data[8] * LUX_LOG_SCALE) - 1)) @property def sound_avg_dba(self) -> float | None: val = self.data[9] << 1 if self.data[11] & 16: # (1 << 4) val |= 1 if val == 0x1FF: return None return round(val / 5 + 18, 2) @property def measurement_sequence_number(self) -> int: return self.data[10] @property def mac(self) -> str: return ":".join(f"{x:02X}" for x in self.data[12:15]) Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/dfe1_decoder.py000066400000000000000000000074221512742123700263040ustar00rootroot00000000000000""" Decoder for RuuviTag Data Format E1 (Extended v1) data. Based on https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-e1.md """ from __future__ import annotations import struct class DataFormatE1Decoder: def __init__(self, raw_data: bytes) -> None: if (data_len := len(raw_data)) < 40: raise ValueError( f"Data must be at least 40 bytes long for data format E1, got {data_len} bytes", ) # Format breakdown (40 bytes total): # 0: header(1B), 1-2: temp(2B), 3-4: humidity(2B), 5-6: pressure(2B), # 7-8: pm1(2B), 9-10: pm25(2B), 11-12: pm4(2B), 13-14: pm10(2B), # 15-16: co2(2B), 17: voc(1B), 18: nox(1B), 19-21: lumi(3B), # 22-24: reserved(3B), 25-27: seq(3B), 28: flags(1B), 29-33: reserved(5B), 34-39: mac(6B) self.data: tuple[int | bytes, ...] = struct.unpack( ">BhHHHHHHHBB3s3s3sB5s6s", raw_data[:40], ) if self.data[0] != 0xE1: raise ValueError( f"Invalid data format: {int(self.data[0])} (expected 0xE1)", ) self.flags = int(self.data[14]) @property def temperature_celsius(self) -> float | None: if self.data[1] == -32768: return None return round(int(self.data[1]) * 0.005, 3) @property def humidity_percentage(self) -> float | None: if self.data[2] == 65535: return None return round(int(self.data[2]) * 0.0025, 3) @property def pressure_hpa(self) -> float | None: if self.data[3] == 0xFFFF: return None return round((int(self.data[3]) + 50000) / 100, 2) @property def pm1_ug_m3(self) -> float | None: if self.data[4] == 0xFFFF: return None return round(int(self.data[4]) * 0.1, 1) @property def pm25_ug_m3(self) -> float | None: if self.data[5] == 0xFFFF: return None return round(int(self.data[5]) * 0.1, 1) @property def pm4_ug_m3(self) -> float | None: if self.data[6] == 0xFFFF: return None return round(int(self.data[6]) * 0.1, 1) @property def pm10_ug_m3(self) -> float | None: if self.data[7] == 0xFFFF: return None return round(int(self.data[7]) * 0.1, 1) @property def co2_ppm(self) -> int | None: if self.data[8] == 0xFFFF: return None return int(self.data[8]) @property def voc_index(self) -> int | None: # VOC is 9 bits: 8 bits in data[9], LSB in bit 6 of flags val = int(self.data[9]) << 1 if self.flags & 64: # bit 6 of flags val |= 1 if val == 0x1FF: return None return int(val) @property def nox_index(self) -> int | None: # NOX is 9 bits: 8 bits in data[10], LSB in bit 7 of flags val = int(self.data[10]) << 1 if self.flags & 128: # bit 7 of flags val |= 1 if val == 0x1FF: return None return int(val) @property def luminosity_lux(self) -> float | None: lumi_bytes = bytes(self.data[11]) if lumi_bytes == b"\xff\xff\xff": return None lumi_val = int.from_bytes(lumi_bytes, byteorder="big") return round(lumi_val * 0.01, 2) @property def measurement_sequence_number(self) -> int | None: seq_bytes = bytes(self.data[13]) if seq_bytes == b"\xff\xff\xff": return None return int.from_bytes(seq_bytes, byteorder="big") @property def calibration_in_progress(self) -> bool: # Bit 0 of flags indicates calibration status return bool(self.flags & 1) @property def mac(self) -> str: return ":".join(f"{b:02X}" for b in bytes(self.data[16])) Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/iaqs.py000066400000000000000000000016541512742123700247360ustar00rootroot00000000000000from __future__ import annotations import math AQI_MAX = 100 PM25_MAX = 60 PM25_MIN = 0 PM25_SCALE = AQI_MAX / (PM25_MAX - PM25_MIN) CO2_MAX = 2300 CO2_MIN = 420 CO2_SCALE = AQI_MAX / (CO2_MAX - CO2_MIN) def calculate_iaqs(co2_value: int | None, pm25_value: float | None) -> int | None: """Calculate the Ruuvi indoor air quality score (IAQS). Documentation for the calculation algorithm can be found at https://docs.ruuvi.com/ruuvi-air-firmware/ruuvi-indoor-air-quality-score-iaqs. """ if co2_value is None or pm25_value is None: return None co2_clamped = min(max(co2_value, CO2_MIN), CO2_MAX) pm25_clamped = min(max(pm25_value, PM25_MIN), PM25_MAX) dx = (pm25_clamped - PM25_MIN) * PM25_SCALE dy = (co2_clamped - CO2_MIN) * CO2_SCALE value = AQI_MAX - math.hypot(dx, dy) if value > AQI_MAX: return AQI_MAX if value < 0: return 0 return int(round(value)) Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/parser.py000066400000000000000000000175151512742123700253000ustar00rootroot00000000000000from __future__ import annotations import logging import math from bluetooth_data_tools import short_address from bluetooth_sensor_state_data import BluetoothData from home_assistant_bluetooth import BluetoothServiceInfo from sensor_state_data import DeviceClass, Units from ruuvitag_ble.df3_decoder import DataFormat3Decoder from ruuvitag_ble.df5_decoder import DataFormat5Decoder from ruuvitag_ble.df6_decoder import DataFormat6Decoder from ruuvitag_ble.dfe1_decoder import DataFormatE1Decoder from ruuvitag_ble.iaqs import calculate_iaqs _LOGGER = logging.getLogger(__name__) decoder_classes: dict[ int, type[ DataFormat3Decoder | DataFormat5Decoder | DataFormat6Decoder | DataFormatE1Decoder ], ] = { 0x03: DataFormat3Decoder, 0x05: DataFormat5Decoder, 0x06: DataFormat6Decoder, 0xE1: DataFormatE1Decoder, } class RuuvitagBluetoothDeviceData(BluetoothData): """Data for Ruuvitag BLE sensors.""" def _start_update(self, service_info: BluetoothServiceInfo) -> None: try: raw_data = service_info.manufacturer_data[0x0499] except (KeyError, IndexError): _LOGGER.debug("Manufacturer ID 0x0499 not found in data") return None data_format = raw_data[0] try: decoder_cls = decoder_classes[data_format] except KeyError: _LOGGER.debug("Data format not supported: %s", raw_data) return decoder = decoder_cls(raw_data) # Compute short identifier from MAC address # (preferring the MAC address the tag broadcasts). identifier = short_address(decoder.mac or service_info.address) dev_type = "Ruuvi Air" if "Air" in str(service_info.name) else "RuuviTag" self.set_device_type(dev_type) self.set_device_manufacturer("Ruuvi Innovations Ltd.") self.set_device_name(f"{dev_type} {identifier}") self.update_sensor( key=DeviceClass.TEMPERATURE, device_class=DeviceClass.TEMPERATURE, native_unit_of_measurement=Units.TEMP_CELSIUS, native_value=decoder.temperature_celsius, ) self.update_sensor( key=DeviceClass.HUMIDITY, device_class=DeviceClass.HUMIDITY, native_unit_of_measurement=Units.PERCENTAGE, native_value=decoder.humidity_percentage, ) self.update_sensor( key=DeviceClass.PRESSURE, device_class=DeviceClass.PRESSURE, native_unit_of_measurement=Units.PRESSURE_HPA, native_value=decoder.pressure_hpa, ) if hasattr(decoder, "battery_voltage_mv"): self.update_sensor( key=DeviceClass.VOLTAGE, device_class=DeviceClass.VOLTAGE, native_unit_of_measurement=Units.ELECTRIC_POTENTIAL_MILLIVOLT, native_value=decoder.battery_voltage_mv, ) if hasattr(decoder, "movement_counter"): self.update_sensor( key="movement_counter", device_class=DeviceClass.COUNT, native_unit_of_measurement=None, native_value=decoder.movement_counter, ) if hasattr(decoder, "pm1_ug_m3"): self.update_sensor( key=DeviceClass.PM1, device_class=DeviceClass.PM1, native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_value=decoder.pm1_ug_m3, ) if hasattr(decoder, "pm25_ug_m3"): self.update_sensor( key=DeviceClass.PM25, device_class=DeviceClass.PM25, native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_value=decoder.pm25_ug_m3, ) if hasattr(decoder, "pm4_ug_m3"): self.update_sensor( key=DeviceClass.PM4, device_class=DeviceClass.PM4, native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_value=decoder.pm4_ug_m3, ) if hasattr(decoder, "pm10_ug_m3"): self.update_sensor( key=DeviceClass.PM10, device_class=DeviceClass.PM10, native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, native_value=decoder.pm10_ug_m3, ) if hasattr(decoder, "co2_ppm"): self.update_sensor( key=DeviceClass.CO2, device_class=DeviceClass.CO2, native_unit_of_measurement=Units.CONCENTRATION_PARTS_PER_MILLION, native_value=decoder.co2_ppm, ) if hasattr(decoder, "voc_index"): self.update_sensor( key="voc_index", device_class=DeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=None, native_value=decoder.voc_index, ) if hasattr(decoder, "nox_index"): self.update_sensor( key="nox_index", device_class=DeviceClass.NITROGEN_MONOXIDE, native_unit_of_measurement=None, native_value=decoder.nox_index, ) if hasattr(decoder, "luminosity_lux"): self.update_sensor( key=DeviceClass.ILLUMINANCE, device_class=DeviceClass.ILLUMINANCE, native_unit_of_measurement=Units.LIGHT_LUX, native_value=decoder.luminosity_lux, ) if hasattr(decoder, "acceleration_vector_mg"): self._update_acceleration(decoder) # type: ignore[arg-type] if hasattr(decoder, "co2_ppm") and hasattr(decoder, "pm25_ug_m3"): self.update_sensor( key="iaqs", device_class=DeviceClass.AQI, native_unit_of_measurement=None, native_value=calculate_iaqs(decoder.co2_ppm, decoder.pm25_ug_m3), ) def _update_acceleration( self, decoder: DataFormat3Decoder | DataFormat5Decoder, ) -> None: try: acc_x_mg, acc_y_mg, acc_z_mg = decoder.acceleration_vector_mg # Typing ignores are used here, as the arising TypeErrors # will be caught at runtime (IOW, we don't waste runtime doing # unlikely type checks). acc_x_mss = round(acc_x_mg * 0.00980665, 2) # type: ignore acc_y_mss = round(acc_y_mg * 0.00980665, 2) # type: ignore acc_z_mss = round(acc_z_mg * 0.00980665, 2) # type: ignore acc_total_mss = round( math.hypot(acc_x_mss, acc_y_mss, acc_z_mss), 2, ) except TypeError: # When any of the acceleration values are None (unlikely) acc_total_mss = acc_x_mss = acc_y_mss = acc_z_mss = None # type: ignore self.update_sensor( key="acceleration_x", device_class=DeviceClass.ACCELERATION, native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, native_value=acc_x_mss, ) self.update_sensor( key="acceleration_y", device_class=DeviceClass.ACCELERATION, native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, native_value=acc_y_mss, ) self.update_sensor( key="acceleration_z", device_class=DeviceClass.ACCELERATION, native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, native_value=acc_z_mss, ) self.update_sensor( key="acceleration_total", device_class=DeviceClass.ACCELERATION, native_unit_of_measurement=Units.ACCELERATION_METERS_PER_SQUARE_SECOND, native_value=acc_total_mss, ) Bluetooth-Devices-ruuvitag-ble-e84c0ea/src/ruuvitag_ble/py.typed000066400000000000000000000000001512742123700251060ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/000077500000000000000000000000001512742123700213045ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/__init__.py000066400000000000000000000000001512742123700234030ustar00rootroot00000000000000Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/test_e1.py000066400000000000000000000107151512742123700232260ustar00rootroot00000000000000import pytest from ruuvitag_ble import RuuvitagBluetoothDeviceData from ruuvitag_ble.dfe1_decoder import DataFormatE1Decoder from tests.utils import ( KEY_CO2, KEY_HUMIDITY, KEY_ILLUMINANCE, KEY_NOX_INDEX, KEY_PM25, KEY_PRESSURE, KEY_TEMPERATURE, KEY_VOC_INDEX, bytes_to_service_info, ) # Test vectors from https://docs.ruuvi.com/communication/bluetooth-advertisements/data-format-e1.md # Note: The "XXXXXX" in the raw data represents reserved fields that we can set to 0x00 # The flags byte (index 28) has bit 0 set for calibration in progress E1_VALID_DATA = bytes.fromhex( "E1170C5668C79E0065007004BD11CA00C90A0213E0AC000000DECDEE110000000000CBB8334C884F", ) E1_MAX_VALUES = bytes.fromhex( "E17FFF9C40FFFE27102710271027109C40FAFADC28F0000000FFFFFE3F0000000000CBB8334C884F", ) E1_MIN_VALUES = bytes.fromhex( "E1800100000000000000000000000000000000000000000000000000000000000000CBB8334C884F", ) E1_INVALID_VALUES = bytes.fromhex( "E18000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", ) def test_parsing_e1_valid_data(): """Test parsing E1 format with valid sensor data.""" p = DataFormatE1Decoder(E1_VALID_DATA) assert p.temperature_celsius == 29.500 assert p.humidity_percentage == 55.300 assert p.pressure_hpa == 1011.02 assert p.pm1_ug_m3 == 10.1 assert p.pm25_ug_m3 == 11.2 assert p.pm4_ug_m3 == 121.3 assert p.pm10_ug_m3 == 455.4 assert p.co2_ppm == 201 assert p.voc_index == 20 assert p.nox_index == 4 assert p.luminosity_lux == 13027.00 assert p.measurement_sequence_number == 14601710 assert p.calibration_in_progress assert p.mac == "CB:B8:33:4C:88:4F" def test_parsing_e1_max_values(): """Test parsing E1 format with maximum values.""" p = DataFormatE1Decoder(E1_MAX_VALUES) assert p.temperature_celsius == 163.835 assert p.humidity_percentage == 100.000 assert p.pressure_hpa == 1155.34 assert p.pm1_ug_m3 == 1000.0 assert p.pm25_ug_m3 == 1000.0 assert p.pm4_ug_m3 == 1000.0 assert p.pm10_ug_m3 == 1000.0 assert p.co2_ppm == 40000 assert p.voc_index == 500 assert p.nox_index == 500 assert p.luminosity_lux == 144284.00 assert p.measurement_sequence_number == 16777214 assert p.mac == "CB:B8:33:4C:88:4F" def test_parsing_e1_min_values(): """Test parsing E1 format with minimum values.""" p = DataFormatE1Decoder(E1_MIN_VALUES) assert p.temperature_celsius == pytest.approx(-163.835, abs=0.001) assert p.humidity_percentage == 0.000 assert p.pressure_hpa == 500.00 assert p.pm1_ug_m3 == 0.0 assert p.pm25_ug_m3 == 0.0 assert p.pm4_ug_m3 == 0.0 assert p.pm10_ug_m3 == 0.0 assert p.co2_ppm == 0 assert p.voc_index == 0 assert p.nox_index == 0 assert p.luminosity_lux == 0.00 assert p.measurement_sequence_number == 0 assert not p.calibration_in_progress assert p.mac == "CB:B8:33:4C:88:4F" def test_parsing_e1_invalid_values(): """Test parsing E1 format with invalid/NaN values.""" p = DataFormatE1Decoder(E1_INVALID_VALUES) assert p.temperature_celsius is None assert p.humidity_percentage is None assert p.pressure_hpa is None assert p.pm1_ug_m3 is None assert p.pm25_ug_m3 is None assert p.pm4_ug_m3 is None assert p.pm10_ug_m3 is None assert p.co2_ppm is None assert p.voc_index is None assert p.nox_index is None assert p.luminosity_lux is None assert p.measurement_sequence_number is None assert p.mac == "FF:FF:FF:FF:FF:FF" def test_parsing_e1_via_bluetooth_device_data(): """Test parsing E1 format through the BluetoothDeviceData interface.""" device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(E1_VALID_DATA) assert device.supported(advertisement) up = device.update(advertisement) assert "884F" in str(up.devices[None].name) v = up.entity_values assert v[KEY_TEMPERATURE].native_value == 29.500 assert v[KEY_HUMIDITY].native_value == 55.300 assert v[KEY_PRESSURE].native_value == 1011.02 assert v[KEY_PM25].native_value == 11.2 assert v[KEY_CO2].native_value == 201 assert v[KEY_VOC_INDEX].native_value == 20 assert v[KEY_NOX_INDEX].native_value == 4 assert v[KEY_ILLUMINANCE].native_value == 13027.00 def test_bad_data(): with pytest.raises(ValueError): DataFormatE1Decoder(bytes.fromhex("E114")) with pytest.raises(ValueError): DataFormatE1Decoder(bytes.fromhex("06" + "00" * 39)) Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/test_iaqs.py000066400000000000000000000010461512742123700236530ustar00rootroot00000000000000from ruuvitag_ble.iaqs import calculate_iaqs def test_ruuvi_iaqs_calculation(): """Test calculation of Ruuvi indoor air quality score (IAQS).""" assert calculate_iaqs(None, 0) is None assert calculate_iaqs(0, None) is None assert calculate_iaqs(500, 0.4) == 96 assert calculate_iaqs(420, 0.0) > 90 # type: ignore[operator] assert calculate_iaqs(450, 5.0) > 90 # type: ignore[operator] assert calculate_iaqs(633, 11.5) < 90 # type: ignore[operator] assert calculate_iaqs(1000, 35.0) < 70 # type: ignore[operator] Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/test_v3.py000066400000000000000000000032231512742123700232450ustar00rootroot00000000000000from ruuvitag_ble import RuuvitagBluetoothDeviceData from tests.utils import ( KEY_ACCELERATION_TOTAL, KEY_ACCELERATION_X, KEY_ACCELERATION_Y, KEY_ACCELERATION_Z, KEY_HUMIDITY, KEY_PRESSURE, KEY_TEMPERATURE, KEY_VOLTAGE, bytes_to_service_info, ) V3_SENSOR_DATA = bytes.fromhex("03b20c1fca20007a002603d0088f") # fmt: skip V3_SENSOR_DATA_SUBZERO = bytes.fromhex("03b28145ca20007a002603d0088f") # fmt: skip def test_parsing_v3(): device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V3_SENSOR_DATA) assert device.supported(advertisement) up = device.update(advertisement) expected_name = "RuuviTag 0000" assert up.devices[None].name == expected_name assert up.entity_values[KEY_TEMPERATURE].native_value == 12.31 # Celsius assert up.entity_values[KEY_HUMIDITY].native_value == 89.0 # % assert up.entity_values[KEY_PRESSURE].native_value == 1017.44 # hPa assert up.entity_values[KEY_VOLTAGE].native_value == 2191 # mV assert up.entity_values[KEY_ACCELERATION_X].native_value == 1.2 # m/s^2 assert up.entity_values[KEY_ACCELERATION_Y].native_value == 0.37 # m/s^2 assert up.entity_values[KEY_ACCELERATION_Z].native_value == 9.57 # m/s^2 assert up.entity_values[KEY_ACCELERATION_TOTAL].native_value == 9.65 # m/s^2 def test_parsing_v3_subzero(): device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V3_SENSOR_DATA_SUBZERO) assert device.supported(advertisement) up = device.update(advertisement) # via the datasheet: 0x8145 = -1.69 °C assert up.entity_values[KEY_TEMPERATURE].native_value == -1.69 # Celsius Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/test_v5.py000066400000000000000000000043611512742123700232530ustar00rootroot00000000000000from ruuvitag_ble import RuuvitagBluetoothDeviceData from tests.utils import ( KEY_ACCELERATION_TOTAL, KEY_ACCELERATION_X, KEY_ACCELERATION_Y, KEY_ACCELERATION_Z, KEY_HUMIDITY, KEY_MOVEMENT, KEY_PRESSURE, KEY_TEMPERATURE, KEY_VOLTAGE, bytes_to_service_info, ) # INDOOR_SENSOR_DATA = bytes.fromhex("050ea44d7ec818fcbcfdf0ffb42bf600103cd9370ff7aa48") # fmt: skip V5_OUTDOOR_SENSOR_DATA = bytes.fromhex("0505a060a0c89afd34028cff006376726976dead7b3fefaf") # fmt: skip V5_OUTDOOR_SENSOR_DATA_INVALID_ACCEL = bytes.fromhex("0505a060a0c89a8000028cff006376726976dead7b3fefaf") # fmt: skip def test_parsing_v5(): device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V5_OUTDOOR_SENSOR_DATA) assert device.supported(advertisement) up = device.update(advertisement) expected_name = "RuuviTag EFAF" assert up.devices[None].name == expected_name # Parsed from advertisement assert up.entity_values[KEY_TEMPERATURE].native_value == 7.2 # Celsius assert up.entity_values[KEY_HUMIDITY].native_value == 61.84 # % assert up.entity_values[KEY_PRESSURE].native_value == 1013.54 # hPa assert up.entity_values[KEY_VOLTAGE].native_value == 2395 # mV assert up.entity_values[KEY_MOVEMENT].native_value == 114 # count assert up.entity_values[KEY_ACCELERATION_X].native_value == -7.02 # m/s^2 assert up.entity_values[KEY_ACCELERATION_Y].native_value == 6.39 # m/s^2 assert up.entity_values[KEY_ACCELERATION_Z].native_value == -2.51 # m/s^2 assert up.entity_values[KEY_ACCELERATION_TOTAL].native_value == 9.82 # m/s^2 def test_parsing_v5_invalid_acceleration(): """ Test that invalid acceleration values are handled correctly (any component being invalid invalidates all acceleration values, including the total). """ device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V5_OUTDOOR_SENSOR_DATA_INVALID_ACCEL) up = device.update(advertisement) assert up.entity_values[KEY_ACCELERATION_X].native_value is None assert up.entity_values[KEY_ACCELERATION_Y].native_value is None assert up.entity_values[KEY_ACCELERATION_Z].native_value is None assert up.entity_values[KEY_ACCELERATION_TOTAL].native_value is None Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/test_v6.py000066400000000000000000000155261512742123700232610ustar00rootroot00000000000000import pytest from ruuvitag_ble import RuuvitagBluetoothDeviceData from ruuvitag_ble.df6_decoder import DataFormat6Decoder from tests.utils import ( KEY_CO2, KEY_HUMIDITY, KEY_ILLUMINANCE, KEY_NOX_INDEX, KEY_PM25, KEY_PRESSURE, KEY_TEMPERATURE, KEY_VOC_INDEX, bytes_to_service_info, ) # Test vectors from DF6 sensor data V6_BASELINE_SENSOR_DATA = bytes.fromhex("06144E40F8C915000602193200A34BBDC0FF00FF") # fmt: skip V6_BREATH_HIGH_CO2_DATA = bytes.fromhex("0614DA863CC90300090DF9A600A057F690FF00FF") # fmt: skip V6_BREATH_LOWER_CO2_DATA = bytes.fromhex("0615113E08C9030007057B3E00A44D6290FF00FF") # fmt: skip V6_LOW_LUMINOSITY_DATA = bytes.fromhex("0614974158C9110005020C56007C4B9580FF00FF") # fmt: skip V6_C_TEST_DATA = bytes.fromhex("06170C5668C79E007000C90501D94ACD004C884F") # fmt: skip def test_parsing_v6_baseline(): """Test parsing DF6 baseline sensor data.""" device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V6_BASELINE_SENSOR_DATA) assert device.supported(advertisement) up = device.update(advertisement) expected_name = "RuuviTag 00FF" assert up.devices[None].name == expected_name # Parsed from advertisement v = up.entity_values assert v[KEY_CO2].native_value == 537 # ppm assert v[KEY_HUMIDITY].native_value == 41.58 # % assert v[KEY_ILLUMINANCE].native_value == 1232 # lux assert v[KEY_NOX_INDEX].native_value == 1 # index assert v[KEY_PM25].native_value == 0.6 # μg/m³ assert v[KEY_PRESSURE].native_value == 1014.77 # hPa assert v[KEY_TEMPERATURE].native_value == 25.99 # Celsius assert v[KEY_VOC_INDEX].native_value == 101.0 # index def test_parsing_v6_breath_high_co2(): """Test parsing DF6 data with elevated CO2 from breath.""" device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V6_BREATH_HIGH_CO2_DATA) v = device.update(advertisement).entity_values assert v[KEY_CO2].native_value == 3577 # ppm (elevated from breath) assert v[KEY_HUMIDITY].native_value == 85.91 # % assert v[KEY_ILLUMINANCE].native_value == 1080 # lux assert v[KEY_NOX_INDEX].native_value == 1 # index assert v[KEY_PM25].native_value == 0.9 # μg/m³ assert v[KEY_PRESSURE].native_value == 1014.59 # hPa assert v[KEY_TEMPERATURE].native_value == 26.69 # Celsius assert v[KEY_VOC_INDEX].native_value == 332 # index (elevated from breath) def test_parsing_v6_breath_lower_co2(): """Test parsing DF6 data with lower CO2 from breath test.""" device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V6_BREATH_LOWER_CO2_DATA) v = device.update(advertisement).entity_values assert v[KEY_CO2].native_value == 1403 # ppm (lower CO2 than first breath test) assert v[KEY_HUMIDITY].native_value == 39.7 # % assert v[KEY_ILLUMINANCE].native_value == 1287 # lux assert v[KEY_NOX_INDEX].native_value == 1 # index assert v[KEY_PM25].native_value == 0.7 # μg/m³ assert v[KEY_PRESSURE].native_value == 1014.59 # hPa assert v[KEY_TEMPERATURE].native_value == 26.96 # Celsius assert v[KEY_VOC_INDEX].native_value == 124 # index (lower than first breath test) def test_parsing_v6_low_luminosity(): """Test parsing DF6 data with low luminosity conditions.""" device = RuuvitagBluetoothDeviceData() advertisement = bytes_to_service_info(V6_LOW_LUMINOSITY_DATA) v = device.update(advertisement).entity_values assert v[KEY_ILLUMINANCE].native_value == 224 # lux (low light conditions) def test_parsing_c_data(): # See https://github.com/ruuvi/ruuvi.endpoints.c/blob/f16619cc261ec/test/test_ruuvi_endpoint_6.c#L33 p = DataFormat6Decoder(V6_C_TEST_DATA) assert p.temperature_celsius == 29.5 assert p.humidity_percentage == 55.3 assert p.pressure_hpa == 1011.02 assert p.pm25_ug_m3 == 11.2 assert p.co2_ppm == 201 assert p.voc_index == 10 assert p.nox_index == 2 assert p.luminosity_lux == 13027 assert p.sound_avg_dba == 47.6 assert p.measurement_sequence_number == 205 assert p.mac == "4C:88:4F" def test_parsing_v6_invalid_data(): """Test parsing DF6 data with invalid/NaN values.""" # Based on test_ruuvi_endpoint_6_get_invalid_data invalid_data = bytes.fromhex("068000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF") p = DataFormat6Decoder(invalid_data) # Temperature should be NaN (0x8000 represents invalid temperature) # Most other values should be invalid (0xFFFF or 0xFF patterns) assert p.temperature_celsius is None assert p.sound_avg_dba is None assert p.humidity_percentage is None assert p.pressure_hpa is None assert p.pm25_ug_m3 is None assert p.co2_ppm is None assert p.voc_index is None assert p.nox_index is None assert p.luminosity_lux is None assert p.measurement_sequence_number == 255 assert p.mac == "FF:FF:FF" def test_parsing_v6_underflow(): """Test parsing DF6 data with underflow values that get clamped to minimum.""" # Based on test_ruuvi_endpoint_6_underflow # Values below minimum should be clamped to minimum valid values underflow_data = bytes.fromhex("0680010000000000000000000000000000000000") p = DataFormat6Decoder(underflow_data) # These should be clamped to minimum valid values assert p.temperature_celsius == pytest.approx(-163.835, abs=0.01) # Min temperature assert p.humidity_percentage == 0.0 # Min humidity assert p.pressure_hpa == 500.0 # Min pressure assert p.pm25_ug_m3 == 0.0 # Min PM2.5 assert p.co2_ppm == 0 # Min CO2 assert p.voc_index == 0 # Min VOC assert p.nox_index == 0 # Min NOx assert p.luminosity_lux == 0 # Min luminosity assert p.sound_avg_dba == 18.0 # Min sound assert p.measurement_sequence_number == 0 assert p.mac == "00:00:00" def test_parsing_v6_overflow(): """Test parsing DF6 data with overflow values that get clamped to maximum.""" overflow_data = bytes.fromhex("067FFF9C40FFFE27109C40FAFAFEFEFF07FFFFFF") p = DataFormat6Decoder(overflow_data) # These should be clamped to maximum valid values assert p.temperature_celsius == pytest.approx(163.835, abs=0.01) # Max temperature assert p.humidity_percentage == 100.0 # Max humidity assert p.pressure_hpa == pytest.approx(1155.34, abs=0.01) # Max pressure assert p.pm25_ug_m3 == pytest.approx(1000.0, abs=0.01) # Max PM2.5 assert p.co2_ppm == 40000 # Max CO2 assert p.voc_index == 500 # Max VOC assert p.nox_index == 500 # Max NOx assert p.luminosity_lux == 65535 # Max luminosity assert p.sound_avg_dba == 119.6 # Max sound (120 in C version) assert p.measurement_sequence_number == 255 assert p.mac == "FF:FF:FF" def test_bad_data(): with pytest.raises(ValueError): DataFormat6Decoder(bytes.fromhex("0614")) with pytest.raises(ValueError): DataFormat6Decoder(bytes.fromhex("07" * 20)) Bluetooth-Devices-ruuvitag-ble-e84c0ea/tests/utils.py000066400000000000000000000024571512742123700230260ustar00rootroot00000000000000from home_assistant_bluetooth import BluetoothServiceInfo from sensor_state_data import DeviceClass, DeviceKey KEY_ACCELERATION_TOTAL = DeviceKey(key="acceleration_total", device_id=None) KEY_ACCELERATION_X = DeviceKey(key="acceleration_x", device_id=None) KEY_ACCELERATION_Y = DeviceKey(key="acceleration_y", device_id=None) KEY_ACCELERATION_Z = DeviceKey(key="acceleration_z", device_id=None) KEY_CO2 = DeviceKey(key=DeviceClass.CO2, device_id=None) KEY_HUMIDITY = DeviceKey(key=DeviceClass.HUMIDITY, device_id=None) KEY_ILLUMINANCE = DeviceKey(key=DeviceClass.ILLUMINANCE, device_id=None) KEY_MOVEMENT = DeviceKey(key="movement_counter", device_id=None) KEY_NOX_INDEX = DeviceKey(key="nox_index", device_id=None) KEY_PM25 = DeviceKey(key="pm25", device_id=None) KEY_PRESSURE = DeviceKey(key=DeviceClass.PRESSURE, device_id=None) KEY_TEMPERATURE = DeviceKey(key=DeviceClass.TEMPERATURE, device_id=None) KEY_VOC_INDEX = DeviceKey(key="voc_index", device_id=None) KEY_VOLTAGE = DeviceKey(key=DeviceClass.VOLTAGE, device_id=None) def bytes_to_service_info(payload: bytes) -> BluetoothServiceInfo: return BluetoothServiceInfo( name="Test", address="00:00:00:00:00:00", rssi=-60, manufacturer_data={1177: payload}, service_data={}, service_uuids=[], source="", )