pax_global_header00006660000000000000000000000064144170427300014514gustar00rootroot0000000000000052 comment=5f65fac2475d0d13f68983bee60df8cc567b7694 akx-aioruuvigateway-5b86d3d/000077500000000000000000000000001441704273000161435ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/.github/000077500000000000000000000000001441704273000175035ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/.github/workflows/000077500000000000000000000000001441704273000215405ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/.github/workflows/build.yml000066400000000000000000000006761441704273000233730ustar00rootroot00000000000000name: Build on: pull_request: branches: - master push: branches: - master tags: - v* jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.11" - run: pip install hatch - run: hatch build - uses: actions/upload-artifact@v2 with: name: dist path: dist/* akx-aioruuvigateway-5b86d3d/.github/workflows/pre-commit.yml000066400000000000000000000004041441704273000243350ustar00rootroot00000000000000name: pre-commit on: pull_request: branches: [master] push: branches: [master] jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 - uses: pre-commit/action@v3.0.0 akx-aioruuvigateway-5b86d3d/.github/workflows/test.yml000066400000000000000000000010441441704273000232410ustar00rootroot00000000000000name: Test on: pull_request: branches: - master push: branches: - master jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.11"] steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: pyproject.toml - run: pip install hatch - run: hatch run cov - uses: codecov/codecov-action@v3 akx-aioruuvigateway-5b86d3d/.gitignore000066400000000000000000000001271441704273000201330ustar00rootroot00000000000000*.log *.py[cod] *cache .coverage /experiment* coverage.xml dist htmlcov tests/fixtures akx-aioruuvigateway-5b86d3d/.pre-commit-config.yaml000066400000000000000000000013521441704273000224250ustar00rootroot00000000000000repos: - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.0.241 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black rev: 22.12.0 hooks: - id: black language_version: python3 - repo: https://github.com/codespell-project/codespell rev: v2.2.2 hooks: - id: codespell - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.991 hooks: - id: mypy - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.7.1 hooks: - id: prettier akx-aioruuvigateway-5b86d3d/LICENSE.txt000066400000000000000000000021031441704273000177620ustar00rootroot00000000000000MIT License Copyright (c) 2022-present 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. akx-aioruuvigateway-5b86d3d/README.md000066400000000000000000000017721441704273000174310ustar00rootroot00000000000000# aioruuvigateway An asyncio-native library for requesting data from a Ruuvi Gateway. [![PyPI - Version](https://img.shields.io/pypi/v/aioruuvigateway.svg)](https://pypi.org/project/aioruuvigateway) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/aioruuvigateway.svg)](https://pypi.org/project/aioruuvigateway) --- ## Installation Requires Python 3.7 or newer. ```console pip install aioruuvigateway ``` ## Usage Ensure you have set up bearer token authentication in your Ruuvi Gateway (and that you know the token). ### API Documentation can be found in `test_library.py` for now, sorry. ### Command line interface You can use the command line interface to test the library. ```console python -m aioruuvigateway --host 192.168.1.249 --token bearbear --parse --json ``` will output data from the gateway in JSON format, printing changed information every 10 seconds. ## License `aioruuvigateway` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. akx-aioruuvigateway-5b86d3d/aioruuvigateway/000077500000000000000000000000001441704273000213705ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/aioruuvigateway/__about__.py000066400000000000000000000000261441704273000236460ustar00rootroot00000000000000__version__ = "0.1.0" akx-aioruuvigateway-5b86d3d/aioruuvigateway/__init__.py000066400000000000000000000000001441704273000234670ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/aioruuvigateway/__main__.py000066400000000000000000000052161441704273000234660ustar00rootroot00000000000000import argparse import asyncio import dataclasses import json import logging import httpx from aioruuvigateway.api import get_gateway_history_data from aioruuvigateway.models import TagData log = logging.getLogger(__name__) def json_default(o): if isinstance(o, bytes): return o.hex() raise TypeError(f"Object {o!r} is not JSON serializable") def dump_tag_json(tag: TagData, *, parse: bool) -> str: data = dataclasses.asdict(tag) if parse: data["parsed"] = dataclasses.asdict(tag.parse_announcement()) return json.dumps( data, ensure_ascii=False, default=json_default, ) async def run( host: str, token: str, interval: int, parse: bool = False, output_json: bool = False, ) -> None: async with httpx.AsyncClient() as client: last_tag_datas: dict[str, TagData] = {} while True: data = await get_gateway_history_data( client, host=host, bearer_token=token, ) n_new = 0 for tag in data.tags: if ( tag.mac not in last_tag_datas or last_tag_datas[tag.mac].data != tag.data ): try: if output_json: print(dump_tag_json(tag, parse=parse)) else: print(tag) if parse: print(" ", tag.parse_announcement()) except Exception: log.exception("Error printing tag %s", tag.mac) last_tag_datas[tag.mac] = tag n_new += 1 if not n_new: log.info("No new data") await asyncio.sleep(interval) def main() -> None: ap = argparse.ArgumentParser( description="Log Ruuvi Gateway history data", ) ap.add_argument("--host", required=True) ap.add_argument("--token", required=True) ap.add_argument("--interval", type=int, default=5) ap.add_argument("--json", action="store_true", help="Output JSON") ap.add_argument("--parse", action="store_true", help="Parse advertisement data") args = ap.parse_args() logging.basicConfig( level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S", format="%(asctime)s %(levelname)s %(message)s", ) asyncio.run( run( host=args.host, token=args.token, interval=args.interval, parse=args.parse, output_json=args.json, ) ) if __name__ == "__main__": main() akx-aioruuvigateway-5b86d3d/aioruuvigateway/api.py000066400000000000000000000020051441704273000225100ustar00rootroot00000000000000from __future__ import annotations import httpx from aioruuvigateway.excs import CannotConnect, InvalidAuth from aioruuvigateway.models import HistoryResponse async def get_gateway_history_data( client: httpx.AsyncClient, host: str, bearer_token: str, timeout: int | None = None, ) -> HistoryResponse: try: resp = await client.get( url=f"http://{host}/history", headers={ "Authorization": f"Bearer {bearer_token}", }, timeout=(timeout if timeout else httpx.USE_CLIENT_DEFAULT), ) if resp.status_code == 200: return HistoryResponse.from_gateway_history_json(resp.json()) resp.raise_for_status() except httpx.HTTPStatusError as err: if err.response.status_code == 401: raise InvalidAuth from err raise CannotConnect from err except Exception as err: # pragma: no cover raise CannotConnect from err raise NotImplementedError("This should not happen") akx-aioruuvigateway-5b86d3d/aioruuvigateway/excs.py000066400000000000000000000001221441704273000226770ustar00rootroot00000000000000class InvalidAuth(Exception): pass class CannotConnect(Exception): pass akx-aioruuvigateway-5b86d3d/aioruuvigateway/models.py000066400000000000000000000037731441704273000232370ustar00rootroot00000000000000from __future__ import annotations import dataclasses import datetime from bluetooth_data_tools import BLEGAPAdvertisement, parse_advertisement_data class TimestampConversionMixin: timestamp: int @property def datetime(self) -> datetime.datetime: return datetime.datetime.utcfromtimestamp(self.timestamp) @dataclasses.dataclass() class TagData(TimestampConversionMixin): mac: str # AA:BB:CC:DD:EE:FF rssi: int timestamp: int data: bytes age_seconds: int | None = None def parse_announcement(self) -> BLEGAPAdvertisement: return parse_advertisement_data([self.data]) @classmethod def from_gateway_history_json_tag( cls, mac: str, data: dict, response_timestamp: int | None, ) -> TagData: tag_timestamp = int(data["timestamp"]) age_seconds = ( (response_timestamp - tag_timestamp) if response_timestamp else None ) return cls( mac=mac, rssi=int(data["rssi"]), timestamp=tag_timestamp, data=bytes.fromhex(data["data"]), age_seconds=age_seconds, ) @dataclasses.dataclass() class HistoryResponse(TimestampConversionMixin): timestamp: int gw_mac: str tags: list[TagData] coordinates: str = "" @property def gw_mac_suffix(self) -> str: return self.gw_mac[-5:].upper() @classmethod def from_gateway_history_json(cls, data: dict) -> HistoryResponse: data = data["data"] response_timestamp = int(data["timestamp"]) tags = [ TagData.from_gateway_history_json_tag( mac=mac, data=tag_data, response_timestamp=response_timestamp, ) for mac, tag_data in data.get("tags", {}).items() ] return HistoryResponse( timestamp=response_timestamp, gw_mac=data["gw_mac"], coordinates=data.get("coordinates", ""), tags=tags, ) akx-aioruuvigateway-5b86d3d/aioruuvigateway/py.typed000066400000000000000000000000001441704273000230550ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/pyproject.toml000066400000000000000000000036051441704273000210630ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "aioruuvigateway" description = '' readme = "README.md" requires-python = ">=3.7" license = "MIT" keywords = [] authors = [ { name = "Aarni Koskela", email = "akx@iki.fi" }, ] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ "bluetooth-data-tools>=0.3.1", "httpx>=0.23.0", ] dynamic = ["version"] [project.urls] Documentation = "https://github.com/akx/aioruuvigateway#readme" Issues = "https://github.com/akx/aioruuvigateway/issues" Source = "https://github.com/akx/aioruuvigateway" [tool.hatch.version] path = "aioruuvigateway/__about__.py" [tool.hatch.envs.default] dependencies = [ "pytest", "pytest-cov", "pytest-asyncio", "pytest-httpx", ] [tool.hatch.envs.default.scripts] cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=aioruuvigateway --cov=tests {args}" no-cov = "cov --no-cov {args}" [[tool.hatch.envs.test.matrix]] python = ["37", "38", "39", "310", "311"] [tool.coverage.run] branch = true parallel = true omit = [ "aioruuvigateway/__about__.py", "aioruuvigateway/__main__.py", ] [tool.coverage.report] exclude_lines = [ "no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:", "raise NotImplementedError", ] [tool.ruff] target-version = "py37" ignore = ["SIM105"] line-length = 88 select = [ "C9", "E", "F", "SIM", "TID", "W", ] [tool.ruff.mccabe] max-complexity = 10 [tool.ruff.flake8-tidy-imports] ban-relative-imports = "all" akx-aioruuvigateway-5b86d3d/tests/000077500000000000000000000000001441704273000173055ustar00rootroot00000000000000akx-aioruuvigateway-5b86d3d/tests/__init__.py000066400000000000000000000001431441704273000214140ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022-present Aarni Koskela # # SPDX-License-Identifier: MIT akx-aioruuvigateway-5b86d3d/tests/example.json000066400000000000000000000016531441704273000216400ustar00rootroot00000000000000{ "data": { "coordinates": "", "timestamp": "1672395021", "gw_mac": "AA:BB:CC:DD:EE:FF", "tags": { "AA:AA:AA:AA:AF:EF": { "rssi": -59, "timestamp": "1672395020", "data": "0201061BFF99040505765FB4C18AFD240278FF04487656D881AAAAAAAAAFEF" }, "A0:72:36:AD:41:FE": { "rssi": -53, "timestamp": "1672395021", "data": "1AFF4C000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000000000" }, "69:D9:F9:C9:E9:49": { "rssi": -56, "timestamp": "1672395021", "data": "02010616FF2D01000001010202B00000000003CB000000000000" }, "BB:BB:BB:BB:CA:08": { "rssi": -65, "timestamp": "1672395021", "data": "0201061BFF9904050F4845C0C0EC03200284FFBC9976876268BBBBBBBBCA08" }, "E0:50:40:30:20:A0": { "rssi": -87, "timestamp": "1672395002", "data": "07FF4C0012020000" } } } } akx-aioruuvigateway-5b86d3d/tests/test_library.py000066400000000000000000000030311441704273000223570ustar00rootroot00000000000000from collections import Counter from pathlib import Path import httpx import pytest from aioruuvigateway.api import get_gateway_history_data from aioruuvigateway.excs import InvalidAuth example_path = Path(__file__).parent / "example.json" @pytest.mark.asyncio async def test_library(httpx_mock): httpx_mock.add_response( url="http://192.168.1.202/history", content=example_path.read_bytes(), headers={"Content-Type": "application/json"}, ) async with httpx.AsyncClient() as client: history = await get_gateway_history_data( client=client, host="192.168.1.202", bearer_token="bear, a scary bear", ) assert history.gw_mac_suffix == "EE:FF" manufacturers = Counter() for tag in history.tags: assert tag.datetime.year == 2022 assert tag.age_seconds in {0, 1, 19} ann = tag.parse_announcement() print(tag, ann) manufacturers.update(ann.manufacturer_data.keys()) assert manufacturers == { 0x0499: 2, # Two Ruuvitags 0x012D: 1, # One Sony 0x004C: 2, # Two Apples } @pytest.mark.asyncio async def test_auth(httpx_mock): httpx_mock.add_response( url="http://192.168.1.202/history", status_code=401, ) async with httpx.AsyncClient() as client: with pytest.raises(InvalidAuth): await get_gateway_history_data( client=client, host="192.168.1.202", bearer_token="not good at all", )