pax_global_header00006660000000000000000000000064152612547360014525gustar00rootroot0000000000000052 comment=9b796dbe2fab6b3e49a0a0fef140f8da7748cf54 home-assistant-libs-python-otbr-api-9b796db/000077500000000000000000000000001526125473600210755ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/.github/000077500000000000000000000000001526125473600224355ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/.github/dependabot.yml000066400000000000000000000004101526125473600252600ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: pip directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 home-assistant-libs-python-otbr-api-9b796db/.github/release-drafter.yml000066400000000000000000000002221526125473600262210ustar00rootroot00000000000000categories: - title: "⬆️ Dependencies" collapse-after: 1 labels: - "dependencies" template: | ## What's Changed $CHANGES home-assistant-libs-python-otbr-api-9b796db/.github/workflows/000077500000000000000000000000001526125473600244725ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/.github/workflows/pythonpublish.yml000066400000000000000000000015021526125473600301230ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: - published jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 - name: Set up Python uses: actions/setup-python@v7.0.0 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* home-assistant-libs-python-otbr-api-9b796db/.github/workflows/release-drafter.yml000066400000000000000000000005101526125473600302560ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main jobs: update_release_draft: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v7.9.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} home-assistant-libs-python-otbr-api-9b796db/.github/workflows/test.yml000066400000000000000000000021121526125473600261700ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 - name: Set up Python 3.11 uses: actions/setup-python@v7.0.0 with: python-version: 3.11 - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - name: Lint with flake8 run: | flake8 python_otbr_api tests - name: Check formatting with black run: | black python_otbr_api tests --check --diff - name: Lint with mypy run: | mypy python_otbr_api tests - name: Lint with pylint run: | pylint python_otbr_api tests - name: Run tests run: | pytest tests home-assistant-libs-python-otbr-api-9b796db/.gitignore000066400000000000000000000002151526125473600230630ustar00rootroot00000000000000.DS_Store .idea *.log tmp/ *.py[cod] *.egg htmlcov .projectile .venv/ venv/ .mypy_cache/ *.egg-info/ # Visual Studio Code .vscode/* dist home-assistant-libs-python-otbr-api-9b796db/AI_POLICY.md000066400000000000000000000074711526125473600230000ustar00rootroot00000000000000# Open Home Foundation - AI Policy We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects. Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted. ## Autonomous agents **We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates. ## Communication on issues, pull requests, and code reviews We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output. If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy. **Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own. If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets. ## Non-native English speakers We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable. ## Code and documentation contributions AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI. All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review. ## Our use of AI Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct. If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer. ## Enforcement Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss. --- The canonical version of this policy is published at . In case of differences, the published version applies. home-assistant-libs-python-otbr-api-9b796db/LICENSE000066400000000000000000000020501526125473600220770ustar00rootroot00000000000000MIT License Copyright (c) 2023 ESPHome 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. home-assistant-libs-python-otbr-api-9b796db/README.md000066400000000000000000000001141526125473600223500ustar00rootroot00000000000000# Python OTBR API Python package to interact with an OTBR via its REST API home-assistant-libs-python-otbr-api-9b796db/pyproject.toml000066400000000000000000000016501526125473600240130ustar00rootroot00000000000000[build-system] requires = ["setuptools>=65.6"] build-backend = "setuptools.build_meta" [project] name = "python-otbr-api" version = "3.1.0" license = {text = "MIT"} description = "API to interact with an OTBR via its REST API" readme = "README.md" authors = [ {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} ] requires-python = ">=3.11.0" dependencies = [ "aiohttp", "bitstruct", "cryptography", "typing_extensions", "voluptuous", ] [project.urls] "Homepage" = "https://github.com/home-assistant-libs/python-otbr-api" [tool.pylint.BASIC] class-const-naming-style = "any" good-names = [ "c", "i", ] [tool.pytest.ini_options] asyncio_mode = "auto" [tool.setuptools] platforms = ["any"] zip-safe = true include-package-data = true [tool.setuptools.packages.find] include = ["python_otbr_api*"] [tool.setuptools.package-data] "*" = ["py.typed"] home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/000077500000000000000000000000001526125473600242755ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/__init__.py000066400000000000000000000755131526125473600264210ustar00rootroot00000000000000"""API to interact with the Open Thread Border Router REST API.""" from __future__ import annotations from enum import Enum, auto from http import HTTPStatus from typing import Any import json import logging import aiohttp import voluptuous as vol # type: ignore[import] from .models import ( ActiveDataSet, EphemeralKeyActivationResult, EphemeralKeyState, EphemeralKeyStatus, PendingDataSet, Timestamp, ) # 5 minutes as recommended by # https://github.com/openthread/openthread/discussions/8567#discussioncomment-4468920 PENDING_DATASET_DELAY_TIMER = 5 * 60 * 1000 # How long a pending dataset write may take. A border router that registers # the dataset with the Thread leader (ot-br-posix#3582) waits up to 30 s for # the leader's verdict before it answers; a client that gives up sooner can # never receive that verdict. A longer client timeout is honoured as it is. PENDING_DATASET_TIMEOUT = 40 _LOGGER = logging.getLogger(__name__) # OTBR flipped the REST API from PascalCase to camelCase in ot-br-posix # PR #2514 (Sept 2025). The models speak camelCase internally; this table # translates both directions at the HTTP boundary for legacy servers. _CAMEL_TO_PASCAL: dict[str, str] = { # Timestamp "authoritative": "Authoritative", "seconds": "Seconds", "ticks": "Ticks", # SecurityPolicy "autonomousEnrollment": "AutonomousEnrollment", "commercialCommissioning": "CommercialCommissioning", "externalCommissioning": "ExternalCommissioning", "nativeCommissioning": "NativeCommissioning", "networkKeyProvisioning": "NetworkKeyProvisioning", "nonCcmRouters": "NonCcmRouters", "obtainNetworkKey": "ObtainNetworkKey", "rotationTime": "RotationTime", "routers": "Routers", "tobleLink": "TobleLink", # ActiveDataSet "activeTimestamp": "ActiveTimestamp", "channelMask": "ChannelMask", "channel": "Channel", "extPanId": "ExtPanId", "meshLocalPrefix": "MeshLocalPrefix", "networkKey": "NetworkKey", "networkName": "NetworkName", "panId": "PanId", "pskc": "PSKc", "securityPolicy": "SecurityPolicy", # PendingDataSet "activeDataset": "ActiveDataset", "delay": "Delay", "pendingTimestamp": "PendingTimestamp", } _PASCAL_TO_CAMEL: dict[str, str] = {v: k for k, v in _CAMEL_TO_PASCAL.items()} class KeyFormat(Enum): """JSON key format used by the OTBR REST API. PASCAL_CASE is the pre-Sept-2025 wire format (ot-br-posix pre-#2514): writes are rewritten to PascalCase. CAMEL_CASE covers every post-flip server, including the Sept-2025..April-2026 window where some keys (e.g. `Routers` in SecurityPolicy) still came back PascalCase — reads always run through the PascalCase normalization table so those stragglers are picked up transparently. """ CAMEL_CASE = "camel" PASCAL_CASE = "pascal" class _UndefinedType(Enum): """Singleton sentinel distinguishing "not probed yet" from a probed None.""" UNDEFINED = auto() _UNDEFINED = _UndefinedType.UNDEFINED class OTBRError(Exception): """Raised on error.""" class FactoryResetNotSupportedError(OTBRError): """Raised when attempting to factory reset a router which does not support it.""" class GetBorderAgentIdNotSupportedError(OTBRError): """Raised when attempting to get the agent ID if the router does not support it.""" class ThreadNetworkActiveError(OTBRError): """Raised on attempts to modify the active dataset when thread network is active.""" class EphemeralKeyNotSupportedError(OTBRError): """Raised when the router does not support the ephemeral key (ePSKc) feature.""" class EphemeralKeyConflictError(OTBRError): """Raised when activating an ephemeral key while the feature is disabled or a key is already active.""" class PendingDatasetConflictError(OTBRError): """Raised when a pending dataset write is refused because one is in place. Its own type rather than a plain OTBRError: the caller's next step is different from the one a transport or protocol failure calls for, since nothing was written and the state that made the write wrong is readable. """ class PendingDatasetRejectedError(OTBRError): """Raised when the border router rejected a pending dataset. A border router that registers a pending dataset with the Thread leader (https://github.com/openthread/ot-br-posix/pull/3582) answers 409 when nothing new was registered. The reason it gives, if any, is in `reason`: - "rejected by leader": the leader refused the dataset, for instance because its pending or active timestamp does not advance. The caller has to change what it asked for. - no reason (`reason` is empty): the router is not attached to a network, or an earlier registration -- a previous write, or the router's own -- is still being answered; the router does not say which. That keeps it busy until the exchange with the leader finalizes, which can take up to about 90 seconds, well past the router's 30 second wait. An earlier write may yet take effect: retry the same dataset later, rather than change it. A 409 for an exchange that was aborted after the dataset went out is not a rejection; it is raised as PendingDatasetOutcomeUnknownError. """ def __init__(self, reason: str) -> None: """Initialize with the reason the border router gave, if any.""" super().__init__( f"the border router rejected the pending dataset: {reason}" if reason else "the border router rejected the pending dataset" ) self.reason = reason class PendingDatasetOutcomeUnknownError(OTBRError): """Raised when a pending dataset write got no answer. A border router that registers the dataset with the Thread leader (https://github.com/openthread/ot-br-posix/pull/3582) answers 504 when the leader does not respond in time, and 409 "no longer attached" when the exchange with the leader was aborted after the dataset was sent (for instance because Thread stopped, or the router detached or changed its address), and a client can time out waiting for an answer itself. In each case the dataset may or may not have been accepted: a caller must treat the write as possibly in flight, the way it would a dropped connection, not as refused. """ # The detail a border router gives with a 409 when the exchange with the # leader was aborted after the dataset was sent (OT_ERROR_ABORT): Thread # stopped, the router detached or changed its address, or the leader sent a # CoAP reset. The leader may have accepted the dataset before the answer was # lost, so this is an unknown outcome rather than a rejection. _PENDING_DATASET_ABORTED = "no longer attached" def _rewrite_keys(data: Any, mapping: dict[str, str]) -> Any: """Recursively rename dict keys according to mapping; pass through others.""" if not isinstance(data, dict): return data return {mapping.get(k, k): _rewrite_keys(v, mapping) for k, v in data.items()} async def _error_reason(response: aiohttp.ClientResponse) -> str: """Return the reason the border router gave for an error, if any. The REST API answers with an RFC 7807 style JSON error object: `title` is the HTTP status phrase and the reason, if there is one, is the `detail`. A plain body is taken as it is. """ text = (await response.text()).strip() try: error = json.loads(text) except ValueError: return text if not isinstance(error, dict): return text return str(error.get("detail") or "") async def _raise_for_pending_dataset_status(response: aiohttp.ClientResponse) -> None: """Raise for the answers the two pending dataset writers share. Both write to the same endpoint and read its verdict the same way; one place keeps them from drifting apart. """ if response.status == HTTPStatus.CONFLICT: reason = await _error_reason(response) if reason == _PENDING_DATASET_ABORTED: raise PendingDatasetOutcomeUnknownError( "the exchange with the leader was aborted before it answered" ) raise PendingDatasetRejectedError(reason) if response.status == HTTPStatus.PRECONDITION_FAILED: raise PendingDatasetConflictError("a pending dataset is already in place") if response.status == HTTPStatus.GATEWAY_TIMEOUT: raise PendingDatasetOutcomeUnknownError("no response from the leader") if response.status not in (HTTPStatus.CREATED, HTTPStatus.OK): raise OTBRError(f"unexpected http status {response.status}") class OTBR: # pylint: disable=too-many-public-methods """Class to interact with the Open Thread Border Router REST API.""" def __init__( self, url: str, session: aiohttp.ClientSession, timeout: int = 10, *, key_format: KeyFormat | None = None, ) -> None: """Initialize.""" self._session = session self._url = url self._timeout = timeout self._key_format = key_format self._api_version: str | None | _UndefinedType = _UNDEFINED async def _maybe_detect_key_format(self) -> None: """Probe the OTBR REST API to determine the JSON key format.""" if self._key_format is not None: return response = await self._session.get( f"{self._url}/api/actions", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.OK: self._key_format = KeyFormat.CAMEL_CASE elif response.status == HTTPStatus.NOT_FOUND: self._key_format = KeyFormat.PASCAL_CASE else: raise OTBRError( f"could not detect OTBR version: unexpected http status " f"{response.status}" ) _LOGGER.debug("Detected OTBR JSON key format: %s", self._key_format) def _encode(self, data: dict) -> dict: """Rewrite a camelCase body to the detected wire format.""" if self._key_format == KeyFormat.PASCAL_CASE: return _rewrite_keys(data, _CAMEL_TO_PASCAL) return data def _decode(self, data: dict) -> dict: """Normalize a wire response body to camelCase.""" # Runs unconditionally: camelCase keys aren't in the table so they pass through # untouched, while any PascalCase stragglers (full legacy or transition-era # leftovers like `Routers`) get fixed. return _rewrite_keys(data, _PASCAL_TO_CAMEL) async def factory_reset(self) -> None: """Factory reset the router.""" await self._maybe_detect_key_format() response = await self._session.delete( f"{self._url}/node", timeout=aiohttp.ClientTimeout(total=10), ) if response.status == HTTPStatus.METHOD_NOT_ALLOWED: raise FactoryResetNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def get_border_agent_id(self) -> bytes: """Get the border agent ID.""" await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/ba-id", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise GetBorderAgentIdNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return bytes.fromhex(await response.json()) except ValueError as exc: raise OTBRError("unexpected API response") from exc async def set_enabled(self, enabled: bool) -> None: """Enable or disable the router.""" await self._maybe_detect_key_format() response = await self._session.put( f"{self._url}/node/state", json="enable" if enabled else "disable", timeout=aiohttp.ClientTimeout(total=10), ) if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def get_active_dataset(self) -> ActiveDataSet | None: """Get current active operational dataset. Returns None if there is no active operational dataset. Raises if the http status is 400 or higher or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/dataset/active", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NO_CONTENT: return None if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return ActiveDataSet.from_json(self._decode(await response.json())) except (json.JSONDecodeError, vol.Error) as exc: raise OTBRError("unexpected API response") from exc async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None. Returns None if there is no active operational dataset. Raises if the http status is 400 or higher or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/dataset/active", headers={"Accept": "text/plain"}, timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NO_CONTENT: return None if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return bytes.fromhex(await response.text("ASCII")) except ValueError as exc: raise OTBRError("unexpected API response") from exc async def get_pending_dataset_tlvs(self) -> bytes | None: """Get current pending operational dataset in TLVS format, or None. Returns None if there is no pending operational dataset. Raises if the http status is 400 or higher or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/dataset/pending", headers={"Accept": "text/plain"}, timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NO_CONTENT: return None if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return bytes.fromhex(await response.text("ASCII")) except ValueError as exc: raise OTBRError("unexpected API response") from exc async def create_active_dataset(self, dataset: ActiveDataSet) -> None: """Create active operational dataset. The passed in ActiveDataSet does not need to be fully populated, any fields not set will be automatically set by the open thread border router. Raises if the http status is 400 or higher or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.put( f"{self._url}/node/dataset/active", json=self._encode(dataset.as_json()), timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.CONFLICT: raise ThreadNetworkActiveError if response.status not in (HTTPStatus.CREATED, HTTPStatus.OK): raise OTBRError(f"unexpected http status {response.status}") async def delete_active_dataset(self) -> None: """Delete active operational dataset.""" await self._maybe_detect_key_format() response = await self._session.delete( f"{self._url}/node/dataset/active", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.CONFLICT: raise ThreadNetworkActiveError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def _put_pending_dataset(self, **kwargs: Any) -> aiohttp.ClientResponse: """Send a pending dataset write, waiting for the leader's verdict. A timeout leaves the outcome unknown -- the request may have reached the router, which may have registered the dataset -- and is reported the way the router reports no answer from the leader. A timeout while still connecting is included on the safe side; the cause is chained for a caller that wants to tell them apart. """ try: return await self._session.put( f"{self._url}/node/dataset/pending", timeout=aiohttp.ClientTimeout( total=max(self._timeout, PENDING_DATASET_TIMEOUT) ), **kwargs, ) except TimeoutError as exc: raise PendingDatasetOutcomeUnknownError( "no answer from the border router" ) from exc async def create_pending_dataset(self, dataset: PendingDataSet) -> None: """Create pending operational dataset. The passed in PendingDataSet does not need to be fully populated, any fields not set will be automatically set by the open thread border router. A write while a pending dataset is in flight is refused outright, for the same reasons set_pending_dataset_tlvs refuses it: superseding one races the delay timer on every device that already holds the old dataset, so a late replacement can split the mesh, and it would also silently undo whatever the in-flight dataset was doing, such as a channel change. The check runs locally first, and again on the border router for one that honors If-None-Match on this endpoint (https://github.com/openthread/ot-br-posix/pull/3552), where it is atomic with the write; older border routers ignore the header. Raises PendingDatasetConflictError when either check refuses the write, PendingDatasetRejectedError when the border router rejected it, with its reason if it gave one, PendingDatasetOutcomeUnknownError when the write got no answer -- from the leader, or at all -- so it may or may not have taken effect, and OTBRError if the http status is 400 or higher for any other reason or the response is invalid. """ if await self.get_pending_dataset_tlvs() is not None: raise PendingDatasetConflictError("a pending dataset is already in place") await self._maybe_detect_key_format() response = await self._put_pending_dataset( json=self._encode(dataset.as_json()), headers={"If-None-Match": "*"} ) await _raise_for_pending_dataset_status(response) async def delete_pending_dataset(self) -> None: """Delete pending operational dataset.""" await self._maybe_detect_key_format() response = await self._session.delete( f"{self._url}/node/dataset/pending", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.CONFLICT: raise ThreadNetworkActiveError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def set_active_dataset_tlvs(self, dataset: bytes) -> None: """Set current active operational dataset. Raises if the http status is 400 or higher or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.put( f"{self._url}/node/dataset/active", data=dataset.hex(), headers={"Content-Type": "text/plain"}, timeout=aiohttp.ClientTimeout(total=10), ) if response.status == HTTPStatus.CONFLICT: raise ThreadNetworkActiveError if response.status not in (HTTPStatus.CREATED, HTTPStatus.OK): raise OTBRError(f"unexpected http status {response.status}") async def set_pending_dataset_tlvs(self, dataset: bytes) -> None: """Set the pending operational dataset, when none is in place yet. A write while a pending dataset is in flight is refused outright. Superseding one is never safe to do implicitly: the replacement races the delay timer on every device that already holds the old dataset, so a late replacement can split the mesh, and it would also silently undo whatever the in-flight dataset was doing, such as a channel change. A caller that finds the write refused should surface that to whoever asked for it, not retry. The check runs locally first, and again on the border router for one that honors If-None-Match on this endpoint (https://github.com/openthread/ot-br-posix/pull/3552), where it is atomic with the write; older border routers ignore the header. Raises PendingDatasetConflictError when either check refuses the write, PendingDatasetRejectedError when the border router rejected it, with its reason if it gave one, PendingDatasetOutcomeUnknownError when the write got no answer -- from the leader, or at all -- so it may or may not have taken effect, and OTBRError if the http status is 400 or higher for any other reason or the response is invalid. """ if await self.get_pending_dataset_tlvs() is not None: raise PendingDatasetConflictError("a pending dataset is already in place") await self._maybe_detect_key_format() response = await self._put_pending_dataset( data=dataset.hex(), headers={"Content-Type": "text/plain", "If-None-Match": "*"}, ) await _raise_for_pending_dataset_status(response) async def set_channel( self, channel: int, delay: int = PENDING_DATASET_DELAY_TIMER ) -> None: """Change the channel The channel is changed by creating a new pending dataset based on the active dataset. If a pending dataset is already in place, create_pending_dataset refuses the write and raises PendingDatasetConflictError: stamping from the active dataset alone would make the mesh silently ignore it while the router accepts the write, and superseding the pending dataset would race its delay timer on devices that miss the update. The other answers of create_pending_dataset propagate as well: PendingDatasetRejectedError means the border router refused it, and PendingDatasetOutcomeUnknownError means the change may be on its way, not that it failed. """ if not 11 <= channel <= 26: raise OTBRError(f"invalid channel {channel}") if not (dataset := await self.get_active_dataset()): raise OTBRError("router has no active dataset") if dataset.active_timestamp and dataset.active_timestamp.seconds is not None: dataset.active_timestamp.seconds += 1 else: dataset.active_timestamp = Timestamp(False, 1, 0) dataset.channel = channel pending_dataset = PendingDataSet(active_dataset=dataset, delay=delay) await self.create_pending_dataset(pending_dataset) async def get_extended_address(self) -> bytes: """Get extended address (EUI-64). Raises if the http status is not 200 or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/ext-address", headers={"Accept": "application/json"}, timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return bytes.fromhex(await response.json()) except ValueError as exc: raise OTBRError("unexpected API response") from exc async def get_ephemeral_key_enabled(self) -> bool: """Get whether the ephemeral key (ePSKc) feature is enabled. Raises EphemeralKeyNotSupportedError if the router does not support the ephemeral key feature. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/ba-epskc/state", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise EphemeralKeyNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return (await response.json()) == "enabled" except ValueError as exc: raise OTBRError("unexpected API response") from exc async def set_ephemeral_key_enabled(self, enabled: bool) -> None: """Enable or disable the ephemeral key (ePSKc) feature. The feature must be enabled before an ephemeral key can be activated. Raises EphemeralKeyNotSupportedError if the router does not support the ephemeral key feature. """ await self._maybe_detect_key_format() response = await self._session.put( f"{self._url}/node/ba-epskc/state", json="enable" if enabled else "disable", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise EphemeralKeyNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def get_ephemeral_key_status(self) -> EphemeralKeyStatus: """Get the status of the current ephemeral key (ePSKc) session. Raises EphemeralKeyNotSupportedError if the router does not support the ephemeral key feature. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/ba-epskc/key", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise EphemeralKeyNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: data = await response.json() return EphemeralKeyStatus( state=EphemeralKeyState(data["state"]), port=data["port"] ) except (ValueError, KeyError) as exc: raise OTBRError("unexpected API response") from exc async def activate_ephemeral_key( self, lifetime: int | None = None, port: int | None = None ) -> EphemeralKeyActivationResult: """Generate and activate an ephemeral key (ePSKc). Returns the generated 9-digit Thread Administration Passcode (TAP) and the UDP port the border agent is listening on for the ePSKc session. `lifetime` is the key lifetime in milliseconds and `port` the UDP port to use; both default to the router's own defaults when omitted. Raises EphemeralKeyNotSupportedError if the router does not support the ephemeral key feature, and EphemeralKeyConflictError if the feature is disabled or a key is already active. """ await self._maybe_detect_key_format() body: dict[str, int] = {} if lifetime is not None: body["lifetime"] = lifetime if port is not None: body["port"] = port response = await self._session.post( f"{self._url}/node/ba-epskc/key", json=body, timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise EphemeralKeyNotSupportedError if response.status == HTTPStatus.CONFLICT: raise EphemeralKeyConflictError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: data = await response.json() return EphemeralKeyActivationResult(tap=data["tap"], port=data["port"]) except (ValueError, KeyError) as exc: raise OTBRError("unexpected API response") from exc async def deactivate_ephemeral_key(self) -> None: """Deactivate the currently active ephemeral key (ePSKc), if any. Raises EphemeralKeyNotSupportedError if the router does not support the ephemeral key feature. """ await self._maybe_detect_key_format() response = await self._session.delete( f"{self._url}/node/ba-epskc/key", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: raise EphemeralKeyNotSupportedError if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") async def get_coprocessor_version(self) -> str: """Get the coprocessor firmware version. Raises if the http status is not 200 or if the response is invalid. """ await self._maybe_detect_key_format() response = await self._session.get( f"{self._url}/node/coprocessor/version", headers={"Accept": "application/json"}, timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: return await response.json() except ValueError as exc: raise OTBRError("unexpected API response") from exc async def get_api_version(self) -> str | None: """Get the OTBR REST API's semantic version. Reads /.well-known/thread/br-rest (ot-br-posix PR #3330). Returns None on routers that don't expose it. Raises OTBRError if the endpoint exists but responds with an unexpected status or a malformed body. """ if self._api_version is not _UNDEFINED: return self._api_version response = await self._session.get( f"{self._url}/.well-known/thread/br-rest", timeout=aiohttp.ClientTimeout(total=self._timeout), ) if response.status == HTTPStatus.NOT_FOUND: self._api_version = None return None if response.status != HTTPStatus.OK: raise OTBRError(f"unexpected http status {response.status}") try: data = await response.json() api_version: str = data["api"]["version"] except (ValueError, KeyError, TypeError) as exc: raise OTBRError("unexpected API response") from exc self._api_version = api_version _LOGGER.debug("Detected OTBR REST API version: %s", api_version) return api_version home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/mdns.py000066400000000000000000000033741526125473600256170ustar00rootroot00000000000000"""Utility function to decode fields in _meshcop._udp.local. mDNS services. The implementation is based on the Open Thread implementation: https://github.com/openthread/ot-br-posix/blob/8a8b2411abcf68659c25bb97672bdd2e5e724dcc/src/border_agent/border_agent.cpp#L109 """ from dataclasses import dataclass from enum import IntEnum import bitstruct # type: ignore[import] from typing_extensions import Self class ConnectionMode(IntEnum): """Connection mode.""" DISABLED = 0 PSKC = 1 PSKD = 2 VENDOR = 3 X509 = 4 class ThreadInterfaceStatus(IntEnum): """Thread interface status.""" NOT_INITIALIZED = 0 INITIALIZED = 1 ACTIVE = 2 class Availability(IntEnum): """Availability.""" INFREQUENT = 0 HIGH = 1 STATE_BITMAP_FORMAT = "u23u1u1u2u2u3" @dataclass class StateBitmap: """State bitmap.""" connection_mode: ConnectionMode thread_interface_status: ThreadInterfaceStatus availability: Availability is_active: bool is_primary: bool @classmethod def from_bytes(cls, data: bytes) -> Self: """Decode from bytes.""" if len(data) != 4: raise ValueError("Incorrect length") ( padding, is_primary, is_active, availability, thread_if_status, connection_mode, ) = bitstruct.unpack(STATE_BITMAP_FORMAT, data) if padding != 0: raise ValueError(f"Could not decode '{data.hex}'") return cls( connection_mode=ConnectionMode(connection_mode), thread_interface_status=ThreadInterfaceStatus(thread_if_status), availability=Availability(availability), is_active=is_active, is_primary=is_primary, ) home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/models.py000066400000000000000000000220151526125473600261320ustar00rootroot00000000000000"""Data models.""" from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Any import voluptuous as vol # type: ignore[import] class EphemeralKeyState(Enum): """State of the border agent ephemeral key (ePSKc) session. Reported by the `/node/ba-epskc/key` endpoint. See otBorderAgentEphemeralKeyState in openthread/border_agent.h. """ DISABLED = "disabled" STOPPED = "stopped" STARTED = "started" CONNECTED = "connected" ACCEPTED = "accepted" @dataclass class Timestamp: """Timestamp.""" SCHEMA = vol.Schema( { vol.Optional("authoritative"): bool, vol.Optional("seconds"): int, vol.Optional("ticks"): int, } ) authoritative: bool | None = None seconds: int | None = None ticks: int | None = None def as_json(self) -> dict: """Serialize to JSON.""" result: dict[str, Any] = {} if self.authoritative is not None: result["authoritative"] = self.authoritative if self.seconds is not None: result["seconds"] = self.seconds if self.ticks is not None: result["ticks"] = self.ticks return result @classmethod def from_json(cls, json_data: Any) -> Timestamp: """Deserialize from JSON.""" cls.SCHEMA(json_data) return cls( json_data.get("authoritative"), json_data.get("seconds"), json_data.get("ticks"), ) @dataclass class SecurityPolicy: # pylint: disable=too-many-instance-attributes """Security policy.""" SCHEMA = vol.Schema( { vol.Optional("autonomousEnrollment"): bool, vol.Optional("commercialCommissioning"): bool, vol.Optional("externalCommissioning"): bool, vol.Optional("nativeCommissioning"): bool, vol.Optional("networkKeyProvisioning"): bool, vol.Optional("nonCcmRouters"): bool, vol.Optional("obtainNetworkKey"): bool, vol.Optional("rotationTime"): int, vol.Optional("routers"): bool, vol.Optional("tobleLink"): bool, } ) autonomous_enrollment: bool | None = None commercial_commissioning: bool | None = None external_commissioning: bool | None = None native_commissioning: bool | None = None network_key_provisioning: bool | None = None non_ccm_routers: bool | None = None obtain_network_key: bool | None = None rotation_time: int | None = None routers: bool | None = None to_ble_link: bool | None = None def as_json(self) -> dict: """Serialize to JSON.""" result: dict[str, Any] = {} if self.autonomous_enrollment is not None: result["autonomousEnrollment"] = self.autonomous_enrollment if self.commercial_commissioning is not None: result["commercialCommissioning"] = self.commercial_commissioning if self.external_commissioning is not None: result["externalCommissioning"] = self.external_commissioning if self.native_commissioning is not None: result["nativeCommissioning"] = self.native_commissioning if self.network_key_provisioning is not None: result["networkKeyProvisioning"] = self.network_key_provisioning if self.non_ccm_routers is not None: result["nonCcmRouters"] = self.non_ccm_routers if self.obtain_network_key is not None: result["obtainNetworkKey"] = self.obtain_network_key if self.rotation_time is not None: result["rotationTime"] = self.rotation_time if self.routers is not None: result["routers"] = self.routers if self.to_ble_link is not None: result["tobleLink"] = self.to_ble_link return result @classmethod def from_json(cls, json_data: Any) -> SecurityPolicy: """Deserialize from JSON.""" cls.SCHEMA(json_data) return cls( json_data.get("autonomousEnrollment"), json_data.get("commercialCommissioning"), json_data.get("externalCommissioning"), json_data.get("nativeCommissioning"), json_data.get("networkKeyProvisioning"), json_data.get("nonCcmRouters"), json_data.get("obtainNetworkKey"), json_data.get("rotationTime"), json_data.get("routers"), json_data.get("tobleLink"), ) @dataclass class ActiveDataSet: # pylint: disable=too-many-instance-attributes """Operational dataset.""" SCHEMA = vol.Schema( { vol.Optional("activeTimestamp"): dict, vol.Optional("channelMask"): int, vol.Optional("channel"): int, vol.Optional("extPanId"): str, vol.Optional("meshLocalPrefix"): str, vol.Optional("networkKey"): str, vol.Optional("networkName"): str, vol.Optional("panId"): int, vol.Optional("pskc"): str, vol.Optional("securityPolicy"): dict, } ) active_timestamp: Timestamp | None = None channel_mask: int | None = None channel: int | None = None extended_pan_id: str | None = None mesh_local_prefix: str | None = None network_key: str | None = None network_name: str | None = None pan_id: int | None = None psk_c: str | None = None security_policy: SecurityPolicy | None = None def as_json(self) -> dict: """Serialize to JSON.""" result: dict[str, Any] = {} if self.active_timestamp is not None: result["activeTimestamp"] = self.active_timestamp.as_json() if self.channel_mask is not None: result["channelMask"] = self.channel_mask if self.channel is not None: result["channel"] = self.channel if self.extended_pan_id is not None: result["extPanId"] = self.extended_pan_id if self.mesh_local_prefix is not None: result["meshLocalPrefix"] = self.mesh_local_prefix if self.network_key is not None: result["networkKey"] = self.network_key if self.network_name is not None: result["networkName"] = self.network_name if self.pan_id is not None: result["panId"] = self.pan_id if self.psk_c is not None: result["pskc"] = self.psk_c if self.security_policy is not None: result["securityPolicy"] = self.security_policy.as_json() return result @classmethod def from_json(cls, json_data: Any) -> ActiveDataSet: """Deserialize from JSON.""" cls.SCHEMA(json_data) active_timestamp = None security_policy = None if "activeTimestamp" in json_data: active_timestamp = Timestamp.from_json(json_data["activeTimestamp"]) if "securityPolicy" in json_data: security_policy = SecurityPolicy.from_json(json_data["securityPolicy"]) return ActiveDataSet( active_timestamp, json_data.get("channelMask"), json_data.get("channel"), json_data.get("extPanId"), json_data.get("meshLocalPrefix"), json_data.get("networkKey"), json_data.get("networkName"), json_data.get("panId"), json_data.get("pskc"), security_policy, ) @dataclass class EphemeralKeyStatus: """Status of the border agent ephemeral key (ePSKc) session.""" state: EphemeralKeyState port: int @dataclass class EphemeralKeyActivationResult: """Result of activating an ephemeral key (ePSKc).""" tap: str port: int @dataclass class PendingDataSet: # pylint: disable=too-many-instance-attributes """Operational dataset.""" SCHEMA = vol.Schema( { vol.Optional("activeDataset"): dict, vol.Optional("delay"): int, vol.Optional("pendingTimestamp"): dict, } ) active_dataset: ActiveDataSet | None = None delay: int | None = None pending_timestamp: Timestamp | None = None def as_json(self) -> dict: """Serialize to JSON.""" result: dict[str, Any] = {} if self.active_dataset is not None: result["activeDataset"] = self.active_dataset.as_json() if self.delay is not None: result["delay"] = self.delay if self.pending_timestamp is not None: result["pendingTimestamp"] = self.pending_timestamp.as_json() return result @classmethod def from_json(cls, json_data: Any) -> PendingDataSet: """Deserialize from JSON.""" cls.SCHEMA(json_data) active_dataset = None pending_timestamp = None if "activeDataset" in json_data: active_dataset = ActiveDataSet.from_json(json_data["activeDataset"]) if "pendingTimestamp" in json_data: pending_timestamp = Timestamp.from_json(json_data["pendingTimestamp"]) return PendingDataSet( active_dataset, json_data.get("delay"), pending_timestamp, ) home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/pskc.py000066400000000000000000000026251526125473600256140ustar00rootroot00000000000000"""Calculate Thread PSKc. Based on https://github.com/openthread/ot-br-posix/blob/main/src/utils/pskc.cpp """ import struct from cryptography.hazmat.primitives import cmac from cryptography.hazmat.primitives.ciphers import algorithms AES_128_KEY_LEN = 16 ITERATION_COUNTS = 16384 BLKSIZE = 16 SALT_PREFIX = "Thread".encode() def _derive_key(passphrase: str) -> bytes: """Derive key from passphrase according to RFC 4615.""" passphrase_bytes = passphrase.encode() if len(passphrase_bytes) == AES_128_KEY_LEN: return passphrase_bytes c = cmac.CMAC(algorithms.AES128(b"\0" * AES_128_KEY_LEN)) c.update(passphrase_bytes) return c.finalize() def compute_pskc(ext_pan_id: bytes, network_name: str, passphrase: str) -> bytes: """Compute Thread PSKc.""" salt = SALT_PREFIX + ext_pan_id + network_name.encode() key = _derive_key(passphrase) block_counter = 1 prf_input = salt + struct.pack("!L", block_counter) # Calculate U_1 c = cmac.CMAC(algorithms.AES128(key)) c.update(prf_input) prf_output = c.finalize() pskc = bytearray(prf_output) for _ in range(ITERATION_COUNTS - 1): prf_input = prf_output # Calculate U_i c = cmac.CMAC(algorithms.AES128(key)) c.update(prf_input) prf_output = c.finalize() # xor for i in range(BLKSIZE): pskc[i] ^= prf_output[i] return bytes(pskc) home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/py.typed000066400000000000000000000000001526125473600257620ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/python_otbr_api/tlv_parser.py000066400000000000000000000154101526125473600270310ustar00rootroot00000000000000"""Parse datasets TLV encoded as specified by Thread.""" from __future__ import annotations from dataclasses import dataclass, field from enum import IntEnum import struct import logging _LOGGER = logging.getLogger(__name__) class TLVError(Exception): """TLV error.""" class MeshcopTLVType(IntEnum): """Types.""" CHANNEL = 0 PANID = 1 EXTPANID = 2 NETWORKNAME = 3 PSKC = 4 NETWORKKEY = 5 NETWORK_KEY_SEQUENCE = 6 MESHLOCALPREFIX = 7 STEERING_DATA = 8 BORDER_AGENT_RLOC = 9 COMMISSIONER_ID = 10 COMM_SESSION_ID = 11 SECURITYPOLICY = 12 GET = 13 ACTIVETIMESTAMP = 14 COMMISSIONER_UDP_PORT = 15 STATE = 16 JOINER_DTLS = 17 JOINER_UDP_PORT = 18 JOINER_IID = 19 JOINER_RLOC = 20 JOINER_ROUTER_KEK = 21 DURATION = 23 PROVISIONING_URL = 32 VENDOR_NAME_TLV = 33 VENDOR_MODEL_TLV = 34 VENDOR_SW_VERSION_TLV = 35 VENDOR_DATA_TLV = 36 VENDOR_STACK_VERSION_TLV = 37 UDP_ENCAPSULATION_TLV = 48 IPV6_ADDRESS_TLV = 49 PENDINGTIMESTAMP = 51 DELAYTIMER = 52 CHANNELMASK = 53 COUNT = 54 PERIOD = 55 SCAN_DURATION = 56 ENERGY_LIST = 57 THREAD_DOMAIN_NAME = 59 WAKEUP_CHANNEL = 74 DISCOVERYREQUEST = 128 DISCOVERYRESPONSE = 129 JOINERADVERTISEMENT = 241 @dataclass class MeshcopTLVItem: """Base class for TLV items.""" tag: int data: bytes def __str__(self) -> str: """Return a string representation.""" return self.data.hex() @dataclass class Channel(MeshcopTLVItem): """Channel.""" channel: int = field(init=False) def __post_init__(self) -> None: """Decode the channel.""" self.channel = int.from_bytes(self.data, "big") if not self.channel: raise TLVError(f"invalid channel '{self.channel}'") @dataclass class NetworkName(MeshcopTLVItem): """Network name.""" name: str = field(init=False) def __post_init__(self) -> None: """Decode the name.""" try: self.name = self.data.decode() except UnicodeDecodeError as err: raise TLVError(f"invalid network name '{self.data.hex()}'") from err def __str__(self) -> str: return self.name @dataclass class Timestamp(MeshcopTLVItem): """Timestamp.""" authoritative: bool = field(init=False) seconds: int = field(init=False) ticks: int = field(init=False) def __post_init__(self) -> None: """Decode the timestamp.""" # The timestamps are packed in 8 bytes: # [seconds 48 bits][ticks 15 bits][authoritative flag 1 bit] try: unpacked: int = struct.unpack("!Q", self.data)[0] except struct.error as err: raise TLVError(f"invalid timestamp '{self.data.hex()}'") from err self.authoritative = bool(unpacked & 1) self.seconds = unpacked >> 16 self.ticks = (unpacked >> 1) & 0x7FFF @classmethod def from_values( cls, tag: MeshcopTLVType, seconds: int, ticks: int = 0, authoritative: bool = False, ) -> Timestamp: """Construct a timestamp from its field values. Raises TLVError if seconds or ticks don't fit the wire format. """ if not 0 <= seconds < 2**48: raise TLVError(f"timestamp seconds out of range: {seconds}") if not 0 <= ticks < 2**15: raise TLVError(f"timestamp ticks out of range: {ticks}") packed = (seconds << 16) | (ticks << 1) | int(authoritative) return cls(tag, struct.pack("!Q", packed)) @dataclass class DelayTimer(MeshcopTLVItem): """Delay timer.""" delay: int = field(init=False) def __post_init__(self) -> None: """Decode the delay in milliseconds. Raises TLVError if the data is not the four bytes the wire format defines, so a malformed delay timer is rejected instead of being accepted at whatever width it arrived with. """ try: self.delay = struct.unpack("!L", self.data)[0] except struct.error as err: raise TLVError(f"invalid delay timer '{self.data.hex()}'") from err @classmethod def from_milliseconds(cls, delay: int) -> DelayTimer: """Construct a delay timer from a delay in milliseconds. Raises TLVError if the delay doesn't fit the wire format. """ if not 0 <= delay < 2**32: raise TLVError(f"delay timer out of range: {delay}") return cls(MeshcopTLVType.DELAYTIMER, struct.pack("!L", delay)) def _encode_item(item: MeshcopTLVItem) -> bytes: """Encode a dataset item to TLV format.""" data_len = len(item.data) return struct.pack(f"!BB{data_len}s", item.tag, data_len, item.data) def encode_tlv(items: dict[MeshcopTLVType | int, MeshcopTLVItem]) -> str: """Encode a TLV encoded dataset to a hex string. Raises if the TLV is invalid. """ result = b"" for item in items.values(): result += _encode_item(item) return result.hex() def _parse_item(tag: MeshcopTLVType | int, data: bytes) -> MeshcopTLVItem: """Parse a TLV encoded dataset item.""" if tag in (MeshcopTLVType.ACTIVETIMESTAMP, MeshcopTLVType.PENDINGTIMESTAMP): return Timestamp(tag, data) if tag == MeshcopTLVType.CHANNEL: return Channel(tag, data) if tag == MeshcopTLVType.DELAYTIMER: return DelayTimer(tag, data) if tag == MeshcopTLVType.NETWORKNAME: return NetworkName(tag, data) return MeshcopTLVItem(tag, data) def parse_tlv(data: str) -> dict[MeshcopTLVType | int, MeshcopTLVItem]: """Parse a TLV encoded dataset. Raises if the TLV is invalid. """ try: data_bytes = bytes.fromhex(data) except ValueError as err: raise TLVError("invalid tlvs") from err result = {} pos = 0 length = len(data_bytes) while pos < length: if pos + 2 > length: raise TLVError("truncated tlv header") raw_tag = data_bytes[pos] pos += 1 # Unknown tags should still be passed through tag: MeshcopTLVType | int try: tag = MeshcopTLVType(raw_tag) except ValueError: tag = raw_tag _len = data_bytes[pos] pos += 1 if pos + _len > length: raise TLVError(f"expected {_len} bytes for tag {tag!r}, got {length - pos}") val = data_bytes[pos : pos + _len] pos += _len # Log unknown TLVs by type and length only: the value may hold # network credentials, which don't belong in the log. if not isinstance(tag, MeshcopTLVType): _LOGGER.warning("unknown TLV type %d (%d bytes)", raw_tag, _len) if tag in result: raise TLVError(f"duplicated tag {tag!r}") result[tag] = _parse_item(tag, val) return result home-assistant-libs-python-otbr-api-9b796db/requirements-test.txt000066400000000000000000000001321526125473600253320ustar00rootroot00000000000000black==26.5.1 flake8==7.4.1 mypy==2.4.0 pylint==4.1.1 pytest-asyncio==1.4.0 pytest==9.1.1 home-assistant-libs-python-otbr-api-9b796db/requirements.txt000066400000000000000000000000741526125473600243620ustar00rootroot00000000000000aiohttp bitstruct cryptography typing_extensions voluptuous home-assistant-libs-python-otbr-api-9b796db/setup.cfg000066400000000000000000000001461526125473600227170ustar00rootroot00000000000000[flake8] # To work with Black max-line-length = 88 # E203: Whitespace before ':' extend-ignore = E203 home-assistant-libs-python-otbr-api-9b796db/tests/000077500000000000000000000000001526125473600222375ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/tests/__init__.py000066400000000000000000000000151526125473600243440ustar00rootroot00000000000000"""Tests.""" home-assistant-libs-python-otbr-api-9b796db/tests/conftest.py000066400000000000000000000005451526125473600244420ustar00rootroot00000000000000"""Test fixtures.""" from collections.abc import Generator import pytest from tests.test_util.aiohttp import AiohttpClientMocker, mock_aiohttp_client @pytest.fixture def aioclient_mock() -> Generator[AiohttpClientMocker, None, None]: """Fixture to mock aioclient calls.""" with mock_aiohttp_client() as mock_session: yield mock_session home-assistant-libs-python-otbr-api-9b796db/tests/test_ephemeral_key.py000066400000000000000000000147201526125473600264660ustar00rootroot00000000000000"""Tests for the ephemeral key (ePSKc) REST API.""" from http import HTTPStatus import pytest import python_otbr_api from python_otbr_api import EphemeralKeyState, KeyFormat from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-openthread-border-router:8081" def _otbr(aioclient_mock: AiohttpClientMocker) -> python_otbr_api.OTBR: return python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) async def test_get_ephemeral_key_enabled_true( aioclient_mock: AiohttpClientMocker, ) -> None: """A 200 with body "enabled" reports the feature as enabled.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/node/ba-epskc/state", json="enabled") assert await otbr.get_ephemeral_key_enabled() is True async def test_get_ephemeral_key_enabled_false( aioclient_mock: AiohttpClientMocker, ) -> None: """A 200 with body "disabled" reports the feature as disabled.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/node/ba-epskc/state", json="disabled") assert await otbr.get_ephemeral_key_enabled() is False async def test_get_ephemeral_key_enabled_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router does not support ePSKc.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/node/ba-epskc/state", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.EphemeralKeyNotSupportedError): await otbr.get_ephemeral_key_enabled() async def test_get_ephemeral_key_enabled_unexpected_status( aioclient_mock: AiohttpClientMocker, ) -> None: """Any other non-200 status raises OTBRError.""" otbr = _otbr(aioclient_mock) aioclient_mock.get( f"{BASE_URL}/node/ba-epskc/state", status=HTTPStatus.INTERNAL_SERVER_ERROR ) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_ephemeral_key_enabled() async def test_set_ephemeral_key_enabled(aioclient_mock: AiohttpClientMocker) -> None: """Enabling sends body "enable", disabling sends body "disable".""" otbr = _otbr(aioclient_mock) aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", status=HTTPStatus.OK) await otbr.set_ephemeral_key_enabled(True) assert aioclient_mock.mock_calls[-1][2] == "enable" await otbr.set_ephemeral_key_enabled(False) assert aioclient_mock.mock_calls[-1][2] == "disable" async def test_set_ephemeral_key_enabled_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router does not support ePSKc.""" otbr = _otbr(aioclient_mock) aioclient_mock.put(f"{BASE_URL}/node/ba-epskc/state", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.EphemeralKeyNotSupportedError): await otbr.set_ephemeral_key_enabled(True) async def test_get_ephemeral_key_status(aioclient_mock: AiohttpClientMocker) -> None: """A successful status response is parsed into an EphemeralKeyStatus.""" otbr = _otbr(aioclient_mock) aioclient_mock.get( f"{BASE_URL}/node/ba-epskc/key", json={"state": "started", "port": 49152} ) status = await otbr.get_ephemeral_key_status() assert status.state == EphemeralKeyState.STARTED assert status.port == 49152 async def test_get_ephemeral_key_status_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router does not support ePSKc.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.EphemeralKeyNotSupportedError): await otbr.get_ephemeral_key_status() async def test_get_ephemeral_key_status_invalid( aioclient_mock: AiohttpClientMocker, ) -> None: """A malformed response body raises OTBRError.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/node/ba-epskc/key", json={"state": "started"}) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_ephemeral_key_status() async def test_activate_ephemeral_key_defaults( aioclient_mock: AiohttpClientMocker, ) -> None: """With no arguments an empty JSON object is posted.""" otbr = _otbr(aioclient_mock) aioclient_mock.post( f"{BASE_URL}/node/ba-epskc/key", json={"tap": "123456789", "port": 49152}, ) result = await otbr.activate_ephemeral_key() assert result.tap == "123456789" assert result.port == 49152 assert aioclient_mock.mock_calls[-1][2] == {} async def test_activate_ephemeral_key_with_params( aioclient_mock: AiohttpClientMocker, ) -> None: """lifetime and port are forwarded in the request body when given.""" otbr = _otbr(aioclient_mock) aioclient_mock.post( f"{BASE_URL}/node/ba-epskc/key", json={"tap": "123456789", "port": 12345}, ) await otbr.activate_ephemeral_key(lifetime=60000, port=12345) assert aioclient_mock.mock_calls[-1][2] == {"lifetime": 60000, "port": 12345} async def test_activate_ephemeral_key_conflict( aioclient_mock: AiohttpClientMocker, ) -> None: """A 409 means the feature is disabled or a key is already active.""" otbr = _otbr(aioclient_mock) aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.CONFLICT) with pytest.raises(python_otbr_api.EphemeralKeyConflictError): await otbr.activate_ephemeral_key() async def test_activate_ephemeral_key_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router does not support ePSKc.""" otbr = _otbr(aioclient_mock) aioclient_mock.post(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.EphemeralKeyNotSupportedError): await otbr.activate_ephemeral_key() async def test_deactivate_ephemeral_key(aioclient_mock: AiohttpClientMocker) -> None: """A successful deactivation returns None.""" otbr = _otbr(aioclient_mock) aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.OK) await otbr.deactivate_ephemeral_key() assert aioclient_mock.call_count == 1 async def test_deactivate_ephemeral_key_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router does not support ePSKc.""" otbr = _otbr(aioclient_mock) aioclient_mock.delete(f"{BASE_URL}/node/ba-epskc/key", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.EphemeralKeyNotSupportedError): await otbr.deactivate_ephemeral_key() home-assistant-libs-python-otbr-api-9b796db/tests/test_init.py000066400000000000000000000135721526125473600246230ustar00rootroot00000000000000"""Tests for version detection and the camelCase wire format.""" from http import HTTPStatus from typing import Any import pytest import python_otbr_api from python_otbr_api import KeyFormat from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-openthread-border-router:8081" DATASET_JSON_CAMEL: dict[str, Any] = { "activeTimestamp": {"authoritative": False, "seconds": 1, "ticks": 0}, "channelMask": 134215680, "channel": 15, "extPanId": "8478E3379E047B92", "meshLocalPrefix": "fd89:bde7:42ed:a901::/64", "networkKey": "96271D6ECC78749114AB6A591E0D06F1", "networkName": "OpenThread HA", "panId": 33991, "pskc": "9760C89414D461AC717DCD105EB87E5B", "securityPolicy": { "autonomousEnrollment": False, "commercialCommissioning": False, "externalCommissioning": True, "nativeCommissioning": True, "networkKeyProvisioning": False, "nonCcmRouters": False, "obtainNetworkKey": True, "rotationTime": 672, "routers": True, "tobleLink": True, }, } async def test_auto_detect_camel(aioclient_mock: AiohttpClientMocker) -> None: """A 200 on /api/actions selects the camelCase wire format.""" otbr = python_otbr_api.OTBR(BASE_URL, aioclient_mock.create_session()) aioclient_mock.get( f"{BASE_URL}/api/actions", status=HTTPStatus.OK, json={"data": []} ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) ) assert aioclient_mock.mock_calls[-1][2] == { "networkName": "OpenThread HA", "channel": 15, } async def test_auto_detect_pascal(aioclient_mock: AiohttpClientMocker) -> None: """A 404 on /api/actions selects the legacy PascalCase wire format.""" otbr = python_otbr_api.OTBR(BASE_URL, aioclient_mock.create_session()) aioclient_mock.get(f"{BASE_URL}/api/actions", status=HTTPStatus.NOT_FOUND) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) ) assert aioclient_mock.mock_calls[-1][2] == { "NetworkName": "OpenThread HA", "Channel": 15, } async def test_auto_detect_unexpected_status( aioclient_mock: AiohttpClientMocker, ) -> None: """Any probe status other than 200/404 is inconclusive and raises.""" otbr = python_otbr_api.OTBR(BASE_URL, aioclient_mock.create_session()) aioclient_mock.get( f"{BASE_URL}/api/actions", status=HTTPStatus.INTERNAL_SERVER_ERROR ) with pytest.raises(python_otbr_api.OTBRError): await otbr.factory_reset() async def test_auto_detect_runs_once(aioclient_mock: AiohttpClientMocker) -> None: """Detection happens lazily on first call and is cached for subsequent calls.""" otbr = python_otbr_api.OTBR(BASE_URL, aioclient_mock.create_session()) aioclient_mock.get( f"{BASE_URL}/api/actions", status=HTTPStatus.OK, json={"data": []} ) aioclient_mock.delete(f"{BASE_URL}/node", status=HTTPStatus.OK) await otbr.factory_reset() await otbr.factory_reset() # 1 probe + 2 factory_reset calls assert aioclient_mock.call_count == 3 async def test_constructor_key_format_skips_detection( aioclient_mock: AiohttpClientMocker, ) -> None: """Passing key_format at construction time bypasses the probe.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node", status=HTTPStatus.OK) await otbr.factory_reset() assert aioclient_mock.call_count == 1 async def test_get_active_dataset_camel(aioclient_mock: AiohttpClientMocker) -> None: """Dataset read returns camelCase and round-trips cleanly.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON_CAMEL) dataset = await otbr.get_active_dataset() assert dataset is not None assert dataset.as_json() == DATASET_JSON_CAMEL async def test_camel_read_with_pascal_straggler( aioclient_mock: AiohttpClientMocker, ) -> None: """CAMEL_CASE reads are always normalized, so Sept-2025..Apr-2026 OTBR builds that still emit pascal-case stragglers (e.g. `Routers`) parse cleanly instead of blowing up schema validation.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) straggler_dataset = { **DATASET_JSON_CAMEL, "securityPolicy": { **{ k: v for k, v in DATASET_JSON_CAMEL["securityPolicy"].items() if k != "routers" }, "Routers": DATASET_JSON_CAMEL["securityPolicy"]["routers"], }, } aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=straggler_dataset) dataset = await otbr.get_active_dataset() assert dataset is not None assert dataset.security_policy is not None assert dataset.security_policy.routers is True assert dataset.as_json() == DATASET_JSON_CAMEL async def test_create_active_dataset_camel_wire( aioclient_mock: AiohttpClientMocker, ) -> None: """In CAMEL_CASE mode the wire body is camelCase.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.CAMEL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) ) assert aioclient_mock.mock_calls[-1][2] == { "networkName": "OpenThread HA", "channel": 15, } home-assistant-libs-python-otbr-api-9b796db/tests/test_init_legacy.py000066400000000000000000001057231526125473600261470ustar00rootroot00000000000000"""Test the OTBR REST API client with an older OTBR instance.""" from http import HTTPStatus from typing import Any import pytest import python_otbr_api from python_otbr_api import KeyFormat from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-silabs-multiprotocol:8081" DATASET_JSON: dict[str, Any] = { "ActiveTimestamp": { "Authoritative": False, "Seconds": 1, "Ticks": 0, }, "ChannelMask": 134215680, "Channel": 15, "ExtPanId": "8478E3379E047B92", "MeshLocalPrefix": "fd89:bde7:42ed:a901::/64", "NetworkKey": "96271D6ECC78749114AB6A591E0D06F1", "NetworkName": "OpenThread HA", "PanId": 33991, "PSKc": "9760C89414D461AC717DCD105EB87E5B", "SecurityPolicy": { "AutonomousEnrollment": False, "CommercialCommissioning": False, "ExternalCommissioning": True, "NativeCommissioning": True, "NetworkKeyProvisioning": False, "NonCcmRouters": False, "ObtainNetworkKey": True, "RotationTime": 672, "Routers": True, "TobleLink": True, }, } async def test_factory_reset(aioclient_mock: AiohttpClientMocker) -> None: """Test factory_reset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node", status=HTTPStatus.OK) await otbr.factory_reset() assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[-1][0] == "DELETE" assert aioclient_mock.mock_calls[-1][1].path == "/node" assert aioclient_mock.mock_calls[-1][2] is None async def test_factory_reset_unsupported(aioclient_mock: AiohttpClientMocker) -> None: """Test factory_reset is unsupported.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node", status=HTTPStatus.METHOD_NOT_ALLOWED) with pytest.raises(python_otbr_api.FactoryResetNotSupportedError): await otbr.factory_reset() async def test_factory_reset_201(aioclient_mock: AiohttpClientMocker) -> None: """Test factory_reset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.factory_reset() async def test_get_border_agent_id(aioclient_mock: AiohttpClientMocker) -> None: """Test get_border_agent_id.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = "230C6A1AC57F6F4BE262ACF32E5EF52C" aioclient_mock.get(f"{BASE_URL}/node/ba-id", json=mock_response) assert await otbr.get_border_agent_id() == bytes.fromhex(mock_response) async def test_get_border_agent_id_unsupported( aioclient_mock: AiohttpClientMocker, ) -> None: """Test get_border_agent_id with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/ba-id", status=HTTPStatus.NOT_FOUND) with pytest.raises(python_otbr_api.GetBorderAgentIdNotSupportedError): await otbr.get_border_agent_id() async def test_get_border_agent_id_invalid(aioclient_mock: AiohttpClientMocker) -> None: """Test get_border_agent_id with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/ba-id", text="unexpected") with pytest.raises(python_otbr_api.OTBRError): await otbr.get_border_agent_id() async def test_get_border_agent_id_201(aioclient_mock: AiohttpClientMocker) -> None: """Test get_border_agent_id with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/ba-id", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_border_agent_id() async def test_set_enabled(aioclient_mock: AiohttpClientMocker) -> None: """Test set_enabled.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/state", status=HTTPStatus.OK) await otbr.set_enabled(True) assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" await otbr.set_enabled(False) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "disable" async def test_get_active_dataset(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) active_timestamp = python_otbr_api.models.Timestamp( DATASET_JSON["ActiveTimestamp"]["Authoritative"], DATASET_JSON["ActiveTimestamp"]["Seconds"], DATASET_JSON["ActiveTimestamp"]["Ticks"], ) security_policy = python_otbr_api.models.SecurityPolicy( DATASET_JSON["SecurityPolicy"]["AutonomousEnrollment"], DATASET_JSON["SecurityPolicy"]["CommercialCommissioning"], DATASET_JSON["SecurityPolicy"]["ExternalCommissioning"], DATASET_JSON["SecurityPolicy"]["NativeCommissioning"], DATASET_JSON["SecurityPolicy"]["NetworkKeyProvisioning"], DATASET_JSON["SecurityPolicy"]["NonCcmRouters"], DATASET_JSON["SecurityPolicy"]["ObtainNetworkKey"], DATASET_JSON["SecurityPolicy"]["RotationTime"], DATASET_JSON["SecurityPolicy"]["Routers"], DATASET_JSON["SecurityPolicy"]["TobleLink"], ) active_dataset = await otbr.get_active_dataset() assert active_dataset == python_otbr_api.ActiveDataSet( active_timestamp, DATASET_JSON["ChannelMask"], DATASET_JSON["Channel"], DATASET_JSON["ExtPanId"], DATASET_JSON["MeshLocalPrefix"], DATASET_JSON["NetworkKey"], DATASET_JSON["NetworkName"], DATASET_JSON["PanId"], DATASET_JSON["PSKc"], security_policy, ) async def test_get_active_dataset_empty(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.NO_CONTENT) assert await otbr.get_active_dataset() is None async def test_get_active_dataset_tlvs(aioclient_mock: AiohttpClientMocker) -> None: """Test get_active_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = ( "0E080000000000010000000300001035060004001FFFE00208F642646DA209B1C00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=mock_response) assert await otbr.get_active_dataset_tlvs() == bytes.fromhex(mock_response) async def test_get_active_dataset_tlvs_empty(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.NO_CONTENT) assert await otbr.get_active_dataset_tlvs() is None async def test_get_pending_dataset_tlvs(aioclient_mock: AiohttpClientMocker) -> None: """Test get_pending_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = ( "0E080000000000010000340400006699000300000C35060004001FFFE00208057B7CD3D6CC9F65" "0708FD17C9D59809B27A05107546326F20BCCFD946609FBAF7F39AD5030F4F70656E5468726561" "642D32366363010226CC0410FA7EC34EBE58DD1FD74F13F65D021C5B0C0402A0F7F8" ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", text=mock_response) assert await otbr.get_pending_dataset_tlvs() == bytes.fromhex(mock_response) async def test_get_pending_dataset_tlvs_empty(aioclient_mock: AiohttpClientMocker): """Test get_pending_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) assert await otbr.get_pending_dataset_tlvs() is None async def test_create_active_dataset(aioclient_mock: AiohttpClientMocker): """Test create_active_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) await otbr.create_active_dataset(python_otbr_api.ActiveDataSet()) assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-1][2] == {} await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA") ) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-1][2] == {"NetworkName": "OpenThread HA"} await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) ) assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-1][2] == { "NetworkName": "OpenThread HA", "Channel": 15, } async def test_delete_active_dataset(aioclient_mock: AiohttpClientMocker): """Test delete_active_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.OK) await otbr.delete_active_dataset() assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[-1][0] == "DELETE" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-1][2] is None async def test_create_pending_dataset(aioclient_mock: AiohttpClientMocker): """Test create_pending_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == {} assert aioclient_mock.mock_calls[-1][3]["If-None-Match"] == "*" await otbr.create_pending_dataset( python_otbr_api.PendingDataSet( python_otbr_api.ActiveDataSet(network_name="OpenThread HA"), 12345, python_otbr_api.Timestamp(), ) ) assert aioclient_mock.call_count == 4 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == { "ActiveDataset": { "NetworkName": "OpenThread HA", }, "Delay": 12345, "PendingTimestamp": {}, } await otbr.create_pending_dataset( python_otbr_api.PendingDataSet( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15), 23456, ) ) assert aioclient_mock.call_count == 6 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == { "ActiveDataset": { "Channel": 15, "NetworkName": "OpenThread HA", }, "Delay": 23456, } async def test_delete_pending_dataset(aioclient_mock: AiohttpClientMocker): """Test delete_pending_dataset.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.OK) await otbr.delete_pending_dataset() assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[-1][0] == "DELETE" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] is None async def test_set_channel(aioclient_mock: AiohttpClientMocker) -> None: """Test set_channel.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 expected_active_timestamp = DATASET_JSON["ActiveTimestamp"] | {"Seconds": 2} expected_pending_dataset = { "ActiveDataset": DATASET_JSON | { "ActiveTimestamp": expected_active_timestamp, "Channel": new_channel, }, "Delay": 1234, } assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel, 1234) assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[0][0] == "GET" assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "GET" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][0] == "PUT" assert aioclient_mock.mock_calls[2][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][2] == expected_pending_dataset async def test_set_channel_default_delay(aioclient_mock: AiohttpClientMocker) -> None: """Test set_channel.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 expected_active_timestamp = DATASET_JSON["ActiveTimestamp"] | {"Seconds": 2} expected_pending_dataset = { "ActiveDataset": DATASET_JSON | { "ActiveTimestamp": expected_active_timestamp, "Channel": new_channel, }, "Delay": 300000, } assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel) assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[0][0] == "GET" assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "GET" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][0] == "PUT" assert aioclient_mock.mock_calls[2][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][2] == expected_pending_dataset async def test_set_channel_no_timestamp(aioclient_mock: AiohttpClientMocker) -> None: """Test set_channel.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) dataset_json = dict(DATASET_JSON) dataset_json.pop("ActiveTimestamp") aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=dataset_json) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 expected_active_timestamp = {"Authoritative": False, "Seconds": 1, "Ticks": 0} expected_pending_dataset = { "ActiveDataset": DATASET_JSON | { "ActiveTimestamp": expected_active_timestamp, "Channel": new_channel, }, "Delay": 300000, } assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel) assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[0][0] == "GET" assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "GET" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][0] == "PUT" assert aioclient_mock.mock_calls[2][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[2][2] == expected_pending_dataset async def test_set_channel_invalid_channel(aioclient_mock: AiohttpClientMocker) -> None: """Test set_channel.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) with pytest.raises(python_otbr_api.OTBRError): await otbr.set_channel(123) async def test_set_channel_no_dataset(aioclient_mock: AiohttpClientMocker) -> None: """Test set_channel.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.NO_CONTENT) with pytest.raises(python_otbr_api.OTBRError): await otbr.set_channel(16) async def test_set_channel_rejected_while_pending( aioclient_mock: AiohttpClientMocker, ) -> None: """A channel change is refused while a pending dataset is in place. Stamping from the active dataset alone would make the mesh silently ignore the write, and superseding the pending dataset would race its delay timer on devices that miss the update; refusing is the only honest answer. The refusal comes from create_pending_dataset, so nothing is written. """ otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = ( "0E080000000000010000340400006699000300000C35060004001FFFE00208057B7CD3D6CC9F65" "0708FD17C9D59809B27A05107546326F20BCCFD946609FBAF7F39AD5030F4F70656E5468726561" "642D32366363010226CC0410FA7EC34EBE58DD1FD74F13F65D021C5B0C0402A0F7F8" ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", text=mock_response) with pytest.raises(python_otbr_api.PendingDatasetConflictError): await otbr.set_channel(16) assert not [call for call in aioclient_mock.mock_calls if call[0] == "PUT"] assert aioclient_mock.call_count == 2 async def test_get_extended_address(aioclient_mock: AiohttpClientMocker) -> None: """Test get_active_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = "4EF6C4F3FF750626" aioclient_mock.get(f"{BASE_URL}/node/ext-address", json=mock_response) assert await otbr.get_extended_address() == bytes.fromhex(mock_response) async def test_get_coprocessor_version(aioclient_mock: AiohttpClientMocker) -> None: """Test get_coprocessor_version.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) mock_response = ( "OPENTHREAD/thread-reference-20200818-1740-g33cc75ed3;" " NRF52840; Jun 2 2022 14:25:49" ) aioclient_mock.get(f"{BASE_URL}/node/coprocessor/version", json=mock_response) assert await otbr.get_coprocessor_version() == mock_response async def test_set_enabled_201(aioclient_mock: AiohttpClientMocker) -> None: """Test set_enabled.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/state", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.set_enabled(True) async def test_get_active_dataset_201(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_active_dataset() async def test_get_active_dataset_invalid(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text="unexpected") with pytest.raises(python_otbr_api.OTBRError): await otbr.get_active_dataset() async def test_get_active_dataset_tlvs_201(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset_tlvs with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_active_dataset_tlvs() async def test_get_active_dataset_tlvs_invalid(aioclient_mock: AiohttpClientMocker): """Test get_active_dataset_tlvs with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text="unexpected") with pytest.raises(python_otbr_api.OTBRError): await otbr.get_active_dataset_tlvs() async def test_get_pending_dataset_tlvs_201(aioclient_mock: AiohttpClientMocker): """Test test_get_pending_dataset_tlvs_201 with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_pending_dataset_tlvs() async def test_test_get_pending_dataset_tlvs_201_invalid( aioclient_mock: AiohttpClientMocker, ): """Test get_pending_dataset_tlvs with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", text="unexpected") with pytest.raises(python_otbr_api.OTBRError): await otbr.get_pending_dataset_tlvs() async def test_create_active_dataset_thread_active(aioclient_mock: AiohttpClientMocker): """Test create_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CONFLICT) with pytest.raises(python_otbr_api.ThreadNetworkActiveError): await otbr.create_active_dataset(python_otbr_api.ActiveDataSet()) async def test_create_active_dataset_202(aioclient_mock: AiohttpClientMocker): """Test create_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.ACCEPTED) with pytest.raises(python_otbr_api.OTBRError): await otbr.create_active_dataset(python_otbr_api.ActiveDataSet()) async def test_delete_active_dataset_thread_active(aioclient_mock: AiohttpClientMocker): """Test delete_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CONFLICT) with pytest.raises(python_otbr_api.ThreadNetworkActiveError): await otbr.delete_active_dataset() async def test_delete_active_dataset_202(aioclient_mock: AiohttpClientMocker): """Test delete_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.ACCEPTED) with pytest.raises(python_otbr_api.OTBRError): await otbr.delete_active_dataset() async def test_create_pending_dataset_rejected( aioclient_mock: AiohttpClientMocker, ): """Test create_pending_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CONFLICT, json={"title": "Conflict", "status": 409, "detail": "rejected by leader"}, ) with pytest.raises(python_otbr_api.PendingDatasetRejectedError) as exc_info: await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) # The reason is the detail; the title is only the HTTP status phrase. assert exc_info.value.reason == "rejected by leader" assert "rejected by leader" in str(exc_info.value) assert "Conflict" not in str(exc_info.value) async def test_create_pending_dataset_202(aioclient_mock: AiohttpClientMocker): """Test create_pending_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.ACCEPTED) with pytest.raises(python_otbr_api.OTBRError): await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) async def test_delete_pending_dataset_thread_active( aioclient_mock: AiohttpClientMocker, ): """Test delete_pending_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CONFLICT ) with pytest.raises(python_otbr_api.ThreadNetworkActiveError): await otbr.delete_pending_dataset() async def test_delete_pending_dataset_202(aioclient_mock: AiohttpClientMocker): """Test delete_pending_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.delete( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.ACCEPTED ) with pytest.raises(python_otbr_api.OTBRError): await otbr.delete_pending_dataset() async def test_set_active_dataset_tlvs_thread_active( aioclient_mock: AiohttpClientMocker, ): """Test set_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CONFLICT) with pytest.raises(python_otbr_api.ThreadNetworkActiveError): await otbr.set_active_dataset_tlvs(b"") async def test_set_active_dataset_tlvs_202(aioclient_mock: AiohttpClientMocker): """Test set_active_dataset with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.ACCEPTED) with pytest.raises(python_otbr_api.OTBRError): await otbr.set_active_dataset_tlvs(b"") async def test_get_extended_address_201(aioclient_mock: AiohttpClientMocker) -> None: """Test get_extended_address with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/ext-address", status=HTTPStatus.CREATED) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_extended_address() async def test_get_extended_address_invalid(aioclient_mock: AiohttpClientMocker): """Test get_extended_address with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/ext-address", text="unexpected") with pytest.raises(python_otbr_api.OTBRError): await otbr.get_extended_address() async def test_get_coprocessor_version_invalid(aioclient_mock: AiohttpClientMocker): """Test get_coprocessor_version with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get( f"{BASE_URL}/node/coprocessor/version", status=HTTPStatus.NOT_FOUND ) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_coprocessor_version() async def test_set_pending_dataset_tlvs(aioclient_mock: AiohttpClientMocker) -> None: """Test set_pending_dataset_tlvs.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) dataset = bytes.fromhex( "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD" "70BFE5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444" "656D6F010212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) await otbr.set_pending_dataset_tlvs(dataset) assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == dataset.hex() assert aioclient_mock.mock_calls[-1][3]["If-None-Match"] == "*" async def test_set_pending_dataset_tlvs_rejected( aioclient_mock: AiohttpClientMocker, ) -> None: """Test set_pending_dataset_tlvs with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CONFLICT, text="rejected by leader", ) with pytest.raises(python_otbr_api.PendingDatasetRejectedError) as exc_info: await otbr.set_pending_dataset_tlvs(b"") # A plain body is taken as it is. assert exc_info.value.reason == "rejected by leader" async def test_set_pending_dataset_tlvs_refused_while_pending( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write is refused while a pending dataset is in place.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) in_flight = "0E080000000000010000340400006699000300000C" aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", text=in_flight) with pytest.raises(python_otbr_api.PendingDatasetConflictError): await otbr.set_pending_dataset_tlvs(bytes.fromhex(in_flight)) assert aioclient_mock.call_count == 1 async def test_set_pending_dataset_tlvs_refused_by_router( aioclient_mock: AiohttpClientMocker, ) -> None: """Test the border router refusing the precondition is surfaced.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.PRECONDITION_FAILED ) # The dedicated type, so a caller can tell "nothing was written because a # dataset is in place" from a transport or protocol failure. with pytest.raises(python_otbr_api.PendingDatasetConflictError): await otbr.set_pending_dataset_tlvs(b"") async def test_set_pending_dataset_tlvs_202( aioclient_mock: AiohttpClientMocker, ) -> None: """Test set_pending_dataset_tlvs with error.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.ACCEPTED) with pytest.raises(python_otbr_api.OTBRError): await otbr.set_pending_dataset_tlvs(b"") async def test_create_pending_dataset_refused_while_pending( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write is refused while a pending dataset is in place.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) in_flight = "0E080000000000010000340400006699000300000C" aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", text=in_flight) with pytest.raises(python_otbr_api.PendingDatasetConflictError): await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) assert aioclient_mock.call_count == 1 async def test_create_pending_dataset_refused_by_router( aioclient_mock: AiohttpClientMocker, ) -> None: """Test the border router refusing the precondition is surfaced. A pending dataset created between the local check and the write is only caught by the router, which evaluates If-None-Match atomically with it. """ otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.PRECONDITION_FAILED ) with pytest.raises(python_otbr_api.PendingDatasetConflictError): await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) home-assistant-libs-python-otbr-api-9b796db/tests/test_mdns.py000066400000000000000000000032711526125473600246140ustar00rootroot00000000000000"""Test decoding fields in _meshcop._udp.local. services.""" import pytest from python_otbr_api.mdns import ( Availability, ConnectionMode, StateBitmap, ThreadInterfaceStatus, ) @pytest.mark.parametrize( "encoded, decoded", [ ( b"\x00\x00\x01\xb1", StateBitmap( connection_mode=ConnectionMode.PSKC, thread_interface_status=ThreadInterfaceStatus.ACTIVE, availability=Availability.HIGH, is_active=True, is_primary=True, ), ), ( b"\x00\x00\x00!", StateBitmap( connection_mode=ConnectionMode.PSKC, thread_interface_status=ThreadInterfaceStatus.NOT_INITIALIZED, availability=Availability.HIGH, is_active=False, is_primary=False, ), ), ], ) def test_decode_state_bitmap(encoded, decoded) -> None: """Test the TLV parser.""" assert StateBitmap.from_bytes(encoded) == decoded @pytest.mark.parametrize( "encoded, error", [ # Input not bytes ("blah", TypeError), # Wrong length (b"\x00\x01\xb1", ValueError), # Padding not zeroed (b"\xff\x00\x01\xb1", ValueError), # Invalid ConnectionMode (b"\x00\x00\x01\xb5", ValueError), # Invalid ThreadInterfaceStatus (b"\x00\x00\x01\xb9", ValueError), # Invalid Availability (b"\x00\x00\x01\xf1", ValueError), ], ) def test_decode_state_bitmap_error(encoded, error) -> None: """Test the TLV parser.""" with pytest.raises(error): StateBitmap.from_bytes(encoded) home-assistant-libs-python-otbr-api-9b796db/tests/test_models.py000066400000000000000000000030451526125473600251350ustar00rootroot00000000000000"""Test data models.""" import python_otbr_api ACTIVE_DATASET_CAMEL = { "activeTimestamp": {"seconds": 1, "ticks": 0, "authoritative": False}, "networkKey": "00112233445566778899aabbccddeeff", "networkName": "OpenThread-1234", "extPanId": "dead00beef00cafe", "meshLocalPrefix": "fd11:2222:3333::/64", "panId": 12345, "channel": 15, "pskc": "aabbccddeeff00112233445566778899", "securityPolicy": { "rotationTime": 672, "obtainNetworkKey": True, "routers": True, }, "channelMask": 134215680, } def test_active_dataset_from_json(): """Test parsing an ActiveDataSet from camelCase JSON.""" dataset = python_otbr_api.ActiveDataSet.from_json(ACTIVE_DATASET_CAMEL) assert dataset.channel == 15 assert dataset.network_name == "OpenThread-1234" assert dataset.active_timestamp == python_otbr_api.Timestamp( authoritative=False, seconds=1, ticks=0 ) assert dataset.security_policy.rotation_time == 672 assert dataset.as_json() == ACTIVE_DATASET_CAMEL PENDING_CAMEL = { "activeDataset": {"networkName": "OpenThread HA"}, "delay": 12345, "pendingTimestamp": {}, } def test_pending_dataset_from_json(): """Test parsing a PendingDataSet, with nested objects.""" result = python_otbr_api.PendingDataSet.from_json(PENDING_CAMEL) assert result == python_otbr_api.PendingDataSet( python_otbr_api.ActiveDataSet(network_name="OpenThread HA"), 12345, python_otbr_api.Timestamp(), ) assert result.as_json() == PENDING_CAMEL home-assistant-libs-python-otbr-api-9b796db/tests/test_pending_dataset.py000066400000000000000000000141351526125473600270050ustar00rootroot00000000000000"""Test the pending dataset writers against a border router that reports verdicts. Since openthread/ot-br-posix#3582 the router registers the dataset with the Thread leader and answers with the leader's verdict, or with none. """ from http import HTTPStatus import pytest import python_otbr_api from python_otbr_api import KeyFormat from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-silabs-multiprotocol:8081" async def test_create_pending_dataset_leader_unanswered( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write the leader never answered is reported as such, not refused.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.GATEWAY_TIMEOUT ) with pytest.raises(python_otbr_api.PendingDatasetOutcomeUnknownError): await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) async def test_create_pending_dataset_client_timeout( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write the client stopped waiting for is an unknown outcome too.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", exc=TimeoutError) with pytest.raises(python_otbr_api.PendingDatasetOutcomeUnknownError): await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) @pytest.mark.parametrize(("client_timeout", "expected"), [(10, 40), (60, 60)]) async def test_pending_dataset_write_waits_for_the_leader( aioclient_mock: AiohttpClientMocker, client_timeout: int, expected: int ) -> None: """Test the write waits long enough for the leader's verdict to arrive.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), client_timeout, key_format=KeyFormat.PASCAL_CASE, ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) await otbr.set_pending_dataset_tlvs(b"") # The pre-write checks keep the client's timeout; only the writes wait. assert [t.total for t in aioclient_mock.mock_timeouts[-4:]] == [ client_timeout, expected, client_timeout, expected, ] async def test_set_pending_dataset_tlvs_leader_unanswered( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write the leader never answered is reported as such, not refused.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.GATEWAY_TIMEOUT ) with pytest.raises(python_otbr_api.PendingDatasetOutcomeUnknownError): await otbr.set_pending_dataset_tlvs(b"") async def test_set_pending_dataset_tlvs_client_timeout( aioclient_mock: AiohttpClientMocker, ) -> None: """Test a write the client stopped waiting for is an unknown outcome too.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", exc=TimeoutError) with pytest.raises(python_otbr_api.PendingDatasetOutcomeUnknownError): await otbr.set_pending_dataset_tlvs(b"") @pytest.mark.parametrize( ("body", "kwargs"), [ # What the router sends when it is not attached or still busy with an # earlier registration: the status phrase and nothing else. ("bare JSON error", {"json": {"title": "Conflict", "status": 409}}), ("empty body", {}), ], ) async def test_set_pending_dataset_tlvs_rejected_without_reason( aioclient_mock: AiohttpClientMocker, body: str, kwargs: dict ) -> None: """Test a rejection the border router did not explain.""" otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CONFLICT, **kwargs ) with pytest.raises(python_otbr_api.PendingDatasetRejectedError) as exc_info: await otbr.set_pending_dataset_tlvs(b"") assert exc_info.value.reason == "", body assert str(exc_info.value) == "the border router rejected the pending dataset" @pytest.mark.parametrize( "writer", ["create_pending_dataset", "set_pending_dataset_tlvs"] ) async def test_pending_dataset_write_aborted( aioclient_mock: AiohttpClientMocker, writer: str ) -> None: """Test a write aborted after it was sent is an unknown outcome, not refused. The router answers 409 "no longer attached" when the exchange with the leader was aborted after the dataset was sent, which the leader may have accepted. """ otbr = python_otbr_api.OTBR( BASE_URL, aioclient_mock.create_session(), key_format=KeyFormat.PASCAL_CASE ) aioclient_mock.put( f"{BASE_URL}/node/dataset/pending", json={"title": "Conflict", "status": 409, "detail": "no longer attached"}, status=HTTPStatus.CONFLICT, ) aioclient_mock.get(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.NO_CONTENT) with pytest.raises(python_otbr_api.PendingDatasetOutcomeUnknownError): if writer == "create_pending_dataset": await otbr.create_pending_dataset(python_otbr_api.PendingDataSet()) else: await otbr.set_pending_dataset_tlvs(b"") home-assistant-libs-python-otbr-api-9b796db/tests/test_pskc.py000066400000000000000000000020241526125473600246060ustar00rootroot00000000000000"""Test calculating PSKc.""" import pytest from python_otbr_api.pskc import compute_pskc @pytest.mark.parametrize( "ext_pan_id, network_name, passphrase, expected_pskc", [ # Example from https://openthread.io/guides/border-router/tools#pskc_generator ( bytes.fromhex("1234AAAA1234BBBB"), "MyOTBRNetwork", "J01NME", "ee4fb64e9341e13846bbe7e1c52b6785", ), # OTBR Web UI default ( bytes.fromhex("1111111122222222"), "OpenThreadDemo", "j01Nme", "445f2b5ca6f2a93a55ce570a70efeecb", ), # 128 bit key ( bytes.fromhex("1234AAAA1234BBBB"), "MyOTBRNetwork", "0123456789ABCDEF", "f1927f0ec11da1ac7ef4ee05e81fe0ce", ), ], ) def test_compute_pskc(ext_pan_id, network_name, passphrase, expected_pskc) -> None: """Test the TLV parser.""" assert expected_pskc == compute_pskc(ext_pan_id, network_name, passphrase).hex() home-assistant-libs-python-otbr-api-9b796db/tests/test_tlv_parser.py000066400000000000000000000312341526125473600260340ustar00rootroot00000000000000"""Test the Thread TLV parser.""" import pytest from python_otbr_api.tlv_parser import ( Timestamp, Channel, DelayTimer, MeshcopTLVItem, MeshcopTLVType, NetworkName, TLVError, encode_tlv, parse_tlv, ) # Shared dataset covering the newly added Meshcop TLV types. NEW_MESHCOP_DATASET: dict[MeshcopTLVType | int, MeshcopTLVItem] = { MeshcopTLVType.DURATION: MeshcopTLVItem( MeshcopTLVType.DURATION, bytes.fromhex("05") ), MeshcopTLVType.PROVISIONING_URL: MeshcopTLVItem( MeshcopTLVType.PROVISIONING_URL, "test".encode() ), MeshcopTLVType.VENDOR_NAME_TLV: MeshcopTLVItem( MeshcopTLVType.VENDOR_NAME_TLV, "ACME".encode() ), MeshcopTLVType.UDP_ENCAPSULATION_TLV: MeshcopTLVItem( MeshcopTLVType.UDP_ENCAPSULATION_TLV, bytes.fromhex("beef") ), MeshcopTLVType.IPV6_ADDRESS_TLV: MeshcopTLVItem( MeshcopTLVType.IPV6_ADDRESS_TLV, bytes.fromhex("20010db8000000000000000000000001"), ), MeshcopTLVType.PENDINGTIMESTAMP: Timestamp( MeshcopTLVType.PENDINGTIMESTAMP, bytes.fromhex("0000000000010000") ), MeshcopTLVType.DELAYTIMER: DelayTimer( MeshcopTLVType.DELAYTIMER, bytes.fromhex("00001388") ), MeshcopTLVType.COUNT: MeshcopTLVItem(MeshcopTLVType.COUNT, bytes.fromhex("03")), MeshcopTLVType.PERIOD: MeshcopTLVItem(MeshcopTLVType.PERIOD, bytes.fromhex("0032")), MeshcopTLVType.SCAN_DURATION: MeshcopTLVItem( MeshcopTLVType.SCAN_DURATION, bytes.fromhex("04") ), MeshcopTLVType.ENERGY_LIST: MeshcopTLVItem( MeshcopTLVType.ENERGY_LIST, bytes.fromhex("010203") ), MeshcopTLVType.THREAD_DOMAIN_NAME: MeshcopTLVItem( MeshcopTLVType.THREAD_DOMAIN_NAME, "home".encode() ), MeshcopTLVType.DISCOVERYREQUEST: MeshcopTLVItem( MeshcopTLVType.DISCOVERYREQUEST, bytes.fromhex("00") ), MeshcopTLVType.DISCOVERYRESPONSE: MeshcopTLVItem( MeshcopTLVType.DISCOVERYRESPONSE, bytes.fromhex("01") ), MeshcopTLVType.JOINERADVERTISEMENT: MeshcopTLVItem( MeshcopTLVType.JOINERADVERTISEMENT, bytes.fromhex("02") ), } # Expected TLV hex for NEW_MESHCOP_DATASET; order follows the dict insertion order. NEW_MESHCOP_DATASET_HEX = ( "170105200474657374210441434d453002beef311020010db8000000000000000000000001" "330800000000000100003404000013883601033702003238010439030102033b04686f6d65" "800100810101f10102" ) def test_encode_tlv() -> None: """Test the TLV parser.""" dataset = { MeshcopTLVType.ACTIVETIMESTAMP: MeshcopTLVItem( MeshcopTLVType.ACTIVETIMESTAMP, bytes.fromhex("0000000000010000") ), MeshcopTLVType.CHANNEL: MeshcopTLVItem( MeshcopTLVType.CHANNEL, bytes.fromhex("00000f") ), MeshcopTLVType.CHANNELMASK: MeshcopTLVItem( MeshcopTLVType.CHANNELMASK, bytes.fromhex("0004001fffe0") ), MeshcopTLVType.EXTPANID: MeshcopTLVItem( MeshcopTLVType.EXTPANID, bytes.fromhex("1111111122222222") ), MeshcopTLVType.MESHLOCALPREFIX: MeshcopTLVItem( MeshcopTLVType.MESHLOCALPREFIX, bytes.fromhex("fdad70bfe5aa15dd") ), MeshcopTLVType.NETWORKKEY: MeshcopTLVItem( MeshcopTLVType.NETWORKKEY, bytes.fromhex("00112233445566778899aabbccddeeff") ), MeshcopTLVType.NETWORKNAME: NetworkName( MeshcopTLVType.NETWORKNAME, "OpenThreadDemo".encode() ), MeshcopTLVType.PANID: MeshcopTLVItem( MeshcopTLVType.PANID, bytes.fromhex("1234") ), MeshcopTLVType.PSKC: MeshcopTLVItem( MeshcopTLVType.PSKC, bytes.fromhex("445f2b5ca6f2a93a55ce570a70efeecb") ), MeshcopTLVType.SECURITYPOLICY: MeshcopTLVItem( MeshcopTLVType.SECURITYPOLICY, bytes.fromhex("02a0f7f8") ), 189: MeshcopTLVItem(189, bytes.fromhex("abcdef")), } dataset_tlv = encode_tlv(dataset) assert ( dataset_tlv == ( "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD" "70BFE5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444" "656D6F010212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8BD03ABCDEF" ).lower() ) encoded_new_types = encode_tlv(NEW_MESHCOP_DATASET) assert encoded_new_types == NEW_MESHCOP_DATASET_HEX def test_parse_tlv() -> None: """Test the TLV parser.""" dataset_tlv = ( "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8BD03ABCDEF" ) dataset = parse_tlv(dataset_tlv) assert dataset == { MeshcopTLVType.CHANNEL: Channel( MeshcopTLVType.CHANNEL, bytes.fromhex("00000f") ), MeshcopTLVType.PANID: MeshcopTLVItem( MeshcopTLVType.PANID, bytes.fromhex("1234") ), MeshcopTLVType.EXTPANID: MeshcopTLVItem( MeshcopTLVType.EXTPANID, bytes.fromhex("1111111122222222") ), MeshcopTLVType.NETWORKNAME: NetworkName( MeshcopTLVType.NETWORKNAME, "OpenThreadDemo".encode() ), MeshcopTLVType.PSKC: MeshcopTLVItem( MeshcopTLVType.PSKC, bytes.fromhex("445f2b5ca6f2a93a55ce570a70efeecb") ), MeshcopTLVType.NETWORKKEY: MeshcopTLVItem( MeshcopTLVType.NETWORKKEY, bytes.fromhex("00112233445566778899aabbccddeeff") ), MeshcopTLVType.MESHLOCALPREFIX: MeshcopTLVItem( MeshcopTLVType.MESHLOCALPREFIX, bytes.fromhex("fdad70bfe5aa15dd") ), MeshcopTLVType.SECURITYPOLICY: MeshcopTLVItem( MeshcopTLVType.SECURITYPOLICY, bytes.fromhex("02a0f7f8") ), MeshcopTLVType.ACTIVETIMESTAMP: Timestamp( MeshcopTLVType.ACTIVETIMESTAMP, bytes.fromhex("0000000000010000") ), MeshcopTLVType.CHANNELMASK: MeshcopTLVItem( MeshcopTLVType.CHANNELMASK, bytes.fromhex("0004001fffe0") ), 189: MeshcopTLVItem(189, bytes.fromhex("abcdef")), } parsed_new_types = parse_tlv(NEW_MESHCOP_DATASET_HEX) assert parsed_new_types == NEW_MESHCOP_DATASET def test_parse_tlv_with_wakeup_channel() -> None: """Test the TLV parser from a (truncated) dataset from an Apple BR.""" dataset_tlv = ( "0e08000065901a07000000030000194a0300000f35060004001fffc003104d79486f6d65313233" "31323331323334" ) dataset = parse_tlv(dataset_tlv) assert dataset == { MeshcopTLVType.ACTIVETIMESTAMP: Timestamp( MeshcopTLVType.ACTIVETIMESTAMP, bytes.fromhex("000065901a070000") ), MeshcopTLVType.CHANNEL: Channel( MeshcopTLVType.CHANNEL, bytes.fromhex("000019") ), MeshcopTLVType.WAKEUP_CHANNEL: MeshcopTLVItem( MeshcopTLVType.WAKEUP_CHANNEL, bytes.fromhex("00000f") ), MeshcopTLVType.CHANNELMASK: MeshcopTLVItem( MeshcopTLVType.CHANNELMASK, bytes.fromhex("0004001fffc0") ), MeshcopTLVType.NETWORKNAME: NetworkName( MeshcopTLVType.NETWORKNAME, "MyHome1231231234".encode() ), } @pytest.mark.parametrize( "tlv, error, msg", ( ( "killevippen", TLVError, "invalid tlvs", ), ( "FF", TLVError, "truncated tlv header", ), ( "FF01", TLVError, "expected 1 bytes for tag 255, got 0", ), ( "030E4F70656E54687265616444656D", TLVError, "expected 14 bytes for tag , got 13", ), ( "030E4F70656E54687265616444656DFF", TLVError, "invalid network name '4f70656e54687265616444656dff'", ), ), ) def test_parse_tlv_error(tlv, error, msg) -> None: """Test the TLV parser error handling.""" with pytest.raises(error, match=msg): parse_tlv(tlv) def test_timestamp_parsing_full_integrity() -> None: """ Test parsing of a timestamp with mixed values for seconds, ticks, and authoritative. We construct a value to ensure no bit overlap: - Seconds: 400 (0x190) - Ticks: 32767 (0x7FFF, Max value to catch the masking bug) - Authoritative: True (1) Hex Construction: - Seconds (48 bits): 00 00 00 00 01 90 - Ticks/Auth (16 bits): (Ticks << 1) | Auth (0x7FFF << 1) | 1 => 0xFFFE | 1 => 0xFFFF Combined Hex: 000000000190FFFF """ timestamp_data = bytes.fromhex("000000000190FFFF") timestamp = Timestamp(MeshcopTLVType.ACTIVETIMESTAMP, timestamp_data) # 1. Check Seconds: Ensures the upper 48 bits are shifted correctly assert timestamp.seconds == 400 # 2. Check Ticks: Ensures the mask is 0x7FFF (32767) assert timestamp.ticks == 32767 # 3. Check Authoritative: Ensures the lowest bit is read correctly assert timestamp.authoritative is True def test_timestamp_from_values() -> None: """Test constructing a timestamp from field values.""" timestamp = Timestamp.from_values( MeshcopTLVType.ACTIVETIMESTAMP, seconds=400, ticks=32767, authoritative=True ) assert timestamp.data == bytes.fromhex("000000000190FFFF") assert timestamp.seconds == 400 assert timestamp.ticks == 32767 assert timestamp.authoritative is True pending = Timestamp.from_values(MeshcopTLVType.PENDINGTIMESTAMP, seconds=1) assert pending.tag == MeshcopTLVType.PENDINGTIMESTAMP assert pending.data == bytes.fromhex("0000000000010000") assert pending.ticks == 0 assert pending.authoritative is False # The largest encodable timestamp survives a roundtrip ceiling = Timestamp.from_values( MeshcopTLVType.ACTIVETIMESTAMP, seconds=2**48 - 1, ticks=2**15 - 1 ) assert ceiling.seconds == 2**48 - 1 assert ceiling.ticks == 2**15 - 1 @pytest.mark.parametrize( ("seconds", "ticks", "msg"), ( (2**48, 0, "timestamp seconds out of range"), (-1, 0, "timestamp seconds out of range"), (0, 2**15, "timestamp ticks out of range"), (0, -1, "timestamp ticks out of range"), ), ) def test_timestamp_from_values_out_of_range(seconds, ticks, msg) -> None: """Test constructing a timestamp from values which don't fit the wire format.""" with pytest.raises(TLVError, match=msg): Timestamp.from_values( MeshcopTLVType.ACTIVETIMESTAMP, seconds=seconds, ticks=ticks ) def test_timestamp_invalid_data() -> None: """Test a malformed timestamp raises TLVError, not struct.error.""" with pytest.raises(TLVError, match="invalid timestamp '00'"): Timestamp(MeshcopTLVType.ACTIVETIMESTAMP, bytes.fromhex("00")) # Also via the parser, as it would arrive from user supplied TLVs with pytest.raises(TLVError, match="invalid timestamp '00000000000100'"): parse_tlv("0E0700000000000100") def test_pending_timestamp_and_delay_timer_parsed() -> None: """Test PENDINGTIMESTAMP and DELAYTIMER decode to typed items.""" dataset = parse_tlv("33080000000000010000340400006699") pending = dataset[MeshcopTLVType.PENDINGTIMESTAMP] assert isinstance(pending, Timestamp) assert pending.seconds == 1 delay = dataset[MeshcopTLVType.DELAYTIMER] assert isinstance(delay, DelayTimer) assert delay.delay == 0x6699 def test_delay_timer_from_milliseconds() -> None: """Test constructing a delay timer from a delay in milliseconds.""" delay = DelayTimer.from_milliseconds(5 * 60 * 1000) assert delay.tag == MeshcopTLVType.DELAYTIMER assert delay.data == bytes.fromhex("000493E0") assert delay.delay == 300000 assert encode_tlv({MeshcopTLVType.DELAYTIMER: delay}) == "3404000493e0" @pytest.mark.parametrize("delay", (2**32, -1)) def test_delay_timer_out_of_range(delay) -> None: """Test constructing a delay timer which doesn't fit the wire format.""" with pytest.raises(TLVError, match="delay timer out of range"): DelayTimer.from_milliseconds(delay) def test_delay_timer_invalid_data() -> None: """Test a malformed delay timer raises TLVError, not struct.error.""" with pytest.raises(TLVError, match="invalid delay timer '00'"): DelayTimer(MeshcopTLVType.DELAYTIMER, bytes.fromhex("00")) # Also via the parser, as it would arrive from user supplied TLVs with pytest.raises(TLVError, match="invalid delay timer '000000'"): parse_tlv("3403000000") def test_unknown_tlv_value_not_logged(caplog: pytest.LogCaptureFixture) -> None: """Test the unknown-TLV warning does not log the value.""" parse_tlv("BD03ABCDEF") assert "unknown TLV type 189 (3 bytes)" in caplog.text assert "abcdef" not in caplog.text.lower() home-assistant-libs-python-otbr-api-9b796db/tests/test_util/000077500000000000000000000000001526125473600242535ustar00rootroot00000000000000home-assistant-libs-python-otbr-api-9b796db/tests/test_util/__init__.py000066400000000000000000000000221526125473600263560ustar00rootroot00000000000000"""Test utils.""" home-assistant-libs-python-otbr-api-9b796db/tests/test_util/aiohttp.py000066400000000000000000000216121526125473600262770ustar00rootroot00000000000000"""Aiohttp test utils.""" import asyncio from contextlib import contextmanager from http import HTTPStatus from json import dumps as json_dumps, loads as json_loads import re from unittest import mock from urllib.parse import parse_qs from aiohttp import ClientSession from aiohttp.client_exceptions import ClientError, ClientResponseError from aiohttp.streams import StreamReader from multidict import CIMultiDict from yarl import URL RETYPE = type(re.compile("")) # pylint: disable=unused-argument, too-many-arguments, too-many-instance-attributes def mock_stream(data): """Mock a stream with data.""" protocol = mock.Mock(_reading_paused=False) stream = StreamReader(protocol, limit=2**16) stream.feed_data(data) stream.feed_eof() return stream class AiohttpClientMocker: """Mock Aiohttp client requests.""" def __init__(self): """Initialize the request mocker.""" self._mocks = [] self._cookies = {} self.mock_calls = [] self.mock_timeouts = [] def request( self, method, url, *, auth=None, status=HTTPStatus.OK, text=None, data=None, content=None, json=None, params=None, headers=None, exc=None, cookies=None, side_effect=None, ): """Mock a request.""" if headers is None: headers = {} if not isinstance(url, RETYPE): url = URL(url) if params: url = url.with_query(params) self._mocks.append( AiohttpClientMockResponse( method=method, url=url, status=status, response=content, json=json, text=text, cookies=cookies, exc=exc, headers=headers, side_effect=side_effect, ) ) def get(self, *args, **kwargs): """Register a mock get request.""" self.request("get", *args, **kwargs) def put(self, *args, **kwargs): """Register a mock put request.""" self.request("put", *args, **kwargs) def post(self, *args, **kwargs): """Register a mock post request.""" self.request("post", *args, **kwargs) def delete(self, *args, **kwargs): """Register a mock delete request.""" self.request("delete", *args, **kwargs) def options(self, *args, **kwargs): """Register a mock options request.""" self.request("options", *args, **kwargs) def patch(self, *args, **kwargs): """Register a mock patch request.""" self.request("patch", *args, **kwargs) @property def call_count(self): """Return the number of requests made.""" return len(self.mock_calls) def clear_requests(self): """Reset mock calls.""" self._mocks.clear() self._cookies.clear() self.mock_calls.clear() def create_session(self): """Create a ClientSession that is bound to this mocker.""" session = ClientSession(json_serialize=json_dumps) # Setting directly on `session` will raise deprecation warning object.__setattr__(session, "_request", self.match_request) return session async def match_request( self, method, url, *, data=None, auth=None, params=None, headers=None, allow_redirects=None, timeout=None, json=None, cookies=None, **kwargs, ): """Match a request against pre-registered requests.""" data = data or json url = URL(url) if params: url = url.with_query(params) for response in self._mocks: if response.match_request(method, url, params): self.mock_calls.append((method, url, data, headers)) self.mock_timeouts.append(timeout) if response.side_effect: response = await response.side_effect(method, url, data) if response.exc: raise response.exc return response raise AssertionError(f"No mock registered for {method.upper()} {url} {params}") class AiohttpClientMockResponse: """Mock Aiohttp client response.""" def __init__( self, method, url, *, status=HTTPStatus.OK, response=None, json=None, text=None, cookies=None, exc=None, headers=None, side_effect=None, ): """Initialize a fake response.""" if json is not None: text = json_dumps(json) if text is not None: response = text.encode("utf-8") if response is None: response = b"" self.charset = "utf-8" self.method = method self._url = url self.status = status self.response = response self.exc = exc self.side_effect = side_effect self._headers = CIMultiDict(headers or {}) self._cookies = {} if cookies: for name, data in cookies.items(): cookie = mock.MagicMock() cookie.value = data self._cookies[name] = cookie def match_request(self, method, url, params=None): """Test if response answers request.""" if method.lower() != self.method.lower(): return False # regular expression matching if isinstance(self._url, RETYPE): return self._url.search(str(url)) is not None if ( self._url.scheme != url.scheme or self._url.host != url.host or self._url.path != url.path ): return False # Ensure all query components in matcher are present in the request request_qs = parse_qs(url.query_string) matcher_qs = parse_qs(self._url.query_string) for key, vals in matcher_qs.items(): for val in vals: try: request_qs.get(key, []).remove(val) except ValueError: return False return True @property def headers(self): """Return content_type.""" return self._headers @property def cookies(self): """Return dict of cookies.""" return self._cookies @property def url(self): """Return yarl of URL.""" return self._url @property def content_type(self): """Return yarl of URL.""" return self._headers.get("content-type") @property def content(self): """Return content.""" return mock_stream(self.response) async def read(self): """Return mock response.""" return self.response async def text(self, encoding="utf-8", errors="strict"): """Return mock response as a string.""" return self.response.decode(encoding, errors=errors) async def json(self, encoding="utf-8", content_type=None, loads=json_loads): """Return mock response as a json.""" return loads(self.response.decode(encoding)) def release(self): """Mock release.""" def raise_for_status(self): """Raise error if status is 400 or higher.""" if self.status >= 400: request_info = mock.Mock(real_url="http://example.com") raise ClientResponseError( request_info=request_info, history=None, status=self.status, headers=self.headers, ) def close(self): """Mock close.""" @contextmanager def mock_aiohttp_client(): """Context manager to mock aiohttp client.""" mocker = AiohttpClientMocker() yield mocker class MockLongPollSideEffect: """Imitate a long_poll request. It should be created and used as a side effect for a GET/PUT/etc. request. Once created, actual responses are queued with queue_response If queue is empty, will await until done. """ def __init__(self): """Initialize the queue.""" self.semaphore = asyncio.Semaphore(0) self.response_list = [] self.stopping = False async def __call__(self, method, url, data): """Fetch the next response from the queue or wait until the queue has items.""" if self.stopping: raise ClientError() await self.semaphore.acquire() kwargs = self.response_list.pop(0) return AiohttpClientMockResponse(method=method, url=url, **kwargs) def queue_response(self, **kwargs): """Add a response to the long_poll queue.""" self.response_list.append(kwargs) self.semaphore.release() def stop(self): """Stop the current request and future ones. This avoids an exception if there is someone waiting when exiting test. """ self.stopping = True self.queue_response(exc=ClientError()) home-assistant-libs-python-otbr-api-9b796db/tests/test_well_known.py000066400000000000000000000066301526125473600260340ustar00rootroot00000000000000"""Tests for the /.well-known/thread/br-rest API discovery endpoint.""" from http import HTTPStatus import pytest import python_otbr_api from tests.test_util.aiohttp import AiohttpClientMocker BASE_URL = "http://core-openthread-border-router:8081" WELL_KNOWN_JSON = { "api": {"version": "0.3.0", "base": "/api/"}, "links": [ { "href": "/.well-known/thread/br-rest", "rel": "self", "type": ["application/json"], }, {"href": "/api/node", "rel": "node", "type": ["application/vnd.api+json"]}, {"href": "/api/actions", "rel": "task", "type": ["application/vnd.api+json"]}, { "href": "/api/devices", "rel": "device", "type": ["application/vnd.api+json"], }, { "href": "/api/diagnostics", "rel": "diagnostic", "type": ["application/vnd.api+json"], }, ], } def _otbr(aioclient_mock: AiohttpClientMocker) -> python_otbr_api.OTBR: return python_otbr_api.OTBR(BASE_URL, aioclient_mock.create_session()) async def test_get_api_version(aioclient_mock: AiohttpClientMocker) -> None: """A 200 with a version resource returns the advertised semver string.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/.well-known/thread/br-rest", json=WELL_KNOWN_JSON) assert await otbr.get_api_version() == "0.3.0" async def test_get_api_version_not_supported( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 means the router predates ot-br-posix #3330: version is unknown.""" otbr = _otbr(aioclient_mock) aioclient_mock.get( f"{BASE_URL}/.well-known/thread/br-rest", status=HTTPStatus.NOT_FOUND ) assert await otbr.get_api_version() is None async def test_get_api_version_unexpected_status( aioclient_mock: AiohttpClientMocker, ) -> None: """Any other non-200 status raises OTBRError.""" otbr = _otbr(aioclient_mock) aioclient_mock.get( f"{BASE_URL}/.well-known/thread/br-rest", status=HTTPStatus.INTERNAL_SERVER_ERROR, ) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_api_version() async def test_get_api_version_malformed_body( aioclient_mock: AiohttpClientMocker, ) -> None: """A 200 without the expected `api.version` field raises OTBRError.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/.well-known/thread/br-rest", json={"api": {}}) with pytest.raises(python_otbr_api.OTBRError): await otbr.get_api_version() async def test_get_api_version_runs_once(aioclient_mock: AiohttpClientMocker) -> None: """Detection happens lazily on first call and is cached for subsequent calls.""" otbr = _otbr(aioclient_mock) aioclient_mock.get(f"{BASE_URL}/.well-known/thread/br-rest", json=WELL_KNOWN_JSON) assert await otbr.get_api_version() == "0.3.0" assert await otbr.get_api_version() == "0.3.0" assert aioclient_mock.call_count == 1 async def test_get_api_version_not_supported_runs_once( aioclient_mock: AiohttpClientMocker, ) -> None: """A 404 result is cached too, so repeated calls don't re-probe.""" otbr = _otbr(aioclient_mock) aioclient_mock.get( f"{BASE_URL}/.well-known/thread/br-rest", status=HTTPStatus.NOT_FOUND ) assert await otbr.get_api_version() is None assert await otbr.get_api_version() is None assert aioclient_mock.call_count == 1