pax_global_header00006660000000000000000000000064146616017760014530gustar00rootroot0000000000000052 comment=53190448e375e7c61c943486e08adbf9214d596c angelnu-thethingsnetwork_python_client-5319044/000077500000000000000000000000001466160177600217205ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/.devcontainer/000077500000000000000000000000001466160177600244575ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/.devcontainer/devcontainer.json000066400000000000000000000027231466160177600300370ustar00rootroot00000000000000{ "name": "zwave-js-server-python Dev", "image": "mcr.microsoft.com/devcontainers/python:3.11", "postCreateCommand": "scripts/setup", "containerEnv": { "DEVCONTAINER": "1" }, "runArgs": ["-e", "GIT_EDITOR=code --wait"], "customizations": { "vscode": { "extensions": [ "charliermarsh.ruff", "ms-python.pylint", "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", "esbenp.prettier-vscode", "GitHub.vscode-pull-request-github" ], // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.pythonPath": "/usr/local/bin/python", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" } }, "terminal.integrated.defaultProfile.linux": "zsh", "yaml.customTags": [ "!input scalar", "!secret scalar", "!include_dir_named scalar", "!include_dir_list scalar", "!include_dir_merge_list scalar", "!include_dir_merge_named scalar" ], "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" } } } } }angelnu-thethingsnetwork_python_client-5319044/.github/000077500000000000000000000000001466160177600232605ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/.github/workflows/000077500000000000000000000000001466160177600253155ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/.github/workflows/publish-to-pypi.yml000066400000000000000000000012641466160177600311100ustar00rootroot00000000000000name: Publish releases to PyPI on: release: types: [published, prereleased] jobs: build-and-publish: name: Builds and publishes releases to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.2 - name: Set up Python 3.11 uses: actions/setup-python@v5.0.0 with: python-version: "3.11" - name: Install wheel run: |- pip install wheel - name: Build run: |- pip install build python -m build - name: Publish release to PyPI uses: pypa/gh-action-pypi-publish@v1.8.14 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }}angelnu-thethingsnetwork_python_client-5319044/.github/workflows/test.yaml000066400000000000000000000027151466160177600271650ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Test on: workflow_dispatch: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.11" - "3.12" - "3.13.0-rc.1" steps: - uses: actions/checkout@v4.1.2 with: fetch-depth: 2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Test with tox run: tox - name: Upload coverage data uses: actions/upload-artifact@v3.1.3 with: name: coverage-${{ matrix.python-version }} path: "coverage.xml" coverage: runs-on: ubuntu-latest needs: build steps: - name: Check out the repository uses: actions/checkout@v4.1.2 with: fetch-depth: 2 - name: Download all coverage data uses: actions/download-artifact@v3.0.2 - name: Upload coverage report uses: codecov/codecov-action@v4.1.0 with: token: ${{ secrets.CODECOV_TOKEN }}angelnu-thethingsnetwork_python_client-5319044/.gitignore000066400000000000000000000060201466160177600237060ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .DS_Store angelnu-thethingsnetwork_python_client-5319044/.pre-commit-config.yaml000066400000000000000000000000111466160177600261710ustar00rootroot00000000000000repos: []angelnu-thethingsnetwork_python_client-5319044/.vscode/000077500000000000000000000000001466160177600232615ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/.vscode/settings.json000066400000000000000000000000621466160177600260120ustar00rootroot00000000000000{ "cSpell.words": [ "sensecap" ] }angelnu-thethingsnetwork_python_client-5319044/LICENSE000066400000000000000000000020641466160177600227270ustar00rootroot00000000000000MIT License Copyright (c) 2024 Angel Nunez Mencias 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. angelnu-thethingsnetwork_python_client-5319044/README.md000066400000000000000000000045511466160177600232040ustar00rootroot00000000000000# The Things Network_python_client [![codecov](https://codecov.io/gh/angelnu/thethingsnetwork_python_client/graph/badge.svg?token=yUTImnfbUL)](https://codecov.io/gh/angelnu/thethingsnetwork_python_client) [![PyPI version](https://badge.fury.io/py/ttn-client.svg)](https://badge.fury.io/py/ttn-client) A python client to fetch/receive and parse uplink messages from The Things Network It is used primarily for the [home-assistant integration](https://www.home-assistant.io/integrations/thethingsnetwork/) but it should also work independently - check the [testcases](tests). ## Assumptions For this library to work, the [TheThingNetwork storage plugin](https://www.thethingsindustries.com/docs/integrations/storage/) is required. You might also check the [home assistant prerequisites](https://www.home-assistant.io/integrations/thethingsnetwork/#prerequisites) as they also apply when this library is used standalone. A [payload decoded](https://www.thethingsindustries.com/docs/integrations/payload-formatters/) needs to be present so that you have a `decoded_payload` in the uplink message. By default, this library expects the `decoded_payload` to have a key-value format such as: ```json5 "decoded_payload": { "accelerometer_77": { "x": 1, # Sensor will be called accelerometer_77_x "y": 0.5, # Sensor will be called accelerometer_77_y "z": 9.8 # Sensor will be called accelerometer_77_z }, "voltage": 3.1, # Sensor will be called voltage and be of type float "boolean_1": true, # Binary Sensor will be called boolean_1 "digital_in_1": 8, # Sensor will be called voltage and be of type int "gps_34": { # Device Tracker will be called gps_34 "latitude": 48.76826575, "longitude": 9.1596689, "altitude": 310 } } ``` If you have a device using a different format, please open an [Issue](issues) and post a copy of **full** message for your device. ## Supported devices - [Default](tests/parsers/test_data/default_valid.json) - [Sensecap](tests/parsers/test_data/sensecap_valid.json) ## How to test This library uses [tox](https://tox.wiki) so just install it and run `tox` ## Thanks This package structure and pipeline is derived from the [zwave-js-server-python](https://github.com/home-assistant-libs/zwave-js-server-python) package. angelnu-thethingsnetwork_python_client-5319044/codecov.yml000066400000000000000000000001231466160177600240610ustar00rootroot00000000000000comment: false coverage: status: project: default: target: 100 angelnu-thethingsnetwork_python_client-5319044/pyproject.toml000066400000000000000000000012201466160177600246270ustar00rootroot00000000000000[project] name = "ttn_client" version = "1.2.1" authors = [ { name="Angel Nunez Mencias", email="pypi@angelnu.com" }, ] description = "A python client to fetch/receive and parse uplink messages from The Thinks Network" readme = "README.md" requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] [project.urls] Homepage = "https://github.com/angelnu/thethinksnetwork_python_client" Issues = "https://github.com/angelnu/thethinksnetwork_python_client/issues" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" angelnu-thethingsnetwork_python_client-5319044/requirements.txt000066400000000000000000000000171466160177600252020ustar00rootroot00000000000000aiohttp==3.10.2angelnu-thethingsnetwork_python_client-5319044/requirements_dev.txt000066400000000000000000000001631466160177600260420ustar00rootroot00000000000000-r requirements.txt -r requirements_lint.txt -r requirements_scripts.txt -r requirements_test.txt tox==4.14.1 -e .angelnu-thethingsnetwork_python_client-5319044/requirements_lint.txt000066400000000000000000000001461466160177600262330ustar00rootroot00000000000000black==24.3.0 mypy==1.9.0 pylint==3.1.0 pylint-strict-informational==0.1 pre-commit==3.6.2 ruff==0.3.2angelnu-thethingsnetwork_python_client-5319044/requirements_scripts.txt000066400000000000000000000002501466160177600267500ustar00rootroot00000000000000colorlog==6.8.2 requests==2.32.0 python-slugify==8.0.4 types-python-slugify==8.0.2.20240310 types-requests==2.31.0.20240311 -r requirements.txt -r requirements_lint.txtangelnu-thethingsnetwork_python_client-5319044/requirements_test.txt000066400000000000000000000001141466160177600262370ustar00rootroot00000000000000pytest==8.1.1 pytest-cov==4.1.0 pytest-asyncio==0.23.7 pytest-timeout==2.3.1angelnu-thethingsnetwork_python_client-5319044/scripts/000077500000000000000000000000001466160177600234075ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/scripts/setup000077500000000000000000000004061466160177600244750ustar00rootroot00000000000000#!/usr/bin/env bash # Setups the repository. # Stop on errors set -e if [ ! -n "$DEVCONTAINER" ] && [ ! -n "$VIRTUAL_ENV" ];then python3 -m venv venv source venv/bin/activate fi python3 -m pip install -r requirements_dev.txt pre-commit install echo Doneangelnu-thethingsnetwork_python_client-5319044/src/000077500000000000000000000000001466160177600225075ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/000077500000000000000000000000001466160177600246525ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/__init__.py000066400000000000000000000002401466160177600267570ustar00rootroot00000000000000"""Export public classes.""" from .client import TTNClient # noqa: F401 from .values import * # noqa: F401,F403 from .exceptions import * # noqa: F401,F403 angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/client.py000066400000000000000000000076431466160177600265140ustar00rootroot00000000000000"""Client for The Thinks Network.""" from collections.abc import Awaitable, Callable from datetime import datetime import json import logging import aiohttp from aiohttp.hdrs import ACCEPT, AUTHORIZATION from .const import DEFAULT_TIMEOUT, TTN_DATA_STORAGE_URL from .values import TTNBaseValue from .exceptions import TTNAuthError from .parsers import ttn_parse _LOGGER = logging.getLogger(__name__) class TTNClient: # pylint: disable=too-few-public-methods """Client to connect to the Things Network.""" DATA_TYPE = dict[str, dict[str, TTNBaseValue]] def __init__( # pylint: disable=too-many-arguments self, hostname: str, application_id: str, access_key: str, first_fetch_h: int = 24, push_callback: Callable[[DATA_TYPE], Awaitable[None]] | None = None, ) -> None: self.__hostname = hostname self.__application_id = application_id self.__access_key = access_key self.__first_fetch_h = first_fetch_h self.__push_callback = push_callback # TBD: add support for MQTT to get faster updates # pylint: disable=W0238 self.__last_measurement_datetime: datetime | None = None async def fetch_data(self) -> DATA_TYPE: """Fetch data stored by the TTN Storage since the last time we fetched/received data.""" if not self.__last_measurement_datetime: fetch_last = f"{self.__first_fetch_h}h" _LOGGER.info("First fetch of tth data: %s", fetch_last) else: # Fetch new measurements since last time (with an extra minute margin) delta = datetime.now() - self.__last_measurement_datetime delta_s = delta.total_seconds() fetch_last = f"{delta_s}s" _LOGGER.info("Fetch of ttn data: %s", fetch_last) self.__last_measurement_datetime = datetime.now() # Discover entities # See API docs # at https://www.thethingsindustries.com/docs/reference/api/storage_integration/ return await self.__storage_api_call(f"?last={fetch_last}&order=received_at") async def __storage_api_call(self, options) -> DATA_TYPE: url = TTN_DATA_STORAGE_URL.format( app_id=self.__application_id, hostname=self.__hostname, options=options ) _LOGGER.debug("URL: %s", url) headers = { ACCEPT: "text/event-stream", AUTHORIZATION: f"Bearer {self.__access_key}", } async with ( aiohttp.ClientSession(timeout=DEFAULT_TIMEOUT) as session, session.get( url, allow_redirects=False, timeout=DEFAULT_TIMEOUT, headers=headers ) as response, ): if response.status in range(400, 500): # LOGGER.error("Not authorized for Application ID: %s", self.__application_id) raise TTNAuthError if response.status not in range(200, 300): raise RuntimeError( f"expected 200 got {response.status} - {response.reason}", ) ttn_values: TTNClient.DATA_TYPE = {} async for application_up_raw in response.content: # Skip empty lines not containing a result if len(application_up_raw) < len("result"): continue _LOGGER.debug("TTN entry: %s", application_up_raw) # Parse line with json dictionary application_up_json = json.loads(application_up_raw) if "result" not in application_up_json: _LOGGER.error("TTN entry without result: %s", application_up_json) continue application_up = application_up_json["result"] # Get device_id and uplink_message from measurement device_id = application_up["end_device_ids"]["device_id"] ttn_values[device_id] = ttn_parse(application_up) return ttn_values angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/const.py000066400000000000000000000004651466160177600263570ustar00rootroot00000000000000"""The Things Network's client constants.""" from typing import Final from aiohttp import ClientTimeout DEFAULT_TIMEOUT: Final[ClientTimeout] = ClientTimeout(total=10 * 60) TTN_DATA_STORAGE_URL = ( "https://{hostname}/api/v3/as/applications/" "{app_id}/packages/storage/uplink_message{options}" ) angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/exceptions/000077500000000000000000000000001466160177600270335ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/exceptions/__init__.py000066400000000000000000000001221466160177600311370ustar00rootroot00000000000000"""Exports public classes.""" from .auth_error import TTNAuthError # noqa: F401 angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/exceptions/auth_error.py000066400000000000000000000002401466160177600315530ustar00rootroot00000000000000"""Authorization Error for The Thinks Network client.""" # define Python user-defined exceptions class TTNAuthError(Exception): "Raised when we get 4xx." angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/parsers/000077500000000000000000000000001466160177600263315ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/parsers/__init__.py000066400000000000000000000014131466160177600304410ustar00rootroot00000000000000"""Parsers for for The Thinks Network client.""" from ..values import TTNBaseValue from .default import default_parser from .sensecap import sensecap_parser def ttn_parse(uplink_data: dict) -> dict[str, TTNBaseValue]: """Return a parser for the device.""" version_ids = uplink_data.get("uplink_message", {}).get("version_ids", {}) version_ids = uplink_data["uplink_message"].get("version_ids", {}) brand_id = version_ids.get("brand_id", {}) # model_id = version_ids.get("model_id", {}) # hardware_version = version_ids.get("hardware_version", {}) # firmware_version = version_ids.get("firmware_version", {}) if brand_id == "sensecap": parser = sensecap_parser else: parser = default_parser return parser(uplink_data) angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/parsers/default.py000066400000000000000000000047731466160177600303420ustar00rootroot00000000000000"""Cayenne parser for for The Thinks Network client.""" import logging from ..values import ( TTNBaseValue, TTNDeviceTrackerValue, TTNBinarySensorValue, TTNSensorValue, ) _LOGGER = logging.getLogger(__name__) def default_parser(uplink_data: dict) -> dict[str, TTNBaseValue]: """Cayenne parser for for The Thinks Network client.""" ttn_values: dict[str, TTNBaseValue] = {} # Get device_id and uplink_message from measurement device_id = uplink_data["end_device_ids"]["device_id"] uplink_message = uplink_data["uplink_message"] # Skip not decoded measurements if "decoded_payload" not in uplink_message: _LOGGER.warning("No decoded_payload for device %s", device_id) else: for field_id, value_item in uplink_message["decoded_payload"].items(): __default_parse_field( ttn_values, field_id, uplink_data, value_item, ) return ttn_values def __default_parse_field( ttn_values: dict[str, TTNBaseValue], field_id: str, application_up: dict, new_value, ) -> None: """Parses a cayenne field""" new_ttn_value: TTNBaseValue | None if isinstance(new_value, dict): if "latitude" in new_value and "longitude" in new_value: # GPS new_ttn_value = TTNDeviceTrackerValue(application_up, field_id, new_value) else: # Other - such as acceleration -> split in multiple ttn_values for key, value_item in new_value.items(): __default_parse_field( ttn_values, f"{field_id}_{key}", application_up, value_item, ) return elif isinstance(new_value, bool): # BinarySensor new_ttn_value = TTNBinarySensorValue(application_up, field_id, new_value) elif isinstance(new_value, list): # TTN_SensorValue with list as string new_ttn_value = TTNSensorValue(application_up, field_id, str(new_value)) elif isinstance(new_value, (str, int, float)): new_ttn_value = TTNSensorValue(application_up, field_id, new_value) elif new_value is None: # Skip null values _LOGGER.warning( "Ignoring entry %s with value=None - check your application decoder", field_id, ) return else: raise TypeError(f"Unexpected type {type(new_value)} for value: {new_value}") ttn_values[field_id] = new_ttn_value angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/parsers/sensecap.py000066400000000000000000000052541466160177600305120ustar00rootroot00000000000000"""Sensecap parser for for The Thinks Network client.""" import logging from ..values import TTNBaseValue, TTNSensorValue # pylint: disable=duplicate-code _LOGGER = logging.getLogger(__name__) def sensecap_parser(uplink_data: dict) -> dict[str, TTNBaseValue]: """Sensecap parser for for The Thinks Network client.""" ttn_values: dict[str, TTNBaseValue] = {} # Get device_id and uplink_message from measurement device_id = uplink_data["end_device_ids"]["device_id"] uplink_message = uplink_data["uplink_message"] # Skip not decoded measurements if "decoded_payload" not in uplink_message: _LOGGER.warning("No decoded_payload for device %s", device_id) else: decoded_payload = uplink_message["decoded_payload"] # Check im msg is valid if not decoded_payload.get("valid", False): _LOGGER.warning( "Ignoring message without valid=true for device %s: %s", device_id, decoded_payload, ) else: # Create values for fixed msgs for field in ["err", "payload"]: ttn_values[field] = TTNSensorValue( uplink_data, field, decoded_payload[field] ) if "messages" not in decoded_payload: _LOGGER.warning("No messages for device %s", device_id) else: # Parse messages messages = decoded_payload["messages"] for value_item in messages: __sensecap_parse_msg( ttn_values, device_id, uplink_data, value_item, ) return ttn_values def __sensecap_parse_msg( ttn_values: dict[str, TTNBaseValue], device_id: str, uplink_data: dict, value_item, ) -> None: """Parses a Sensecap field""" if isinstance(value_item, dict): battery = value_item.get("Battery(%)") measurement_id = value_item.get("measurementId") measurement_value = value_item.get("measurementValue") measurement_type = value_item.get("type") if battery: ttn_values["battery"] = TTNSensorValue(uplink_data, "battery", battery) return if measurement_id and measurement_value and measurement_type: field_id = f"{measurement_type.replace(' ','_')}_{measurement_id}" ttn_values[field_id] = TTNSensorValue( uplink_data, field_id, measurement_value ) _LOGGER.warning( "Message for device %s ignored (type %s): %s", device_id, type(value_item), value_item, ) angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/py.typed000066400000000000000000000000001466160177600263370ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/000077500000000000000000000000001466160177600261515ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/__init__.py000066400000000000000000000003731466160177600302650ustar00rootroot00000000000000"""Exports public classes.""" from .base import TTNBaseValue # noqa: F401 from .sensor import TTNSensorValue # noqa: F401 from .binary_sensor import TTNBinarySensorValue # noqa: F401 from .device_tracker import TTNDeviceTrackerValue # noqa: F401 angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/base.py000066400000000000000000000021441466160177600274360ustar00rootroot00000000000000"""Base value for The Thinks Network client.""" from datetime import datetime class TTNBaseValue: """Represents a TTN sensor value and includes metadata from the uplink message.""" def __init__(self, uplink: dict, field_id: str, value) -> None: self.__uplink = uplink self.__field_id = field_id self._value = value @property def uplink(self) -> dict: """raw uplink message.""" return self.__uplink @property def field_id(self) -> str: """field_id representing this value-""" return self.__field_id @property def value(self): """the value itself.""" return str(self._value) @property def received_at(self) -> datetime: """the datetime the value was received.""" # Example: 2024-03-11T08:49:11.153738893Z return datetime.fromisoformat(self.uplink["received_at"]) @property def device_id(self) -> str: """device_id for this value.""" return self.uplink["end_device_ids"]["device_id"] def __repr__(self) -> str: return f"TTN_Value({self.value})" angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/binary_sensor.py000066400000000000000000000004131466160177600313760ustar00rootroot00000000000000"""Binary Sensor value for The Thinks Network client.""" from .base import TTNBaseValue class TTNBinarySensorValue(TTNBaseValue): """Sensor of type bool.""" @property def value(self) -> bool: """the value itself.""" return self._value angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/device_tracker.py000066400000000000000000000016031466160177600314750ustar00rootroot00000000000000"""Device Tracker value for The Thinks Network client.""" from .base import TTNBaseValue class TTNDeviceTrackerValue(TTNBaseValue): """Sensor of type gps.""" def __init__(self, uplink: dict, field_id: str, value) -> None: super().__init__(uplink, field_id, value) assert "latitude" in self.value assert "longitude" in self.value @property def value(self) -> dict: """the value itself.""" return self._value @property def latitude(self) -> float: """Return latitude value of the device.""" return self.value["latitude"] @property def longitude(self) -> float: """Return longitude value of the device.""" return self.value["longitude"] @property def altitude(self) -> float | None: """Return altitude value of the device.""" return self.value.get("altitude", None) angelnu-thethingsnetwork_python_client-5319044/src/ttn_client/values/sensor.py000066400000000000000000000004301466160177600300310ustar00rootroot00000000000000"""Sensor value for The Thinks Network client.""" from .base import TTNBaseValue class TTNSensorValue(TTNBaseValue): """Sensor of type str, int or float.""" @property def value(self) -> str | int | float: """the value itself.""" return self._value angelnu-thethingsnetwork_python_client-5319044/tests/000077500000000000000000000000001466160177600230625ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/tests/conftest.py000066400000000000000000000026001466160177600252570ustar00rootroot00000000000000"""Fixtures.""" import json from unittest.mock import patch import pytest import ttn_client @pytest.fixture def dummy_client(): """Test a basic connection to TTN.""" client = ttn_client.TTNClient( hostname="eu1.cloud.thethings.network", application_id="home-assistant-casa", access_key="NNSXS.dummy", ) assert client is not None return client class MockContent: """Mock ahttp response content.""" def __init__(self, data): self._content = data def __aiter__(self): return self async def __anext__(self): if self._content: content = self._content self._content = None return content else: raise StopAsyncIteration class MockResponse: """Mock ahttp response.""" def __init__(self, data, status, reason): self.content = MockContent(data) self.status = status self.reason = reason async def __aexit__(self, exc_type, exc, tb): pass async def __aenter__(self): return self @pytest.fixture def mock_aiohttp_client_session_get(): """Patch ahttp to respond with given content and status.""" def mock_get(data, status): resp = MockResponse(json.dumps(data), status, reason=None) return patch("ttn_client.client.aiohttp.ClientSession.get", return_value=resp) return mock_get angelnu-thethingsnetwork_python_client-5319044/tests/parsers/000077500000000000000000000000001466160177600245415ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/tests/parsers/conftest.py000066400000000000000000000011241466160177600267360ustar00rootroot00000000000000"""Fixtures.""" import json import pathlib import pytest def json_config(request, test_file): file = pathlib.Path(request.node.fspath.strpath) config = file.parent.joinpath("test_data", test_file) with config.open() as fp: return json.load(fp) @pytest.fixture def default_valid(request): return json_config(request, "default_valid.json") @pytest.fixture def default_no_decoded_payload(request): return json_config(request, "default_no_decoded_payload.json") @pytest.fixture def sensecap_valid(request): return json_config(request, "sensecap_valid.json") angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_data/000077500000000000000000000000001466160177600265115ustar00rootroot00000000000000default_no_decoded_payload.json000066400000000000000000000055121466160177600346300ustar00rootroot00000000000000angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_data{ "name": "as.up.data.forward", "time": "2024-07-06T09:19:21.385361320Z", "identifiers": [ { "device_ids": { "device_id": "distance-03", "application_ids": { "application_id": "home-assistant-casa" }, "dev_eui": "22D11357891CB769", "join_eui": "70B3D57ED002E4B5", "dev_addr": "260B7500" } } ], "data": { "@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp", "end_device_ids": { "device_id": "distance-03", "application_ids": { "application_id": "home-assistant-casa" }, "dev_eui": "22D11357891CB769", "join_eui": "70B3D57ED002E4B5", "dev_addr": "260B7500" }, "correlation_ids": [ "gs:uplink:01J23NEGJP1XSAFZQDQVD01HY8" ], "received_at": "2024-07-06T09:19:21.381960868Z", "uplink_message": { "session_key_id": "AZCHQat2VmAxX4ZK0gWZPw==", "f_port": 1, "f_cnt": 41, "frm_payload": "AQAIAQAIAwIBNilnAPYqAgJYKwIPYCxlB9g=", "decoded_payload_warnings": [ "Cayene" ], "rx_metadata": [ { "gateway_ids": { "gateway_id": "eui-a840411dbd104150", "eui": "A840411DBD104150" }, "time": "2024-07-06T09:19:21.154471Z", "timestamp": 556187027, "rssi": -54, "channel_rssi": -54, "snr": 9, "location": { "latitude": 48.76826575, "longitude": 9.1596689, "altitude": 310, "source": "SOURCE_REGISTRY" }, "uplink_token": "CiIKIAoUZXVpLWE4NDA0MTFkYmQxMDQxNTASCKhAQR29EEFQEJP7mokCGgsImZiktAYQ8prtUiC4jNL6l/2sAQ==", "received_at": "2024-07-06T09:19:21.150027496Z" } ], "settings": { "data_rate": { "lora": { "bandwidth": 125000, "spreading_factor": 7, "coding_rate": "4/5" } }, "frequency": "868100000", "timestamp": 556187027, "time": "2024-07-06T09:19:21.154471Z" }, "received_at": "2024-07-06T09:19:21.175051417Z", "consumed_airtime": "0.082176s", "network_ids": { "net_id": "000013", "ns_id": "EC656E0000000181", "tenant_id": "ttn", "cluster_id": "eu1", "cluster_address": "eu1.cloud.thethings.network" } } }, "correlation_ids": [ "gs:uplink:01J23NEGJP1XSAFZQDQVD01HY8" ], "origin": "ip-10-100-12-74.eu-west-1.compute.internal", "context": { "tenant-id": "CgN0dG4=" }, "visibility": { "rights": [ "RIGHT_APPLICATION_TRAFFIC_READ" ] }, "unique_id": "01J23NEGS9EGGPDDNWV4EDSYRK" }angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_data/default_valid.json000066400000000000000000000073641466160177600322210ustar00rootroot00000000000000{ "name": "as.up.data.forward", "time": "2024-07-06T09:19:21.385361320Z", "identifiers": [ { "device_ids": { "device_id": "distance-03", "application_ids": { "application_id": "home-assistant-casa" }, "dev_eui": "22D11357891CB769", "join_eui": "70B3D57ED002E4B5", "dev_addr": "260B7500" } } ], "data": { "@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp", "end_device_ids": { "device_id": "distance-03", "application_ids": { "application_id": "home-assistant-casa" }, "dev_eui": "22D11357891CB769", "join_eui": "70B3D57ED002E4B5", "dev_addr": "260B7500" }, "correlation_ids": [ "gs:uplink:01J23NEGJP1XSAFZQDQVD01HY8" ], "received_at": "2024-07-06T09:19:21.381960868Z", "uplink_message": { "session_key_id": "AZCHQat2VmAxX4ZK0gWZPw==", "f_port": 1, "f_cnt": 41, "frm_payload": "AQAIAQAIAwIBNilnAPYqAgJYKwIPYCxlB9g=", "decoded_payload": { "accelerometer_77": { "x": 1, "y": 0.5, "z": 9.8 }, "analog_in_3": 3.1, "analog_in_42": 6, "analog_in_43": 39.36, "boolean_1": true, "digital_in_1": 8, "gps_34": { "latitude": 48.76826575, "longitude": 9.1596689, "altitude": 310 }, "illuminance_44": 2008, "raw": [ 1, 0, 8, 1, 0, 8, 3, 2, 1, 54, 41, 103, 0, 246, 42, 2, 2, 88, 43, 2, 15, 96, 44, 101, 7, 216 ], "temperature_41": 24.6 }, "decoded_payload_warnings": [ "Cayene" ], "rx_metadata": [ { "gateway_ids": { "gateway_id": "eui-a840411dbd104150", "eui": "A840411DBD104150" }, "time": "2024-07-06T09:19:21.154471Z", "timestamp": 556187027, "rssi": -54, "channel_rssi": -54, "snr": 9, "location": { "latitude": 48.76826575, "longitude": 9.1596689, "altitude": 310, "source": "SOURCE_REGISTRY" }, "uplink_token": "CiIKIAoUZXVpLWE4NDA0MTFkYmQxMDQxNTASCKhAQR29EEFQEJP7mokCGgsImZiktAYQ8prtUiC4jNL6l/2sAQ==", "received_at": "2024-07-06T09:19:21.150027496Z" } ], "settings": { "data_rate": { "lora": { "bandwidth": 125000, "spreading_factor": 7, "coding_rate": "4/5" } }, "frequency": "868100000", "timestamp": 556187027, "time": "2024-07-06T09:19:21.154471Z" }, "received_at": "2024-07-06T09:19:21.175051417Z", "consumed_airtime": "0.082176s", "network_ids": { "net_id": "000013", "ns_id": "EC656E0000000181", "tenant_id": "ttn", "cluster_id": "eu1", "cluster_address": "eu1.cloud.thethings.network" } } }, "correlation_ids": [ "gs:uplink:01J23NEGJP1XSAFZQDQVD01HY8" ], "origin": "ip-10-100-12-74.eu-west-1.compute.internal", "context": { "tenant-id": "CgN0dG4=" }, "visibility": { "rights": [ "RIGHT_APPLICATION_TRAFFIC_READ" ] }, "unique_id": "01J23NEGS9EGGPDDNWV4EDSYRK" }angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_data/sensecap_valid.json000066400000000000000000000112031466160177600323610ustar00rootroot00000000000000{ "name": "as.up.data.forward", "time": "2024-07-05T16:16:30.901957966Z", "identifiers": [ { "device_ids": { "device_id": "eui-2cf7f1c044300279", "application_ids": { "application_id": "elji-lorawan-1" }, "dev_eui": "2xxxxxx044300279", "join_eui": "8xxxxxx000000009", "dev_addr": "260BA448" } } ], "data": { "***@***.***": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp", "end_device_ids": { "device_id": "eui-2cf7f1c044300279", "application_ids": { "application_id": "elji-lorawan-1" }, "dev_eui": "2xxxxxx044300279", "join_eui": "8xxxxxx000000009", "dev_addr": "260BA448" }, "correlation_ids": [ "gs:uplink:01J21TXMF4DAS631TSWK0WZF8V" ], "received_at": "2024-07-05T16:16:30.899243333Z", "uplink_message": { "session_key_id": "AZB4Ym7KD33ulfRwvqmLdg==", "f_port": 3, "f_cnt": 642, "frm_payload": "AQCFTQAAD0cAABUCAH8AAAAAJu4DZA==", "decoded_payload": { "err": 0, "messages": [ { "measurementId": "4097", "measurementValue": 13.3, "type": "Air Temperature" }, { "measurementId": "4098", "measurementValue": 77, "type": "Air Humidity" }, { "measurementId": "4099", "measurementValue": 3911, "type": "Light Intensity" }, { "measurementId": "4190", "measurementValue": 0, "type": "UV Index" }, { "measurementId": "4105", "measurementValue": 2.1, "type": "Wind Speed" }, { "measurementId": "4104", "measurementValue": 127, "type": "Wind Direction Sensor" }, { "measurementId": "4113", "measurementValue": 0, "type": "Rain Gauge" }, { "measurementId": "4101", "measurementValue": 99660, "type": "Barometric Pressure" }, { "Battery(%)": 100 } ], "payload": "0100854D00000F4700001502007F0000000026EE0364", "valid": true }, "rx_metadata": [ { "gateway_ids": { "gateway_id": "eui-24e124fffef9a166", "eui": "24E124FFFEF9A166" }, "time": "2024-07-05T16:16:28.294Z", "timestamp": 4045136893, "rssi": -73, "channel_rssi": -73, "snr": 14.5, "frequency_offset": "611", "location": { "latitude": 58.013897934237576, "longitude": 11.584006360127455, "altitude": 12, "source": "SOURCE_REGISTRY" }, "uplink_token": "...", "gps_time": "2024-07-05T16:16:28.294Z", "received_at": "2024-07-05T16:16:30.691530054Z" } ], "settings": { "data_rate": { "lora": { "bandwidth": 125000, "spreading_factor": 7, "coding_rate": "4/5" } }, "frequency": "868100000", "timestamp": 4045136893, "time": "2024-07-05T16:16:28.294Z" }, "received_at": "2024-07-05T16:16:30.693554271Z", "confirmed": true, "consumed_airtime": "0.077056s", "locations": { "user": { "latitude": 58.01392692747555, "longitude": 11.5839771760278, "altitude": 12, "source": "SOURCE_REGISTRY" } }, "version_ids": { "brand_id": "sensecap", "model_id": "sensecaps2120-8-in-1", "hardware_version": "1.0", "firmware_version": "1.0", "band_id": "EU_863_870" }, "network_ids": { "net_id": "000013", "ns_id": "EC656E0000000181", "tenant_id": "ttn", "cluster_id": "eu1", "cluster_address": "eu1.cloud.thethings.network" } } }, "correlation_ids": [ "gs:uplink:01J21TXMF4DAS631TSWK0WZF8V" ], "origin": "ip-10-100-12-74.eu-west-1.compute.internal", "context": { "tenant-id": "CgN0dG4=" }, "visibility": { "rights": [ "RIGHT_APPLICATION_TRAFFIC_READ" ] }, "unique_id": "01J21TXMNN6FDH9BYE2P8Y8GZN" }angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_default.py000066400000000000000000000105111466160177600275740ustar00rootroot00000000000000"""Test default parser.""" import datetime import pytest from ttn_client import ( TTNSensorValue, TTNBaseValue, TTNBinarySensorValue, TTNDeviceTrackerValue, ) from ttn_client.parsers import ttn_parse def test_default_valid(default_valid): """Test valid default msg.""" uplink_data = default_valid["data"] ttn_values = ttn_parse(uplink_data) # Test TTNBaseValue assert isinstance(ttn_values["analog_in_3"], TTNBaseValue) sensor_value = ttn_values["analog_in_3"] base_value = super(TTNSensorValue, sensor_value) assert str(sensor_value) == "TTN_Value(3.1)" assert base_value.value == "3.1" assert base_value.uplink == uplink_data assert base_value.field_id == "analog_in_3" assert base_value.received_at == datetime.datetime( 2024, 7, 6, 9, 19, 21, 381960, tzinfo=datetime.timezone.utc ) assert base_value.device_id == "distance-03" # Test TTNSensorValue - float sensor_value = ttn_values["analog_in_3"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, float) assert sensor_value.value == 3.1 # Test TTNSensorValue - int sensor_value = ttn_values["digital_in_1"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, int) assert sensor_value.value == 8 # Test TTNSensorValue - str sensor_value = ttn_values["raw"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, str) assert sensor_value.value == ( "[1, 0, 8, 1, 0, 8, 3, 2, 1, 54, 41, 103, 0, 246," " 42, 2, 2, 88, 43, 2, 15, 96, 44, 101, 7, 216]" ) # Test TTNBinarySensorValue sensor_value = ttn_values["boolean_1"] assert isinstance(sensor_value, TTNBinarySensorValue) assert isinstance(sensor_value.value, bool) assert sensor_value.value # Test TTNDeviceTrackerValue sensor_value = ttn_values["gps_34"] assert isinstance(sensor_value, TTNDeviceTrackerValue) assert isinstance(sensor_value.value, dict) assert sensor_value.value == { "altitude": 310, "latitude": 48.76826575, "longitude": 9.1596689, } assert sensor_value.altitude == 310 assert sensor_value.latitude == 48.76826575 assert sensor_value.longitude == 9.1596689 # Test object sensor_value = ttn_values["accelerometer_77_x"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, int) assert sensor_value.value == 1 sensor_value = ttn_values["accelerometer_77_y"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, float) assert sensor_value.value == 0.5 sensor_value = ttn_values["accelerometer_77_z"] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, float) assert sensor_value.value == 9.8 # Test all other fields were parsed assert isinstance(ttn_values["analog_in_42"], TTNSensorValue) assert isinstance(ttn_values["analog_in_43"], TTNSensorValue) assert isinstance(ttn_values["digital_in_1"], TTNSensorValue) assert isinstance(ttn_values["illuminance_44"], TTNSensorValue) assert isinstance(ttn_values["raw"], TTNSensorValue) assert isinstance(ttn_values["temperature_41"], TTNSensorValue) def test_default_no_decoded_payload(default_no_decoded_payload): """Test msg without decoded payload.""" ttn_values = ttn_parse(default_no_decoded_payload["data"]) assert not ttn_values def test_default_none_value(default_valid): """Test msg with None field value.""" ttn_values = ttn_parse(default_valid["data"]) uplink_with_none_value = default_valid["data"] uplink_with_none_value["uplink_message"]["decoded_payload"]["digital_in_1"] = None ttn_values_with_none = ttn_parse(uplink_with_none_value) assert len(ttn_values_with_none) == len(ttn_values) - 1 def test_default_unexpected_value(default_valid): """Test msg with unexpected field vault.""" uplink_with_unexpected_value = default_valid["data"] uplink_with_unexpected_value["uplink_message"]["decoded_payload"][ "digital_in_1" ] = object with pytest.raises(TypeError) as e_info: ttn_parse(uplink_with_unexpected_value) assert ( str(e_info.value) == "Unexpected type for value: " ) angelnu-thethingsnetwork_python_client-5319044/tests/parsers/test_sensecap.py000066400000000000000000000055331466160177600277610ustar00rootroot00000000000000"""Test sensecap parser.""" import datetime from ttn_client import TTNSensorValue, TTNBaseValue from ttn_client.parsers import ttn_parse def test_sensecap_valid(sensecap_valid): """Test valid sensecap msg.""" uplink_data = sensecap_valid["data"] ttn_values = ttn_parse(uplink_data) # Test TTNBaseValue fieldId = "Air_Temperature_4097" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNBaseValue) base_value = super(TTNSensorValue, sensor_value) assert str(sensor_value) == "TTN_Value(13.3)" assert base_value.value == "13.3" assert base_value.uplink == uplink_data assert base_value.field_id == fieldId assert base_value.received_at == datetime.datetime( 2024, 7, 5, 16, 16, 30, 899243, tzinfo=datetime.timezone.utc ) assert base_value.device_id == "eui-2cf7f1c044300279" # Test TTNSensorValue - float fieldId = "Air_Temperature_4097" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, float) assert sensor_value.value == 13.3 # Test TTNSensorValue - int fieldId = "Air_Humidity_4098" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, int) assert sensor_value.value == 77 # Test TTNSensorValue - Battery fieldId = "battery" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, int) assert sensor_value.value == 100 # Test TTNSensorValue - err fieldId = "err" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, int) assert sensor_value.value == 0 # Test TTNSensorValue - payload fieldId = "payload" sensor_value = ttn_values[fieldId] assert isinstance(sensor_value, TTNSensorValue) assert isinstance(sensor_value.value, str) assert sensor_value.value == "0100854D00000F4700001502007F0000000026EE0364" def test_sensecap_no_decoded_payload(sensecap_valid): """Test sensecap without decoder""" uplink_data = sensecap_valid["data"] del uplink_data["uplink_message"]["decoded_payload"] ttn_values = ttn_parse(uplink_data) assert len(ttn_values) == 0 def test_sensecap_invalid_payload(sensecap_valid): """Test invalid sensecap msg.""" uplink_data = sensecap_valid["data"] uplink_data["uplink_message"]["decoded_payload"]["valid"] = False ttn_values = ttn_parse(uplink_data) assert len(ttn_values) == 0 def test_sensecap_no_messages(sensecap_valid): """Test sensecap without decoder""" uplink_data = sensecap_valid["data"] del uplink_data["uplink_message"]["decoded_payload"]["messages"] ttn_values = ttn_parse(uplink_data) assert len(ttn_values) == 2 angelnu-thethingsnetwork_python_client-5319044/tests/test_client.py000066400000000000000000000023541466160177600257550ustar00rootroot00000000000000"""Test TTN client.""" import pytest import ttn_client pytest_plugins = "pytest_asyncio" @pytest.mark.asyncio async def test_mocked_connection(dummy_client, mock_aiohttp_client_session_get): """Test client with mocked data.""" with mock_aiohttp_client_session_get( {"result": {"end_device_ids": {"device_id": "dummy"}, "uplink_message": {}}}, 200, ): await dummy_client.fetch_data() with mock_aiohttp_client_session_get({}, 200): await dummy_client.fetch_data() @pytest.mark.asyncio async def test_connection_auth_error(dummy_client): """Test that dummy credentials fail.""" with pytest.raises(ttn_client.TTNAuthError): await dummy_client.fetch_data() @pytest.mark.asyncio async def test_invalid_get_status(dummy_client, mock_aiohttp_client_session_get): """Test client with mocked data.""" with mock_aiohttp_client_session_get({}, 500): with pytest.raises(RuntimeError): await dummy_client.fetch_data() @pytest.mark.asyncio async def test_missing_result(dummy_client, mock_aiohttp_client_session_get): """Test client with mocked data.""" with mock_aiohttp_client_session_get({"missing_result": {}}, 200): await dummy_client.fetch_data() angelnu-thethingsnetwork_python_client-5319044/tox.ini000066400000000000000000000017001466160177600232310ustar00rootroot00000000000000[tox] envlist = py311, py312, py313, lint, mypy, coverage skip_missing_interpreters = True [gh-actions] python = 3.11: py311 3.12: py312 3.13: py313, lint, mypy, coverage [testenv] commands = pytest {posargs} deps = -rrequirements.txt -rrequirements_test.txt [testenv:coverage] description = generate coverage report commands = pytest --timeout=30 --cov=tests --cov=ttn_client --cov-report=term-missing --cov-report=xml --cov-report=html --cov-context=test --cov-fail-under=100 {posargs} [coverage:paths] source = src/ .tox/*/lib/python*/site-packages/ [testenv:lint] basepython = python3 ignore_errors = True commands = black --check ./ ruff check src scripts tests pylint src deps = -rrequirements.txt -rrequirements_lint.txt -rrequirements_test.txt [testenv:mypy] basepython = python3 ignore_errors = True commands = mypy src deps = -rrequirements.txt -rrequirements_lint.txt [flake8] max-line-length = 120