├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.md ├── README.md ├── aiohomekit ├── __init__.py ├── __main__.py ├── characteristic_cache.py ├── const.py ├── controller │ ├── __init__.py │ ├── abstract.py │ ├── ble │ │ ├── __init__.py │ │ ├── bleak.py │ │ ├── client.py │ │ ├── connection.py │ │ ├── const.py │ │ ├── controller.py │ │ ├── discovery.py │ │ ├── key.py │ │ ├── manufacturer_data.py │ │ ├── pairing.py │ │ ├── structs.py │ │ └── values.py │ ├── coap │ │ ├── __init__.py │ │ ├── connection.py │ │ ├── controller.py │ │ ├── discovery.py │ │ ├── pairing.py │ │ ├── pdu.py │ │ └── structs.py │ ├── controller.py │ └── ip │ │ ├── __init__.py │ │ ├── connection.py │ │ ├── controller.py │ │ ├── discovery.py │ │ └── pairing.py ├── crypto │ ├── __init__.py │ ├── chacha20poly1305.py │ ├── hkdf.py │ └── srp.py ├── enum.py ├── exceptions.py ├── hkjson.py ├── http │ ├── __init__.py │ └── response.py ├── meshcop.py ├── model │ ├── __init__.py │ ├── categories.py │ ├── characteristics │ │ ├── __init__.py │ │ ├── characteristic.py │ │ ├── characteristic_formats.py │ │ ├── characteristic_types.py │ │ ├── const.py │ │ ├── data.py │ │ ├── permissions.py │ │ ├── structs.py │ │ ├── types.py │ │ └── units.py │ ├── entity_map.py │ ├── feature_flags.py │ ├── services │ │ ├── __init__.py │ │ ├── data.py │ │ ├── service.py │ │ ├── service_types.py │ │ └── types.py │ └── status_flags.py ├── pdu.py ├── protocol │ ├── __init__.py │ ├── statuscodes.py │ └── tlv.py ├── py.typed ├── testing.py ├── tlv8.py ├── utils.py ├── uuid.py └── zeroconf.py ├── codecov.yml ├── poetry.lock ├── pylintrc ├── pyproject.toml ├── scripts ├── generate_metadata.py ├── release.sh └── test.sh ├── setup.cfg └── tests ├── __init__.py ├── accessoryserver.py ├── conftest.py ├── fixtures ├── aqara_gateway.json ├── camera.json ├── ecobee3.json ├── ecobee3_no_sensors.json ├── eve_energy.json ├── home_assistant_bridge_camera.json ├── home_assistant_bridge_fan.json ├── hue_bridge.json ├── idevices_switch.json ├── koogeek_ls1.json ├── lennox_e30.json ├── nanoleaf_bulb.json ├── simpleconnect_fan.json ├── synthetic_float_minstep.json └── vocolinc_flowerbud.json ├── test_coap_structs.py ├── test_controller.py ├── test_controller_ble.py ├── test_controller_coap.py ├── test_controller_coap_pdu.py ├── test_controller_coap_structs.py ├── test_controller_ip_controller.py ├── test_crypto_chacha20poly1305.py ├── test_crypto_hkdf.py ├── test_crypto_srp.py ├── test_enum.py ├── test_hkjson.py ├── test_http.py ├── test_http_response.py ├── test_ip_discovery.py ├── test_ip_pairing.py ├── test_main.py ├── test_meshcop.py ├── test_model.py ├── test_pdu.py ├── test_protocol_tlv.py ├── test_statuscodes.py ├── test_testing.py ├── test_tlv8.py ├── test_utils.py └── test_zeroconf.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | # Regexes for lines to exclude from consideration 3 | exclude_lines = 4 | # Have to re-enable the standard pragma 5 | pragma: no cover 6 | 7 | # Don't complain about missing debug-only code: 8 | def __repr__ 9 | 10 | # Don't complain if tests don't hit defensive assertion code: 11 | raise AssertionError 12 | raise NotImplementedError 13 | 14 | # TYPE_CHECKING and @overload blocks are never executed during pytest run 15 | if TYPE_CHECKING: 16 | @overload 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "chore(deps-ci): " 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" 18 | - package-ecosystem: "pip" # See documentation for possible values 19 | directory: "/" # Location of package manifests 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: ~ 8 | 9 | jobs: 10 | lint: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out code from GitHub 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | 18 | - name: Install poetry 19 | run: pipx install poetry 20 | 21 | - name: Set up Python 3.11 22 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 23 | with: 24 | python-version: 3.11 25 | cache: "poetry" 26 | 27 | - name: Install dependencies 28 | shell: bash 29 | run: poetry install 30 | 31 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 32 | 33 | - name: Run poetry build 34 | shell: bash 35 | run: poetry build 36 | 37 | - name: Run poetry check 38 | shell: bash 39 | run: poetry check 40 | 41 | tests: 42 | name: pytest/${{ matrix.os }}/${{ matrix.python-version }} 43 | runs-on: ${{ matrix.os }}-latest 44 | 45 | strategy: 46 | matrix: 47 | os: [Ubuntu, MacOS, Windows] 48 | python-version: ["3.10", "3.11", "3.12", "3.13"] 49 | 50 | env: 51 | OS: ${{ matrix.os }} 52 | PYTHON: ${{ matrix.python-version }} 53 | 54 | steps: 55 | - name: Check out code from GitHub 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 57 | 58 | - name: Install poetry 59 | run: pipx install poetry 60 | 61 | - name: Set up Python ${{ matrix.python-version }} 62 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 63 | with: 64 | python-version: ${{ matrix.python-version }} 65 | cache: "poetry" 66 | 67 | - name: Get full Python version 68 | id: full-python-version 69 | shell: bash 70 | run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") 71 | 72 | - name: Install dependencies 73 | shell: bash 74 | run: poetry install 75 | 76 | - name: Run pytest 77 | shell: bash 78 | run: poetry run python -m pytest --cov=. --cov-report=xml 79 | 80 | - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 81 | env: 82 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 📦 11 | runs-on: ubuntu-latest 12 | outputs: 13 | tag: ${{ steps.tag.outputs.tag }} 14 | 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | - name: Get tag 18 | id: tag 19 | run: | 20 | echo ::set-output name=tag::${GITHUB_REF#refs/tags/} 21 | - name: Install poetry 22 | run: pipx install poetry 23 | - name: Set up Python 3.11 24 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 25 | with: 26 | python-version: 3.11 27 | cache: "poetry" 28 | - name: Build a binary wheel and a source tarball 29 | run: poetry build 30 | - name: Store the distribution packages 31 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 32 | with: 33 | name: python-package-distributions 34 | path: dist/ 35 | 36 | deploy_pypi: 37 | permissions: 38 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 39 | runs-on: ubuntu-latest 40 | needs: 41 | - build 42 | name: >- 43 | Publish Python 🐍 distribution 📦 to PyPI 44 | environment: 45 | name: release 46 | url: https://pypi.org/p/aiohomekit 47 | 48 | steps: 49 | - name: Download all the dists 50 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 51 | with: 52 | name: python-package-distributions 53 | path: dist/ 54 | - name: Publish package distributions to PyPI 55 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1 56 | 57 | deploy_github: 58 | runs-on: ubuntu-latest 59 | permissions: 60 | id-token: write # IMPORTANT: this permission is mandatory for sigstore signing 61 | contents: write # for creating a release 62 | needs: 63 | - build 64 | name: >- 65 | Publish Python 🐍 distribution 📦 to GitHub 66 | environment: 67 | name: release 68 | steps: 69 | - name: Download all the dists 70 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 71 | with: 72 | name: python-package-distributions 73 | path: dist/ 74 | - name: Collected dists 75 | run: | 76 | tree dist 77 | - name: Sign the dists with Sigstore 78 | uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 79 | with: 80 | inputs: >- 81 | ./dist/*.tar.gz 82 | ./dist/*.whl 83 | 84 | - name: GitHub Release 85 | uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 86 | with: 87 | tag_name: ${{ needs.build.outputs.tag }} 88 | files: dist/** 89 | token: ${{ secrets.GITHUB_TOKEN }} 90 | draft: false 91 | prerelease: false 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .vscode 3 | 4 | build 5 | dist 6 | venv 7 | 8 | __pycache__ 9 | 10 | *.egg-info 11 | *.pyc 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc" 4 | default_stages: [pre-commit] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: debug-statements 11 | - id: check-builtin-literals 12 | - id: check-case-conflict 13 | - id: check-docstring-first 14 | - id: check-json 15 | - id: check-toml 16 | - id: check-xml 17 | - id: check-yaml 18 | - id: detect-private-key 19 | - id: end-of-file-fixer 20 | - id: trailing-whitespace 21 | - repo: https://github.com/python-poetry/poetry 22 | rev: 2.1.3 23 | hooks: 24 | - id: poetry-check 25 | - repo: https://github.com/pre-commit/mirrors-prettier 26 | rev: v4.0.0-alpha.8 27 | hooks: 28 | - id: prettier 29 | args: ["--tab-width", "2"] 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.20.0 32 | hooks: 33 | - id: pyupgrade 34 | args: [--py39-plus] 35 | - repo: https://github.com/astral-sh/ruff-pre-commit 36 | rev: v0.11.11 37 | hooks: 38 | # - id: ruff 39 | # args: [--fix, --exit-non-zero-on-fix] 40 | # Run the formatter. 41 | - id: ruff-format 42 | - repo: https://github.com/PyCQA/flake8 43 | rev: 7.2.0 44 | hooks: 45 | - id: flake8 46 | # - repo: https://github.com/codespell-project/codespell 47 | # rev: v2.4.1 48 | # hooks: 49 | # - id: codespell 50 | # - repo: https://github.com/pre-commit/mirrors-mypy 51 | # rev: v1.15.0 52 | # hooks: 53 | # - id: mypy 54 | # additional_dependencies: [] 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiohomekit 2 | 3 | ![CI](https://github.com/Jc2k/aiohomekit/workflows/CI/badge.svg?event=push) [![codecov](https://codecov.io/gh/Jc2k/aiohomekit/branch/master/graph/badge.svg)](https://codecov.io/gh/Jc2k/aiohomekit) 4 | 5 | This library implements the HomeKit protocol for controlling Homekit accessories using asyncio. 6 | 7 | It's primary use is for with Home Assistant. We target the same versions of python as them and try to follow their code standards. 8 | 9 | At the moment we don't offer any API guarantees. API stability and documentation will happen after we are happy with how things are working within Home Assistant. 10 | 11 | ## Contributing 12 | 13 | `aiohomekit` is primarily for use with Home Assistant. Lots of users are using it with devices from a wide array of vendors. As a community open source project we do not have the hardware or time resources to certify every device with multiple vendors projects. We may be conservative about larger changes or changes that are low level. We do ask where possible that any changes should be tested with a certified HomeKit implementations of shipping products, not just against emulators or other uncertified implementations. 14 | 15 | Because API breaking changes would hamper our ability to quickly update Home Assistant to the latest code, if you can please submit a PR to update [homekit_controller](https://github.com/home-assistant/core/tree/dev/homeassistant/components/homekit_controller) too. If they don't your PR maybe on hold until someone is available to write such a PR. 16 | 17 | Please bear in mind that some shipping devices interpret the HAP specification loosely. In general we prefer to match the behaviour of real HAP controllers even where their behaviour is not strictly specified. Here are just some of the kinds of problems we've had to work around: 18 | 19 | - Despite the precise formatting of JSON being unspecified, there are devices in the wild that cannot handle spaces when parsing JSON. For example, `{"foo": "bar"}` vs `{"foo":"bar"}`. This means we never use a "pretty" encoding of JSON. 20 | - Despite a boolean being explicitly defined as `0`, `1`, `true` or `false` in the spec, some devices only support 3 of the 4. This means booleans must be encoded as `0` or `1`. 21 | - Some devices have shown themselves to be sensitive to headers being missing, in the wrong order or if there are extra headers. So we ensure that only the headers iOS sends are present, and that the casing and ordering is the same. 22 | - Some devices are sensitive to a HTTP message being split into separate TCP packets. So we take care to only write a full message to the network stack. 23 | 24 | And so on. As a rule we need to be strict about what we send and loose about what we receive. 25 | 26 | ## Device compatibility 27 | 28 | `aiohomekit` is primarily tested via Home Assistant with a Phillips Hue bridge and an Eve Extend bridge. It is known to work to some extent with many more devices though these are not currently explicitly documented anywhere at the moment. 29 | 30 | You can look at the problems your device has faced in the home-assistant [issues list](https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+homekit_controller%22). 31 | 32 | ## FAQ 33 | 34 | ### How do I use this? 35 | 36 | It's published on pypi as `aiohomekit` but its still under early development - proceed with caution. 37 | 38 | The main consumer of the API is the [homekit_controller](https://github.com/home-assistant/core/tree/dev/homeassistant/components/homekit_controller) in Home Assistant so that's the best place to get a sense of the API. 39 | 40 | ### Does this support BLE accessories? 41 | 42 | No. Eventually we hope to via [aioble](https://github.com/detectlabs/aioble) which provides an asyncio bluetooth abstraction that works on Linux, macOS and Windows. 43 | 44 | ### Can i use this to make a homekit accessory? 45 | 46 | No, this is just the client part. You should use one the of other implementations: 47 | 48 | - [homekit_python](https://github.com/jlusiardi/homekit_python/) (this is used a lot during aiohomekit development) 49 | - [HAP-python](https://github.com/ikalchev/HAP-python) 50 | 51 | ### Why doesn't Home Assistant use library X instead? 52 | 53 | At the time of writing this is the only python 3.7/3.8 asyncio HAP client with events support. 54 | 55 | ### Why doesn't aiohomekit use library X instead? 56 | 57 | Where possible aiohomekit uses libraries that are easy to install with pip, are ready available as wheels (including on Raspberry Pi via piwheels), are cross platform (including Windows) and are already used by Home Assistant. They should not introduce hard dependencies on uncommon system libraries. The intention here is to avoid any difficulty in the Home Assistant build process. 58 | 59 | People are often alarmed at the hand rolled HTTP code and suggest using an existing HTTP library like `aiohttp`. High level HTTP libraries are pretty much a non-starter because: 60 | 61 | - Of the difficulty of adding in HAP session security without monkey patches. 62 | - They don't expect responses without requests (i.e. events). 63 | - As mentioned above, some of these devices are very sensitive. We don't care if your change is compliant with every spec if it still makes a real world device cry. We are not in a position to demand these devices be fixed. So instead we strive for byte-for-byte accuracy on our write path. Any library would need to give us that flexibility. 64 | - Some parts of the responses are actually not HTTP, even though they look it. 65 | 66 | We are also just reluctant to make a change that large for something that is working with a lot of devices. There is a big chance of introducing a regression. 67 | 68 | Of course a working proof of concept (using a popular well maintained library) that has been tested with something like a Tado internet bridge (including events) would be interesting. 69 | 70 | ## Thanks 71 | 72 | This library wouldn't have been possible without homekit_python, a synchronous implementation of both the client and server parts of HAP. 73 | -------------------------------------------------------------------------------- /aiohomekit/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | __all__ = [ 18 | "AccessoryDisconnectedError", 19 | "AccessoryNotFoundError", 20 | "AlreadyPairedError", 21 | "AuthenticationError", 22 | "BackoffError", 23 | "BluetoothAdapterError", 24 | "BusyError", 25 | "CharacteristicPermissionError", 26 | "ConfigLoadingError", 27 | "ConfigSavingError", 28 | "ConfigurationError", 29 | "Controller", 30 | "FormatError", 31 | "HomeKitException", 32 | "HttpException", 33 | "IncorrectPairingIdError", 34 | "InvalidAuthTagError", 35 | "InvalidError", 36 | "InvalidSignatureError", 37 | "MaxPeersError", 38 | "MaxTriesError", 39 | "ProtocolError", 40 | "RequestRejected", 41 | "UnavailableError", 42 | "UnknownError", 43 | "UnpairedError", 44 | ] 45 | 46 | from .controller import Controller 47 | from .exceptions import ( 48 | AccessoryDisconnectedError, 49 | AccessoryNotFoundError, 50 | AlreadyPairedError, 51 | AuthenticationError, 52 | BackoffError, 53 | BluetoothAdapterError, 54 | BusyError, 55 | CharacteristicPermissionError, 56 | ConfigLoadingError, 57 | ConfigSavingError, 58 | ConfigurationError, 59 | FormatError, 60 | HomeKitException, 61 | HttpException, 62 | IncorrectPairingIdError, 63 | InvalidAuthTagError, 64 | InvalidError, 65 | InvalidSignatureError, 66 | MaxPeersError, 67 | MaxTriesError, 68 | ProtocolError, 69 | RequestRejected, 70 | UnavailableError, 71 | UnknownError, 72 | UnpairedError, 73 | ) 74 | -------------------------------------------------------------------------------- /aiohomekit/characteristic_cache.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """ 17 | Mechanism to cache characteristic database. 18 | 19 | It is slow to query the BLE characteristics to find their iid and 20 | signatures. We only need to do this work when the cn has incremented. 21 | 22 | This interface must be kept compatible with Home Assistant. This is a 23 | dumb implementation for development and CLI usage. 24 | """ 25 | 26 | from __future__ import annotations 27 | 28 | import logging 29 | import pathlib 30 | from typing import Any, Protocol, TypedDict 31 | 32 | from aiohomekit import hkjson 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class Pairing(TypedDict): 38 | """A versioned map of entity metadata as presented by aiohomekit.""" 39 | 40 | config_num: int 41 | accessories: list[Any] 42 | broadcast_key: str | None 43 | state_num: int | None 44 | 45 | 46 | class StorageLayout(TypedDict): 47 | """Cached pairing metadata needed by aiohomekit.""" 48 | 49 | pairings: dict[str, Pairing] 50 | 51 | 52 | class CharacteristicCacheType(Protocol): 53 | def get_map(self, homekit_id: str) -> Pairing | None: 54 | pass 55 | 56 | def async_create_or_update_map( 57 | self, 58 | homekit_id: str, 59 | config_num: int, 60 | accessories: list[Any], 61 | broadcast_key: str | None = None, 62 | state_num: int | None = None, 63 | ) -> Pairing: 64 | pass 65 | 66 | def async_delete_map(self, homekit_id: str) -> None: 67 | pass 68 | 69 | 70 | class CharacteristicCacheMemory: 71 | def __init__(self) -> None: 72 | """Create a new entity map store.""" 73 | self.storage_data: dict[str, Pairing] = {} 74 | 75 | def get_map(self, homekit_id: str) -> Pairing | None: 76 | """Get a pairing cache item.""" 77 | return self.storage_data.get(homekit_id) 78 | 79 | def async_create_or_update_map( 80 | self, 81 | homekit_id: str, 82 | config_num: int, 83 | accessories: list[Any], 84 | broadcast_key: str | None = None, 85 | state_num: int | None = None, 86 | ) -> Pairing: 87 | """Create a new pairing cache.""" 88 | data = Pairing( 89 | config_num=config_num, 90 | accessories=accessories, 91 | broadcast_key=broadcast_key, 92 | state_num=state_num, 93 | ) 94 | self.storage_data[homekit_id] = data 95 | return data 96 | 97 | def async_delete_map(self, homekit_id: str) -> None: 98 | """Delete pairing cache.""" 99 | if homekit_id not in self.storage_data: 100 | return 101 | 102 | self.storage_data.pop(homekit_id) 103 | 104 | 105 | class CharacteristicCacheFile(CharacteristicCacheMemory): 106 | def __init__(self, location: pathlib.Path) -> None: 107 | """Create a new entity map store.""" 108 | super().__init__() 109 | 110 | self.location = location 111 | if location.exists(): 112 | with open(location, encoding="utf-8") as fp: 113 | try: 114 | self.storage_data = hkjson.loads(fp.read())["pairings"] 115 | except hkjson.JSON_DECODE_EXCEPTIONS: 116 | logger.debug("Characteristic cache was corrupted, proceeding with cold cache") 117 | 118 | def async_create_or_update_map( 119 | self, 120 | homekit_id: str, 121 | config_num: int, 122 | accessories: list[Any], 123 | broadcast_key: bytes | None = None, 124 | state_num: int | None = None, 125 | ) -> Pairing: 126 | """Create a new pairing cache.""" 127 | data = super().async_create_or_update_map( 128 | homekit_id, config_num, accessories, broadcast_key, state_num 129 | ) 130 | self._do_save() 131 | return data 132 | 133 | def async_delete_map(self, homekit_id: str) -> None: 134 | """Delete pairing cache.""" 135 | super().async_delete_map(homekit_id) 136 | self._do_save() 137 | 138 | def _do_save(self) -> None: 139 | """Schedule saving the entity map cache.""" 140 | with open(self.location, mode="w", encoding="utf-8") as fp: 141 | fp.write(hkjson.dumps(self._data_to_save())) 142 | 143 | def _data_to_save(self) -> dict[str, Any]: 144 | """Return data of entity map to store in a file.""" 145 | return StorageLayout(pairings=self.storage_data) 146 | -------------------------------------------------------------------------------- /aiohomekit/const.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | import sys 19 | 20 | BLE_TRANSPORT_SUPPORTED = False 21 | COAP_TRANSPORT_SUPPORTED = False 22 | IP_TRANSPORT_SUPPORTED = False 23 | 24 | if "bleak" in sys.modules: 25 | BLE_TRANSPORT_SUPPORTED = True 26 | else: 27 | try: 28 | if "AIOHOMEKIT_TRANSPORT_BLE" in os.environ: 29 | __import__("bleak") 30 | BLE_TRANSPORT_SUPPORTED = True 31 | except ModuleNotFoundError: 32 | pass 33 | 34 | 35 | try: 36 | __import__("aiocoap") 37 | COAP_TRANSPORT_SUPPORTED = True 38 | except ModuleNotFoundError: 39 | pass 40 | 41 | try: 42 | __import__("zeroconf") 43 | IP_TRANSPORT_SUPPORTED = True 44 | except ModuleNotFoundError: 45 | pass 46 | -------------------------------------------------------------------------------- /aiohomekit/controller/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | __all__ = ["Controller", "TransportType"] 18 | 19 | from .abstract import TransportType 20 | from .controller import Controller 21 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from .discovery import BleDiscovery 18 | from .pairing import BlePairing 19 | 20 | __all__ = [ 21 | "BleDiscovery", 22 | "BlePairing", 23 | ] 24 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/connection.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from __future__ import annotations 18 | 19 | from collections.abc import Callable 20 | 21 | from bleak.backends.device import BLEDevice 22 | from bleak_retry_connector import ( 23 | BleakAbortedError, 24 | BleakConnectionError, 25 | BleakError, 26 | BleakNotFoundError, 27 | ) 28 | from bleak_retry_connector import ( 29 | establish_connection as retry_establish_connection, 30 | ) 31 | 32 | from aiohomekit.exceptions import AccessoryDisconnectedError, AccessoryNotFoundError 33 | 34 | from .bleak import AIOHomeKitBleakClient 35 | 36 | MAX_CONNECT_ATTEMPTS = 5 37 | 38 | 39 | async def establish_connection( 40 | device: BLEDevice, 41 | name: str, 42 | disconnected_callback: Callable[[AIOHomeKitBleakClient], None], 43 | max_attempts: int | None = None, 44 | use_services_cache: bool = False, 45 | ble_device_callback: Callable[[BLEDevice], None] = None, 46 | ) -> AIOHomeKitBleakClient: 47 | """Establish a connection to the accessory.""" 48 | try: 49 | return await retry_establish_connection( 50 | AIOHomeKitBleakClient, 51 | device, 52 | name, 53 | disconnected_callback, 54 | max_attempts=max_attempts or MAX_CONNECT_ATTEMPTS, 55 | use_services_cache=use_services_cache, 56 | ble_device_callback=ble_device_callback, 57 | ) 58 | except (BleakAbortedError, BleakConnectionError) as ex: 59 | raise AccessoryDisconnectedError(ex) from ex 60 | except BleakNotFoundError as ex: 61 | raise AccessoryNotFoundError(ex) from ex 62 | except BleakError as ex: 63 | raise AccessoryDisconnectedError(ex) from ex 64 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/const.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from enum import IntEnum 18 | 19 | HAP_MIN_REQUIRED_MTU = 100 20 | 21 | 22 | class AdditionalParameterTypes(IntEnum): 23 | # Additional Parameter Types for BLE (Table 6-9 page 98) 24 | Value = 0x01 25 | AdditionalAuthorizationData = 0x02 26 | Origin = 0x03 27 | CharacteristicType = 0x04 28 | CharacteristicInstanceId = 0x05 29 | ServiceType = 0x06 30 | ServiceInstanceId = 0x07 31 | TTL = 0x08 32 | ParamReturnResponse = 0x09 33 | HAPCharacteristicPropertiesDescriptor = 0x0A 34 | GATTUserDescriptionDescriptor = 0x0B 35 | GATTPresentationFormatDescriptor = 0x0C 36 | GATTValidRange = 0x0D 37 | HAPStepValueDescriptor = 0x0E 38 | HAPServiceProperties = 0x0F 39 | HAPLinkedServices = 0x10 40 | HAPValidValuesDescriptor = 0x11 41 | HAPValidValuesRangeDescriptor = 0x12 42 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/key.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from __future__ import annotations 18 | 19 | from aiohomekit.crypto.chacha20poly1305 import ( 20 | PACK_NONCE, 21 | ChaCha20Poly1305Decryptor, 22 | ChaCha20Poly1305Encryptor, 23 | ChaCha20Poly1305PartialTag, 24 | ) 25 | from aiohomekit.crypto.chacha20poly1305 import DecryptionError # noqa: F401 26 | 27 | 28 | class EncryptionKey: 29 | def __init__(self, key: bytes): 30 | self.key = ChaCha20Poly1305Encryptor(key) 31 | self.counter = 0 32 | 33 | def encrypt(self, data: bytes) -> bytes: 34 | data = self.key.encrypt(b"", PACK_NONCE(self.counter), data) 35 | self.counter += 1 36 | return data 37 | 38 | 39 | class DecryptionKey: 40 | def __init__(self, key: bytes): 41 | self.key = ChaCha20Poly1305Decryptor(key) 42 | self.counter = 0 43 | 44 | def decrypt(self, data: bytes) -> bytes: 45 | data = self.key.decrypt(b"", PACK_NONCE(self.counter), data) 46 | self.counter += 1 47 | return data 48 | 49 | 50 | class BroadcastDecryptionKey: 51 | def __init__(self, key: bytes) -> None: 52 | self.key = ChaCha20Poly1305PartialTag(key) 53 | 54 | def decrypt(self, data: bytes | bytearray, gsn: int, advertising_identifier: bytes) -> bytes | bool: 55 | return self.key.open(PACK_NONCE(gsn), data, advertising_identifier) 56 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/manufacturer_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import struct 5 | from dataclasses import dataclass 6 | 7 | from bleak.backends.device import BLEDevice 8 | from bleak.backends.scanner import AdvertisementData 9 | 10 | from aiohomekit.controller.abstract import AbstractDescription 11 | from aiohomekit.model.categories import Categories 12 | from aiohomekit.model.status_flags import StatusFlags 13 | 14 | UNPACK_HHBB = struct.Struct(" HomeKitAdvertisement: 34 | if not (data := manufacturer_data.get(APPLE_MANUFACTURER_ID)): 35 | raise ValueError("Not an Apple device") 36 | 37 | if data[0] != HOMEKIT_ADVERTISEMENT_TYPE: 38 | raise ValueError("Not a HomeKit device") 39 | 40 | sf = data[2] 41 | device_id = ":".join(data[3:9].hex()[0 + i : 2 + i] for i in range(0, 12, 2)).lower() 42 | acid, gsn, cn, cv = UNPACK_HHBB(data[9:15]) 43 | sh = data[15:19] 44 | 45 | return cls( 46 | name=name, 47 | id=device_id, 48 | category=Categories(acid), 49 | status_flags=StatusFlags(sf), 50 | config_num=cn, 51 | state_num=gsn, 52 | setup_hash=sh, 53 | address=address, 54 | ) 55 | 56 | @classmethod 57 | def from_cache(cls, address: str, id: str, config_num: int, state_num: int) -> HomeKitAdvertisement: 58 | """Create a HomeKitAdvertisement from a cache entry.""" 59 | return cls( 60 | name=address, 61 | id=id, 62 | category=Categories(0), 63 | status_flags=StatusFlags(0), 64 | config_num=config_num, 65 | state_num=state_num, 66 | setup_hash=b"", 67 | address=address, 68 | ) 69 | 70 | @classmethod 71 | def from_advertisement( 72 | cls, device: BLEDevice, advertisement_data: AdvertisementData 73 | ) -> HomeKitAdvertisement: 74 | if not (mfr_data := advertisement_data.manufacturer_data): 75 | raise ValueError("No manufacturer data") 76 | 77 | return cls.from_manufacturer_data(device.name, device.address, mfr_data) 78 | 79 | 80 | @dataclass 81 | class HomeKitEncryptedNotification: 82 | name: str 83 | address: str 84 | id: str 85 | advertising_identifier: bytes 86 | encrypted_payload: bytes 87 | 88 | @classmethod 89 | def from_manufacturer_data( 90 | cls, name: str, address: str, manufacturer_data: dict[int, bytes] 91 | ) -> HomeKitAdvertisement: 92 | if not (data := manufacturer_data.get(APPLE_MANUFACTURER_ID)): 93 | raise ValueError("Not an Apple device") 94 | 95 | if data[0] != HOMEKIT_ENCRYPTED_NOTIFICATION_TYPE: 96 | raise ValueError("Not a HomeKit encrypted notification") 97 | 98 | advertising_identifier = data[2:8] 99 | device_id = ":".join(advertising_identifier.hex()[0 + i : 2 + i] for i in range(0, 12, 2)).lower() 100 | encrypted_payload = data[8:] 101 | 102 | return cls( 103 | name=name, 104 | id=device_id, 105 | address=address, 106 | advertising_identifier=advertising_identifier, 107 | encrypted_payload=encrypted_payload, 108 | ) 109 | 110 | @classmethod 111 | def from_advertisement( 112 | cls, device: BLEDevice, advertisement_data: AdvertisementData 113 | ) -> HomeKitAdvertisement: 114 | if not (mfr_data := advertisement_data.manufacturer_data): 115 | raise ValueError("No manufacturer data") 116 | 117 | return cls.from_manufacturer_data(device.name, device.address, mfr_data) 118 | -------------------------------------------------------------------------------- /aiohomekit/controller/ble/values.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import struct 4 | 5 | from aiohomekit.model import Characteristic, CharacteristicFormats 6 | 7 | INT_TYPES = { 8 | CharacteristicFormats.uint8, 9 | CharacteristicFormats.uint16, 10 | CharacteristicFormats.uint32, 11 | CharacteristicFormats.uint64, 12 | CharacteristicFormats.int, 13 | } 14 | 15 | 16 | def from_bytes(char: Characteristic, value: bytes) -> bool | str | float | int | bytes: 17 | if char.format == CharacteristicFormats.bool: 18 | return struct.unpack_from("?", value)[0] 19 | if char.format == CharacteristicFormats.uint8: 20 | return struct.unpack_from("B", value)[0] 21 | if char.format == CharacteristicFormats.uint16: 22 | return struct.unpack_from("H", value)[0] 23 | if char.format == CharacteristicFormats.uint32: 24 | return struct.unpack_from("I", value)[0] 25 | if char.format == CharacteristicFormats.uint64: 26 | return struct.unpack_from("Q", value)[0] 27 | if char.format == CharacteristicFormats.int: 28 | return struct.unpack_from("i", value)[0] 29 | if char.format == CharacteristicFormats.float: 30 | # FOR BLE float is 32 bit 31 | return struct.unpack_from("f", value)[0] 32 | if char.format == CharacteristicFormats.string: 33 | return value.decode("utf-8") 34 | 35 | return value.hex() 36 | 37 | 38 | def to_bytes(char: Characteristic, value: bool | str | float | int | bytes) -> bytes: 39 | if char.format == CharacteristicFormats.bool: 40 | value = struct.pack("?", value) 41 | elif char.format == CharacteristicFormats.uint8: 42 | value = struct.pack("B", value) 43 | elif char.format == CharacteristicFormats.uint16: 44 | value = struct.pack("H", value) 45 | elif char.format == CharacteristicFormats.uint32: 46 | value = struct.pack("I", value) 47 | elif char.format == CharacteristicFormats.uint64: 48 | value = struct.pack("Q", value) 49 | elif char.format == CharacteristicFormats.int: 50 | value = struct.pack("i", value) 51 | elif char.format == CharacteristicFormats.float: 52 | # FOR BLE float is 32 bit 53 | value = struct.pack("f", value) 54 | elif char.format == CharacteristicFormats.string: 55 | value = value.encode("utf-8") 56 | 57 | return bytes(value) 58 | -------------------------------------------------------------------------------- /aiohomekit/controller/coap/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from .discovery import CoAPDiscovery 18 | from .pairing import CoAPPairing 19 | 20 | __all__ = [ 21 | "CoAPDiscovery", 22 | "CoAPPairing", 23 | ] 24 | -------------------------------------------------------------------------------- /aiohomekit/controller/coap/controller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from aiohomekit.controller.abstract import TransportType 6 | from aiohomekit.controller.coap.discovery import CoAPDiscovery 7 | from aiohomekit.controller.coap.pairing import CoAPPairing 8 | from aiohomekit.zeroconf import HAP_TYPE_UDP, ZeroconfController 9 | 10 | 11 | class CoAPController(ZeroconfController): 12 | hap_type = HAP_TYPE_UDP 13 | discoveries: dict[str, CoAPDiscovery] 14 | pairings: dict[str, CoAPPairing] 15 | aliases: dict[str, CoAPPairing] 16 | transport_type = TransportType.COAP 17 | 18 | def _make_discovery(self, discovery) -> CoAPDiscovery: 19 | return CoAPDiscovery(self, discovery) 20 | 21 | def load_pairing(self, alias: str, pairing_data: dict[str, Any]) -> CoAPPairing | None: 22 | if pairing_data["Connection"] != "CoAP": 23 | return None 24 | 25 | if not (hkid := pairing_data.get("AccessoryPairingID")): 26 | return None 27 | 28 | pairing = self.pairings[hkid.lower()] = CoAPPairing(self, pairing_data) 29 | 30 | if discovery := self.discoveries.get(hkid.lower()): 31 | pairing._async_description_update(discovery.description) 32 | 33 | self.aliases[alias] = pairing 34 | 35 | return pairing 36 | -------------------------------------------------------------------------------- /aiohomekit/controller/coap/discovery.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from aiohomekit.controller.abstract import FinishPairing 18 | from aiohomekit.utils import check_pin_format, pair_with_auth 19 | from aiohomekit.zeroconf import HomeKitService, ZeroconfDiscovery 20 | 21 | from .connection import CoAPHomeKitConnection 22 | from .pairing import CoAPPairing 23 | 24 | 25 | class CoAPDiscovery(ZeroconfDiscovery): 26 | """ 27 | A discovered CoAP HAP device that is unpaired. 28 | """ 29 | 30 | def __init__(self, controller, description: HomeKitService): 31 | super().__init__(description) 32 | self.controller = controller 33 | self.connection = CoAPHomeKitConnection(None, description.address, description.port) 34 | 35 | def __repr__(self): 36 | return f"CoAPDiscovery(host={self.description.address}, port={self.description.port})" 37 | 38 | async def _ensure_connected(self): 39 | """ 40 | No preparation needs to be done for pair setup over CoAP. 41 | """ 42 | return 43 | 44 | async def close(self): 45 | """ 46 | No teardown needs to be done for pair setup over CoAP. 47 | """ 48 | return 49 | 50 | async def async_identify(self) -> None: 51 | return await self.connection.do_identify() 52 | 53 | async def async_start_pairing(self, alias: str) -> FinishPairing: 54 | salt, srpB = await self.connection.do_pair_setup(pair_with_auth(self.description.feature_flags)) 55 | 56 | async def finish_pairing(pin: str) -> CoAPPairing: 57 | check_pin_format(pin) 58 | 59 | pairing = await self.connection.do_pair_setup_finish(pin, salt, srpB) 60 | pairing["AccessoryIP"] = self.description.address 61 | pairing["AccessoryPort"] = self.description.port 62 | pairing["Connection"] = "CoAP" 63 | 64 | obj = self.controller.pairings[alias] = CoAPPairing(self.controller, pairing) 65 | 66 | return obj 67 | 68 | return finish_pairing 69 | -------------------------------------------------------------------------------- /aiohomekit/controller/coap/pdu.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from __future__ import annotations 18 | 19 | import logging 20 | import struct 21 | from enum import Enum 22 | 23 | from aiohomekit.enum import EnumWithDescription 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class OpCode(Enum): 29 | CHAR_SIG_READ = 0x01 30 | CHAR_WRITE = 0x02 31 | CHAR_READ = 0x03 32 | CHAR_TIMED_WRITE = 0x04 33 | CHAR_EXEC_WRITE = 0x05 34 | SERV_SIG_READ = 0x06 35 | UNK_09_READ_GATT = 0x09 36 | UNK_0B_SUBSCRIBE = 0x0B 37 | UNK_0C_UNSUBSCRIBE = 0x0C 38 | 39 | 40 | class PDUStatus(EnumWithDescription): 41 | SUCCESS = 0, "Success" 42 | UNSUPPORTED_PDU = 1, "Unsupported PDU" 43 | MAX_PROCEDURES = 2, "Max procedures" 44 | INSUFFICIENT_AUTHORIZATION = 3, "Insufficient authorization" 45 | INVALID_INSTANCE_ID = 4, "Invalid instance ID" 46 | INSUFFICIENT_AUTHENTICATION = 5, "Insufficient authentication" 47 | INVALID_REQUEST = 6, "Invalid request" 48 | # custom error states 49 | TID_MISMATCH = 256, "Transaction ID mismatch" 50 | BAD_CONTROL = 257, "Control field doesn't have expected bits set" 51 | 52 | 53 | def encode_pdu(opcode: OpCode, tid: int, iid: int, data: bytes) -> bytes: 54 | buf = struct.pack(" bytes: 59 | iids_data = zip(iids, data) 60 | req_pdu = b"".join( 61 | [ 62 | encode_pdu( 63 | opcode, 64 | idx, 65 | iid_data[0], 66 | iid_data[1], 67 | ) 68 | for (idx, iid_data) in enumerate(iids_data) 69 | ] 70 | ) 71 | return req_pdu 72 | 73 | 74 | def decode_pdu(expected_tid: int, data: bytes) -> tuple[int, bytes | PDUStatus]: 75 | control, tid, status, body_len = struct.unpack(" list[tuple[int, bytes | PDUStatus]]: 104 | idx = starting_tid 105 | offset = 0 106 | res = [] 107 | while True: 108 | body_len, body = decode_pdu(idx, data[offset:]) 109 | res.append(body) 110 | 111 | idx += 1 112 | offset += 5 + body_len 113 | if offset >= len(data): 114 | break 115 | 116 | return res 117 | -------------------------------------------------------------------------------- /aiohomekit/controller/ip/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from .controller import IpController 18 | from .discovery import IpDiscovery 19 | from .pairing import IpPairing 20 | 21 | __all__ = [ 22 | "IpController", 23 | "IpDiscovery", 24 | "IpPairing", 25 | ] 26 | -------------------------------------------------------------------------------- /aiohomekit/controller/ip/controller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from aiohomekit.controller.abstract import TransportType 6 | from aiohomekit.controller.ip.discovery import IpDiscovery 7 | from aiohomekit.controller.ip.pairing import IpPairing 8 | from aiohomekit.zeroconf import HAP_TYPE_TCP, ZeroconfController 9 | 10 | 11 | class IpController(ZeroconfController): 12 | hap_type = HAP_TYPE_TCP 13 | discoveries: dict[str, IpDiscovery] 14 | pairings: dict[str, IpPairing] 15 | transport_type = TransportType.IP 16 | 17 | def _make_discovery(self, discovery) -> IpDiscovery: 18 | return IpDiscovery(self, discovery) 19 | 20 | def load_pairing(self, alias: str, pairing_data: dict[str, Any]) -> IpPairing | None: 21 | if pairing_data["Connection"] != "IP": 22 | return None 23 | 24 | if not (hkid := pairing_data.get("AccessoryPairingID")): 25 | return None 26 | 27 | pairing = self.pairings[hkid.lower()] = IpPairing(self, pairing_data) 28 | 29 | if discovery := self.discoveries.get(hkid.lower()): 30 | pairing._async_description_update(discovery.description) 31 | 32 | self.aliases[alias] = pairing 33 | 34 | return pairing 35 | -------------------------------------------------------------------------------- /aiohomekit/controller/ip/discovery.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import uuid 18 | 19 | from aiohomekit.controller.abstract import FinishPairing 20 | from aiohomekit.exceptions import AlreadyPairedError 21 | from aiohomekit.protocol import perform_pair_setup_part1, perform_pair_setup_part2 22 | from aiohomekit.protocol.statuscodes import to_status_code 23 | from aiohomekit.utils import check_pin_format, pair_with_auth 24 | from aiohomekit.zeroconf import HomeKitService, ZeroconfDiscovery 25 | 26 | from .connection import HomeKitConnection 27 | from .pairing import IpPairing 28 | 29 | 30 | class IpDiscovery(ZeroconfDiscovery): 31 | """ 32 | A discovered IP HAP device that is unpaired. 33 | """ 34 | 35 | def __init__(self, controller, description: HomeKitService): 36 | super().__init__(description) 37 | self.controller = controller 38 | self.connection = HomeKitConnection(None, description.addresses, description.port) 39 | 40 | def __repr__(self): 41 | return f"IPDiscovery(host={self.description.address}, port={self.description.port})" 42 | 43 | async def _ensure_connected(self): 44 | await self.connection.ensure_connection() 45 | 46 | async def close(self): 47 | """ 48 | Close the pairing's communications. This closes the session. 49 | """ 50 | await self.connection.close() 51 | 52 | async def async_start_pairing(self, alias: str) -> FinishPairing: 53 | await self._ensure_connected() 54 | 55 | state_machine = perform_pair_setup_part1(pair_with_auth(self.description.feature_flags)) 56 | request, expected = state_machine.send(None) 57 | while True: 58 | try: 59 | response = await self.connection.post_tlv( 60 | "/pair-setup", 61 | body=request, 62 | expected=expected, 63 | ) 64 | request, expected = state_machine.send(response) 65 | except StopIteration as result: 66 | # If the state machine raises a StopIteration then we have XXX 67 | salt, pub_key = result.value 68 | break 69 | 70 | async def finish_pairing(pin: str) -> IpPairing: 71 | check_pin_format(pin) 72 | 73 | state_machine = perform_pair_setup_part2(pin, str(uuid.uuid4()), salt, pub_key) 74 | request, expected = state_machine.send(None) 75 | 76 | while True: 77 | try: 78 | response = await self.connection.post_tlv( 79 | "/pair-setup", 80 | body=request, 81 | expected=expected, 82 | ) 83 | request, expected = state_machine.send(response) 84 | except StopIteration as result: 85 | # If the state machine raises a StopIteration then we have XXX 86 | pairing = result.value 87 | break 88 | 89 | pairing["AccessoryIP"] = self.description.address 90 | pairing["AccessoryIPs"] = self.description.addresses 91 | pairing["AccessoryPort"] = self.description.port 92 | pairing["Connection"] = "IP" 93 | 94 | obj = self.controller.pairings[alias] = IpPairing(self.controller, pairing) 95 | 96 | await self.connection.close() 97 | 98 | return obj 99 | 100 | return finish_pairing 101 | 102 | async def async_identify(self) -> None: 103 | await self._ensure_connected() 104 | 105 | response = await self.connection.post_json("/identify", {}) 106 | 107 | if not response: 108 | return True 109 | 110 | code = to_status_code(response["code"]) 111 | 112 | raise AlreadyPairedError(f"Identify failed because: {code.description} ({code.value}).") 113 | -------------------------------------------------------------------------------- /aiohomekit/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | __all__ = [ 18 | "NONCE_PADDING", 19 | "PACK_NONCE", 20 | "ChaCha20Poly1305Decryptor", 21 | "ChaCha20Poly1305Encryptor", 22 | "DecryptionError", 23 | "SrpClient", 24 | "SrpServer", 25 | "hkdf_derive", 26 | ] 27 | 28 | from .chacha20poly1305 import ( 29 | NONCE_PADDING, 30 | PACK_NONCE, 31 | ChaCha20Poly1305Decryptor, 32 | ChaCha20Poly1305Encryptor, 33 | DecryptionError, 34 | ) 35 | from .hkdf import hkdf_derive 36 | from .srp import SrpClient, SrpServer 37 | -------------------------------------------------------------------------------- /aiohomekit/crypto/chacha20poly1305.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """ 18 | Implements the ChaCha20 stream cipher and the Poly1350 authenticator. More information can be found on 19 | https://tools.ietf.org/html/rfc7539. See HomeKit spec page 51. 20 | """ 21 | 22 | from __future__ import annotations 23 | 24 | import logging 25 | import struct 26 | from functools import partial 27 | from struct import Struct 28 | 29 | from chacha20poly1305 import ( 30 | ChaCha, 31 | Poly1305, 32 | ) 33 | from chacha20poly1305 import ( 34 | ChaCha20Poly1305 as ChaCha20Poly1305PurePython, 35 | ) 36 | from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable 37 | from cryptography.exceptions import InvalidTag 38 | 39 | DecryptionError = InvalidTag 40 | 41 | NONCE_PADDING = bytes([0, 0, 0, 0]) 42 | PACK_NONCE = partial(Struct(" None: 52 | """Init the encryptor 53 | 54 | :param key: 256-bit (32-byte) key of type bytes 55 | """ 56 | assert type(key) is bytes, "key is no instance of bytes" 57 | assert len(key) == 32 58 | self.chacha = ChaCha20Poly1305Reusable(key) 59 | 60 | def encrypt(self, aad: bytes, nonce: bytes, plaintext: bytes) -> bytes: 61 | """ 62 | The encrypt method for chacha20 aead as required by the Apple specification. The 96-bit nonce from RFC7539 is 63 | formed from the constant and the initialisation vector. 64 | 65 | :param aad: arbitrary length additional authenticated data 66 | :param iv: the initialisation vector 67 | :param constant: constant 68 | :param plaintext: arbitrary length plaintext of type bytes or bytearray 69 | :return: the cipher text and tag 70 | """ 71 | return self.chacha.encrypt(nonce, plaintext, aad) 72 | 73 | 74 | class ChaCha20Poly1305Decryptor: 75 | """Decrypt data with ChaCha20Poly1305.""" 76 | 77 | def __init__(self, key: bytes) -> None: 78 | """Init the decrypter 79 | 80 | :param key: 256-bit (32-byte) key of type bytes 81 | """ 82 | assert type(key) is bytes, "key is no instance of bytes" 83 | assert len(key) == 32 84 | self.chacha = ChaCha20Poly1305Reusable(key) 85 | 86 | def decrypt(self, aad: bytes, nonce: bytes, ciphertext: bytes) -> bytes: 87 | """ 88 | The decrypt method for chacha20 aead as required by the Apple specification. The 96-bit nonce from RFC7539 is 89 | formed from the constant and the initialisation vector. 90 | 91 | :param aad: arbitrary length additional authenticated data 92 | :param iv: the initialisation vector 93 | :param constant: constant 94 | :param ciphertext: arbitrary length plaintext of type bytes or bytearray 95 | :return: False if the tag could not be verified or the plaintext as bytes 96 | """ 97 | return self.chacha.decrypt(nonce, ciphertext, aad) 98 | 99 | 100 | class ChaCha20Poly1305PartialTag(ChaCha20Poly1305PurePython): 101 | def open(self, nonce: bytes, combined_text: bytes, data: bytes) -> bytes: 102 | """ 103 | Decrypts and authenticates ciphertext using nonce and data. If the 104 | tag is valid, the plaintext is returned. If the tag is invalid, 105 | returns None. 106 | 107 | This decryption only handles ble advertisements, which have a 4 byte 108 | partial tag. 109 | """ 110 | if len(nonce) != 12: 111 | raise ValueError("Nonce must be 96 bit long") 112 | 113 | expected_tag = combined_text[-4:] 114 | ciphertext = combined_text[:-4] 115 | 116 | otk = self.poly1305_key_gen(self.key, nonce) 117 | 118 | mac_data = data + self.pad16(data) 119 | mac_data += ciphertext + self.pad16(ciphertext) 120 | mac_data += struct.pack(" bytes: 25 | hkdf = HKDF( 26 | algorithm=hashes.SHA512(), 27 | length=length, 28 | salt=salt, 29 | info=info, 30 | backend=backend, 31 | ) 32 | return hkdf.derive(input) 33 | -------------------------------------------------------------------------------- /aiohomekit/enum.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import enum 18 | 19 | 20 | class EnumWithDescription(enum.Enum): 21 | def __new__(cls, *args, **kwds): 22 | obj = object.__new__(cls) 23 | obj._value_ = args[0] 24 | return obj 25 | 26 | def __init__(self, _: str, description: str = None): 27 | self.__description = description 28 | 29 | def __str__(self): 30 | return str(self.value) 31 | 32 | @property 33 | def description(self): 34 | return self.__description 35 | -------------------------------------------------------------------------------- /aiohomekit/hkjson.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | from __future__ import annotations 17 | 18 | import json 19 | from typing import Any 20 | 21 | import commentjson 22 | import orjson 23 | 24 | JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) 25 | JSON_DECODE_EXCEPTIONS = (json.JSONDecodeError, orjson.JSONDecodeError) 26 | 27 | 28 | def loads(s: str | bytes | bytearray | memoryview) -> Any: 29 | """Load json or fallback to commentjson. 30 | 31 | We try to load the json with built-in json, and 32 | if it fails with JSONDecodeError we fallback to 33 | the slower but more tolerant commentjson to 34 | accomodate devices that use trailing commas 35 | in their json since iOS allows it. 36 | 37 | This approach ensures only devices that produce 38 | the technically invalid json have to pay the 39 | price of the double decode attempt. 40 | """ 41 | try: 42 | return orjson.loads(s) 43 | except orjson.JSONDecodeError: 44 | return commentjson.loads(s) 45 | 46 | 47 | def dumps(data: Any) -> str: 48 | """JSON encoder that uses orjson.""" 49 | return dump_bytes(data).decode("utf-8") 50 | 51 | 52 | def dump_bytes(data: Any) -> str: 53 | """JSON encoder that works with iOS. 54 | 55 | An iPhone sends JSON like this: 56 | 57 | {"characteristics":[{"iid":15,"aid":2,"ev":true}]} 58 | 59 | Some devices (Tado Internet Bridge) depend on this some of the time. 60 | 61 | orjson natively generates output with no spaces. 62 | """ 63 | return orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS) 64 | 65 | 66 | def dumps_indented(data: Any) -> str: 67 | """JSON encoder that uses orjson with indent.""" 68 | return orjson.dumps( 69 | data, 70 | option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS, 71 | ).decode("utf-8") 72 | -------------------------------------------------------------------------------- /aiohomekit/http/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import enum 18 | 19 | __all__ = ["HttpContentTypes", "HttpStatusCodes"] 20 | 21 | 22 | class HttpContentTypes(enum.Enum): 23 | """ 24 | Collection of HTTP content types as used in HTTP headers 25 | """ 26 | 27 | JSON = "application/hap+json" 28 | TLV = "application/pairing+tlv8" 29 | 30 | 31 | class _HttpStatusCodes: 32 | """ 33 | See Table 4-2 Chapter 4.15 Page 59 34 | """ 35 | 36 | OK = 200 37 | NO_CONTENT = 204 38 | MULTI_STATUS = 207 39 | BAD_REQUEST = 400 40 | FORBIDDEN = 403 41 | NOT_FOUND = 404 42 | METHOD_NOT_ALLOWED = 405 43 | TOO_MANY_REQUESTS = 429 44 | CONNECTION_AUTHORIZATION_REQUIRED = 470 45 | INTERNAL_SERVER_ERROR = 500 46 | 47 | def __init__(self) -> None: 48 | self._codes = { 49 | _HttpStatusCodes.OK: "OK", 50 | _HttpStatusCodes.NO_CONTENT: "No Content", 51 | _HttpStatusCodes.MULTI_STATUS: "Multi-Status", 52 | _HttpStatusCodes.BAD_REQUEST: "Bad Request", 53 | _HttpStatusCodes.METHOD_NOT_ALLOWED: "Method Not Allowed", 54 | _HttpStatusCodes.TOO_MANY_REQUESTS: "Too Many Requests", 55 | _HttpStatusCodes.CONNECTION_AUTHORIZATION_REQUIRED: "Connection Authorization Required", 56 | _HttpStatusCodes.INTERNAL_SERVER_ERROR: "Internal Server Error", 57 | } 58 | self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} 59 | 60 | def __getitem__(self, item: int) -> str: 61 | if item in self._codes: 62 | return self._codes[item] 63 | 64 | raise KeyError(f"Item {item} not found") 65 | 66 | 67 | HttpStatusCodes = _HttpStatusCodes() 68 | -------------------------------------------------------------------------------- /aiohomekit/http/response.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import logging 18 | from typing import Union 19 | 20 | from aiohomekit.exceptions import HttpException 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class HttpResponse: 26 | STATE_PRE_STATUS = 0 27 | STATE_HEADERS = 1 28 | STATE_BODY = 2 29 | STATE_DONE = 3 30 | 31 | def __init__(self) -> None: 32 | self._state = HttpResponse.STATE_PRE_STATUS 33 | self._raw_response = bytearray() 34 | self._is_ready = False 35 | self._is_chunked = False 36 | self._had_empty_chunk = False 37 | self._content_length = -1 38 | self.version = None 39 | self.code = None 40 | self.reason = None 41 | self.headers = [] 42 | self.body = bytearray() 43 | 44 | def parse(self, part: Union[bytearray, bytes]) -> bytearray: 45 | self._raw_response += part 46 | pos = self._raw_response.find(b"\r\n") 47 | 48 | while pos != -1 and self._state < HttpResponse.STATE_BODY: 49 | line = self._raw_response[:pos] 50 | self._raw_response = self._raw_response[pos + 2 :] 51 | if self._state == HttpResponse.STATE_PRE_STATUS: 52 | # parse status line 53 | line = line.split(b" ", 2) 54 | if len(line) != 3: 55 | raise HttpException("Malformed status line.") 56 | self.version = line[0].decode() 57 | self.code = int(line[1]) 58 | self.reason = line[2].decode() 59 | self._state = HttpResponse.STATE_HEADERS 60 | 61 | elif self._state == HttpResponse.STATE_HEADERS and line == b"": 62 | # this is the empty line after the headers 63 | self._state = HttpResponse.STATE_BODY 64 | 65 | elif self._state == HttpResponse.STATE_HEADERS: 66 | # parse a header line 67 | line = line.split(b":", 1) 68 | name = line[0].decode().strip().title() 69 | value = line[1].decode().strip() 70 | if name == "Transfer-Encoding": 71 | if value == "chunked": 72 | self._is_chunked = True 73 | elif name == "Content-Length": 74 | self._content_length = int(value) 75 | self.headers.append((name, value)) 76 | else: 77 | raise HttpException("Unknown parser state") 78 | 79 | pos = self._raw_response.find(b"\r\n") 80 | 81 | if self._state == HttpResponse.STATE_BODY and self._is_chunked: 82 | pos = self._raw_response.find(b"\r\n") 83 | while pos > -1: 84 | line = self._raw_response[:pos] 85 | self._raw_response = self._raw_response[pos + 2 :] 86 | length = int(line, 16) 87 | if length + 2 > len(self._raw_response): 88 | self._raw_response = line + b"\r\n" + self._raw_response 89 | # the remaining bytes in raw response are not sufficient. bail out and wait for an other call. 90 | break 91 | 92 | if length == 0: 93 | self._had_empty_chunk = True 94 | self._state = HttpResponse.STATE_DONE 95 | self._raw_response = self._raw_response[length + 2 :] 96 | break 97 | 98 | line = self._raw_response[:length] 99 | self.body += line 100 | self._raw_response = self._raw_response[length + 2 :] 101 | 102 | pos = self._raw_response.find(b"\r\n") 103 | 104 | if self._state == HttpResponse.STATE_BODY and self._content_length > 0: 105 | remaining = self._content_length - len(self.body) 106 | self.body += self._raw_response[:remaining] 107 | self._raw_response = self._raw_response[remaining:] 108 | 109 | if self.is_read_completely(): 110 | # Whatever is left in the buffer is part of the next request 111 | if len(self._raw_response) > 0: 112 | logger.debug("Bytes left in buffer after parsing packet: %r", self._raw_response) 113 | return self._raw_response 114 | 115 | return bytearray() 116 | 117 | def read(self): 118 | """ 119 | Returns the body of the response. 120 | 121 | :return: The read body or None if no body content was read yet 122 | """ 123 | return self.body 124 | 125 | def is_read_completely(self) -> bool: 126 | if self._is_chunked: 127 | return self._had_empty_chunk 128 | 129 | if self._state < HttpResponse.STATE_BODY: 130 | return False 131 | 132 | if self._content_length != -1: 133 | return len(self.body) == self._content_length 134 | 135 | return True 136 | 137 | def get_http_name(self) -> str: 138 | """ 139 | Returns the HTTP name (e.g. HTTP or EVENT). 140 | 141 | :return: The name or None if the status line was not yet read 142 | """ 143 | if self.version is not None: 144 | return self.version.split("/")[0] 145 | return None 146 | -------------------------------------------------------------------------------- /aiohomekit/meshcop.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2023 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """ 17 | Struct for various records from Meshcop Thread systems. 18 | 19 | https://openthread.io/reference/group/api-operational-dataset 20 | https://github.com/openthread/openthread/blob/main/include/openthread/dataset.h 21 | https://software-dl.ti.com/lprf/simplelink_cc26x2_sdk-1.60/docs/thread/doxygen/openthread-docs-0.01.00/html/dd/d6b/meshcop__tlvs_8hpp_source.html 22 | https://github.com/home-assistant-libs/python-otbr-api/blob/main/python_otbr_api/tlv_parser.py 23 | """ 24 | 25 | from dataclasses import dataclass 26 | 27 | from aiohomekit.tlv8 import TLVStruct, bu16, tlv_entry 28 | 29 | 30 | @dataclass 31 | class Meshcop(TLVStruct): 32 | channel: bu16 = tlv_entry(0) 33 | panid: bu16 = tlv_entry(1) 34 | extpanid: bytes = tlv_entry(2) 35 | networkname: str = tlv_entry(3) 36 | pskc: bytes = tlv_entry(4) 37 | networkkey: bytes = tlv_entry(5) 38 | network_key_sequence: bytes = tlv_entry(6) 39 | meshlocalprefix: bytes = tlv_entry(7) 40 | steering_data: bytes = tlv_entry(8) 41 | border_agent_rloc: bytes = tlv_entry(9) 42 | commissioner_id: bytes = tlv_entry(10) 43 | comm_session_id: bytes = tlv_entry(11) 44 | securitypolicy: bytes = tlv_entry(12) 45 | get: bytes = tlv_entry(13) 46 | activetimestamp: bytes = tlv_entry(14) 47 | state: bytes = tlv_entry(16) 48 | joiner_dtls: bytes = tlv_entry(17) 49 | joiner_udp_port: bytes = tlv_entry(18) 50 | joiner_iid: bytes = tlv_entry(19) 51 | joiner_rloc: bytes = tlv_entry(20) 52 | joiner_router_kek: bytes = tlv_entry(21) 53 | provisioning_url: bytes = tlv_entry(32) 54 | vendor_name_tlv: bytes = tlv_entry(33) 55 | vendor_model_tlv: bytes = tlv_entry(34) 56 | vendor_sw_version_tlv: bytes = tlv_entry(35) 57 | vendor_data_tlv: bytes = tlv_entry(36) 58 | vendor_stack_version_tlv: bytes = tlv_entry(37) 59 | pendingtimestamp: bytes = tlv_entry(51) 60 | delaytimer: bytes = tlv_entry(52) 61 | channelmask: bytes = tlv_entry(53) 62 | count: bytes = tlv_entry(54) 63 | period: bytes = tlv_entry(55) 64 | scan_duration: bytes = tlv_entry(56) 65 | energy_list: bytes = tlv_entry(57) 66 | discoveryrequest: bytes = tlv_entry(128) 67 | discoveryresponse: bytes = tlv_entry(129) 68 | 69 | # Seen in a dataset imported through iOS companion app 70 | wakeup_channel: bytes = tlv_entry(74) 71 | discovery_request: bytes = tlv_entry(128) 72 | discovery_response: bytes = tlv_entry(129) 73 | joiner_advertisement: bytes = tlv_entry(241) 74 | -------------------------------------------------------------------------------- /aiohomekit/model/categories.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import enum 18 | 19 | 20 | class Categories(enum.IntFlag): 21 | OTHER = 1 22 | BRIDGE = 2 23 | FAN = 3 24 | GARAGE = 4 25 | LIGHTBULB = 5 26 | DOOR_LOCK = 6 27 | OUTLET = 7 28 | SWITCH = 8 29 | THERMOSTAT = 9 30 | SENSOR = 10 31 | SECURITY_SYSTEM = 11 32 | DOOR = 12 33 | WINDOW = 13 34 | WINDOW_COVERING = 14 35 | PROGRAMMABLE_SWITCH = 15 36 | RANGE_EXTENDER = 16 37 | IP_CAMERA = 17 38 | VIDEO_DOOR_BELL = 18 39 | AIR_PURIFIER = 19 40 | HEATER = 20 41 | AIR_CONDITIONER = 21 42 | HUMIDIFIER = 22 43 | DEHUMIDIFER = 23 44 | APPLE_TV = 24 45 | HOMEPOD = 25 46 | SPEAKER = 26 47 | AIRPORT = 27 48 | SPRINKLER = 28 49 | FAUCET = 29 50 | SHOWER_HEAD = 30 51 | TELEVISION = 31 52 | REMOTE = 32 53 | ROUTER = 33 54 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from .characteristic import Characteristic 18 | from .characteristic_formats import CharacteristicFormats 19 | from .characteristic_types import CharacteristicsTypes 20 | from .const import ( 21 | ActivationStateValues, 22 | CurrentFanStateValues, 23 | CurrentHeaterCoolerStateValues, 24 | CurrentMediaStateValues, 25 | HeatingCoolingCurrentValues, 26 | HeatingCoolingTargetValues, 27 | InputEventValues, 28 | InUseValues, 29 | IsConfiguredValues, 30 | ProgramModeValues, 31 | RemoteKeyValues, 32 | SwingModeValues, 33 | TargetFanStateValues, 34 | TargetHeaterCoolerStateValues, 35 | TargetMediaStateValues, 36 | ValveTypeValues, 37 | ) 38 | from .permissions import CharacteristicPermissions 39 | from .types import CharacteristicShortUUID, CharacteristicUUID 40 | from .units import CharacteristicUnits 41 | 42 | EVENT_CHARACTERISTICS = { 43 | CharacteristicsTypes.INPUT_EVENT, 44 | CharacteristicsTypes.BUTTON_EVENT, 45 | } 46 | # These characteristics are marked as [pr,ev] but make no sense to poll. 47 | # 48 | # Doing so can cause phantom triggers. 49 | 50 | 51 | __all__ = [ 52 | "EVENT_CHARACTERISTICS", 53 | "ActivationStateValues", 54 | "Characteristic", 55 | "CharacteristicFormats", 56 | "CharacteristicPermissions", 57 | "CharacteristicShortUUID", 58 | "CharacteristicUUID", 59 | "CharacteristicUnits", 60 | "CharacteristicsTypes", 61 | "CurrentFanStateValues", 62 | "CurrentHeaterCoolerStateValues", 63 | "CurrentMediaStateValues", 64 | "HeatingCoolingCurrentValues", 65 | "HeatingCoolingTargetValues", 66 | "InUseValues", 67 | "InputEventValues", 68 | "IsConfiguredValues", 69 | "ProgramModeValues", 70 | "RemoteKeyValues", 71 | "SwingModeValues", 72 | "TargetFanStateValues", 73 | "TargetHeaterCoolerStateValues", 74 | "TargetMediaStateValues", 75 | "ValveTypeValues", 76 | ] 77 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/characteristic_formats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | class CharacteristicFormats: 19 | """ 20 | Values for characteristic's format taken from table 5-5 page 67 21 | """ 22 | 23 | bool = "bool" 24 | uint8 = "uint8" 25 | uint16 = "uint16" 26 | uint32 = "uint32" 27 | uint64 = "uint64" 28 | int = "int" 29 | float = "float" 30 | string = "string" 31 | tlv8 = "tlv8" 32 | data = "data" 33 | array = "array" 34 | dict = "dict" 35 | 36 | 37 | class _BleCharacteristicFormats: 38 | """ 39 | Mapping taken from Table 6-36 page 129 and 40 | https://developer.nordicsemi.com/nRF5_SDK/nRF51_SDK_v4.x.x/doc/html/group___b_l_e___g_a_t_t___c_p_f___f_o_r_m_a_t_s.html 41 | """ 42 | 43 | def __init__(self) -> None: 44 | self._formats = { 45 | 0x01: "bool", 46 | 0x04: "uint8", 47 | 0x06: "uint16", 48 | 0x08: "uint32", 49 | 0x0A: "uint64", 50 | 0x10: "int", 51 | 0x14: "float", 52 | 0x19: "string", 53 | 0x1B: "data", 54 | } 55 | 56 | self._formats_rev = {v: k for (k, v) in self._formats.items()} 57 | 58 | def get(self, key, default): 59 | return self._formats.get(key, default) 60 | 61 | def get_reverse(self, key, default): 62 | return self._formats_rev.get(key, default) 63 | 64 | 65 | # 66 | # Have a singleton to avoid overhead 67 | # 68 | BleCharacteristicFormats = _BleCharacteristicFormats() 69 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/const.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class CurrentMediaStateValues(enum.IntEnum): 5 | """States that a TV can be.""" 6 | 7 | PLAYING = 0 8 | PAUSED = 1 9 | STOPPED = 2 10 | 11 | 12 | class TargetMediaStateValues(enum.IntEnum): 13 | """States that a TV can be set to.""" 14 | 15 | PLAY = 0 16 | PAUSE = 1 17 | STOP = 2 18 | 19 | 20 | class RemoteKeyValues(enum.IntEnum): 21 | """Keys that can be send using the Remote Key characteristic.""" 22 | 23 | REWIND = 0 24 | FAST_FORWARD = 1 25 | NEXT_TRACK = 2 26 | PREVIOUS_TRACK = 3 27 | ARROW_UP = 4 28 | ARROW_DOWN = 5 29 | ARROW_LEFT = 6 30 | ARROW_RIGHT = 7 31 | SELECT = 8 32 | BACK = 9 33 | EXIT = 10 34 | PLAY_PAUSE = 11 35 | INFORMATION = 15 36 | 37 | 38 | class InputEventValues(enum.IntEnum): 39 | """Types of button press for CharacteristicsTypes.INPUT_EVENT.""" 40 | 41 | SINGLE_PRESS = 0 42 | DOUBLE_PRESS = 1 43 | LONG_PRESS = 2 44 | 45 | 46 | class HeatingCoolingCurrentValues(enum.IntEnum): 47 | """What is a thermostat currently doing.""" 48 | 49 | IDLE = 0 50 | HEATING = 1 51 | COOLING = 2 52 | 53 | 54 | class HeatingCoolingTargetValues(enum.IntEnum): 55 | """What is the current 'goal' for the thermostat.""" 56 | 57 | OFF = 0 58 | HEAT = 1 59 | COOL = 2 60 | AUTO = 3 61 | 62 | 63 | class InUseValues(enum.IntEnum): 64 | """Whether or not something is in use.""" 65 | 66 | NOT_IN_USE = 0 67 | IN_USE = 1 68 | 69 | 70 | class IsConfiguredValues(enum.IntEnum): 71 | """Whether or not something is configured.""" 72 | 73 | NOT_CONFIGURED = 0 74 | CONFIGURED = 1 75 | 76 | 77 | class ProgramModeValues(enum.IntEnum): 78 | """Whether or not a program is set.""" 79 | 80 | NO_PROGRAM_SCHEDULED = 0 81 | PROGRAM_SCHEDULED = 1 82 | PROGRAM_SCHEDULED_MANUAL_MODE = 2 83 | 84 | 85 | class ValveTypeValues(enum.IntEnum): 86 | """The type of valve.""" 87 | 88 | GENERIC_VALVE = 0 89 | IRRIGATION = 1 90 | SHOWER_HEAD = 2 91 | WATER_FAUCET = 3 92 | 93 | 94 | class ActivationStateValues(enum.IntEnum): 95 | """Possible values for the current status of an accessory. 96 | https://developer.apple.com/documentation/homekit/hmcharacteristicvalueactivationstate 97 | """ 98 | 99 | INACTIVE = 0 100 | ACTIVE = 1 101 | 102 | 103 | class AirQualityValues(enum.IntEnum): 104 | """Possible values for the air quality. 105 | https://developer.apple.com/documentation/homekit/hmcharacteristicvalueairquality""" 106 | 107 | UNKNOWN = 0 108 | EXCELLENT = 1 109 | GOOD = 2 110 | FAIR = 3 111 | INFERIOR = 4 112 | POOR = 5 113 | 114 | 115 | class CurrentAirPurifierStateValues(enum.IntEnum): 116 | """Possible values for the current state of an air purifier. 117 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluecurrentairpurifierstate 118 | """ 119 | 120 | INACTIVE = 0 121 | IDLE = 1 122 | ACTIVE = 2 123 | 124 | 125 | class TargetAirPurifierStateValues(enum.IntEnum): 126 | """Possible values for the target state of an air purifier. 127 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluetargetairpurifierstate 128 | """ 129 | 130 | MANUAL = 0 131 | AUTOMATIC = 1 132 | 133 | 134 | class SwingModeValues(enum.IntEnum): 135 | """Possible values for fan movement. 136 | https://developer.apple.com/documentation/homekit/hmcharacteristicvalueswingmode""" 137 | 138 | DISABLED = 0 139 | ENABLED = 1 140 | 141 | 142 | class CurrentFanStateValues(enum.IntEnum): 143 | """Possible values current fan state. 144 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluecurrentfanstate 145 | """ 146 | 147 | INACTIVE = 0 148 | IDLE = 1 149 | ACTIVE = 2 150 | 151 | 152 | class TargetFanStateValues(enum.IntEnum): 153 | """Possible values target fan state. 154 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluetargetfanstate 155 | """ 156 | 157 | MANUAL = 0 158 | AUTOMATIC = 1 159 | 160 | 161 | class CurrentHeaterCoolerStateValues(enum.IntEnum): 162 | """Possible values for the current state of a device that heats or cools. 163 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluecurrentheatercoolerstate 164 | """ 165 | 166 | INACTIVE = 0 167 | IDLE = 1 168 | HEATING = 2 169 | COOLING = 3 170 | 171 | 172 | class TargetHeaterCoolerStateValues(enum.IntEnum): 173 | """Possible values for the target state of a device that heats or cools. 174 | https://developer.apple.com/documentation/homekit/hmcharacteristicvaluetargetheatercoolerstate 175 | """ 176 | 177 | AUTOMATIC = 0 178 | HEAT = 1 179 | COOL = 2 180 | 181 | 182 | class StreamingStatusValues(enum.IntEnum): 183 | """The current streaming state of a camera.""" 184 | 185 | AVAILABLE = 0 186 | IN_USE = 1 187 | UNAVAILABLE = 2 188 | 189 | 190 | class SessionControlCommandValues(enum.IntEnum): 191 | """Session control commands.""" 192 | 193 | END_SESSION = 0 194 | START_SESSION = 1 195 | SUSPEND_SESSION = 2 196 | RESUME_SESSION = 3 197 | RECONFIGURE_SESSION = 4 198 | 199 | 200 | class VideoCodecTypeValues(enum.IntEnum): 201 | H264 = 0 202 | 203 | 204 | class ProfileIDValues(enum.IntEnum): 205 | """ 206 | The type of H.264 profile used. 207 | 208 | 3-255 are vendor specific. 209 | """ 210 | 211 | CONTRAINED_BASELINE_PROFILE = 0 212 | MAIN_PROFILE = 1 213 | HIGH_PROFILE = 2 214 | 215 | 216 | class ProfileSupportLevelValues(enum.IntEnum): 217 | """ 218 | 3-255 are reserved by Apple. 219 | """ 220 | 221 | THREE_ONE = 0 222 | THREE_TWO = 1 223 | FOUR = 2 224 | 225 | 226 | class PacketizationModeValues(enum.IntEnum): 227 | """ 228 | 1 - 255 are reserved by Apple. 229 | """ 230 | 231 | NON_INTERLEAVED_MODE = 0 232 | 233 | 234 | class CVOEnabledValues(enum.IntEnum): 235 | NOT_SUPPORTED = 0 236 | SUPPORTED = 1 237 | 238 | 239 | class AudioCodecValues(enum.IntEnum): 240 | """ 241 | 7-255 reserved for Apple. 242 | """ 243 | 244 | AAC_ELD = 2 245 | OPUS = 3 246 | AMR = 5 247 | AMR_WB = 6 248 | 249 | 250 | class BitRateValues(enum.IntEnum): 251 | VARIABLE = 0 252 | CONSTANT = 1 253 | 254 | 255 | class SampleRateValues(enum.IntEnum): 256 | EIGHT_KHZ = 0 257 | SIXTEEN_KHZ = 1 258 | TWENTY_FOUR_KHZ = 2 259 | 260 | 261 | class SRTPCryptoSuiteValues(enum.IntEnum): 262 | AES_CM_128_HMAC_SHA1_80 = 0 263 | AES_256_CM_HMAC_SHA1_80 = 1 264 | DISABLED = 2 265 | 266 | 267 | class ThreadNodeCapabilities(enum.IntFlag): 268 | MINIMAL = 0x01 269 | SLEEPY = 0x02 270 | FULL = 0x04 271 | ROUTER_ELIGIBLE = 0x08 272 | BORDER_ROUTER_CAPABLE = 0x10 273 | 274 | 275 | class ThreadStatus(enum.IntFlag): 276 | DISABLED = 0x01 277 | DETACHED = 0x02 278 | JOINING = 0x04 279 | CHILD = 0x08 280 | ROUTER = 0x10 281 | LEADER = 0x20 282 | BORDER_ROUTER = 0x40 283 | 284 | 285 | class TemperatureDisplayUnits(enum.IntEnum): 286 | CELSIUS = 0 287 | FAHRENHEIT = 1 288 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/permissions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | class CharacteristicPermissions: 19 | """ 20 | See table 5-4 page 67 21 | """ 22 | 23 | paired_read = "pr" 24 | paired_write = "pw" 25 | events = "ev" 26 | addition_authorization = "aa" 27 | timed_write = "tw" 28 | hidden = "hd" 29 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/structs.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from collections.abc import Sequence 17 | from dataclasses import dataclass 18 | 19 | from aiohomekit.tlv8 import TLVStruct, tlv_entry, u8, u16, u32 20 | 21 | from .const import ( 22 | AudioCodecValues, 23 | BitRateValues, 24 | CVOEnabledValues, 25 | PacketizationModeValues, 26 | ProfileIDValues, 27 | ProfileSupportLevelValues, 28 | SampleRateValues, 29 | SessionControlCommandValues, 30 | SRTPCryptoSuiteValues, 31 | StreamingStatusValues, 32 | VideoCodecTypeValues, 33 | ) 34 | 35 | 36 | @dataclass 37 | class StreamingStatus(TLVStruct): 38 | status: StreamingStatusValues = tlv_entry(1) 39 | 40 | 41 | @dataclass 42 | class SessionControl(TLVStruct): 43 | session: str = tlv_entry(1) 44 | command: SessionControlCommandValues = tlv_entry(2) 45 | 46 | 47 | @dataclass 48 | class VideoAttrs(TLVStruct): 49 | width: u16 = tlv_entry(1) 50 | height: u16 = tlv_entry(2) 51 | fps: u8 = tlv_entry(3) 52 | 53 | 54 | @dataclass 55 | class VideoCodecParameters(TLVStruct): 56 | profile_id: ProfileIDValues = tlv_entry(1) 57 | level: ProfileSupportLevelValues = tlv_entry(2) 58 | packetization_mode: PacketizationModeValues = tlv_entry(3) 59 | cvo_enabled: CVOEnabledValues = tlv_entry(4) 60 | cvo_id: u8 = tlv_entry(5) 61 | 62 | 63 | @dataclass 64 | class AudioCodecParameters(TLVStruct): 65 | audio_channels: u8 = tlv_entry(1) 66 | bit_rate: BitRateValues = tlv_entry(2) 67 | sample_rate: SampleRateValues = tlv_entry(3) 68 | rtp_time: u8 = tlv_entry(4) 69 | 70 | 71 | @dataclass 72 | class VideoRTPParameters(TLVStruct): 73 | payload_type: u8 = tlv_entry(1) 74 | ssrc: u32 = tlv_entry(2) 75 | max_bitrate: u16 = tlv_entry(3) 76 | min_rtcp_interval: float = tlv_entry(4) 77 | max_mtu: u16 = tlv_entry(5) 78 | 79 | 80 | @dataclass 81 | class SelectedVideoParameters(TLVStruct): 82 | codec_type: VideoCodecTypeValues = tlv_entry(1) 83 | codec_parameters: VideoCodecParameters = tlv_entry(2) 84 | video_attrs: VideoAttrs = tlv_entry(3) 85 | rtp_params: VideoRTPParameters = tlv_entry(4) 86 | 87 | 88 | @dataclass 89 | class AudioRTPParameters(TLVStruct): 90 | payload_type: u8 = tlv_entry(1) 91 | ssrc: u32 = tlv_entry(2) 92 | max_bitrate: u16 = tlv_entry(3) 93 | min_rtcp_interval: float = tlv_entry(4) 94 | comfort_noise_payload_type: u8 = tlv_entry(6) 95 | 96 | 97 | @dataclass 98 | class SelectedAudioParameters(TLVStruct): 99 | codec_type: AudioCodecValues = tlv_entry(1) 100 | codec_parameters: AudioCodecParameters = tlv_entry(2) 101 | rtp_params: AudioRTPParameters = tlv_entry(3) 102 | comfort_noise: u8 = tlv_entry(4) 103 | 104 | 105 | @dataclass 106 | class AudioCodecConfiguration(TLVStruct): 107 | codec: AudioCodecValues = tlv_entry(1) 108 | parameters: Sequence[AudioCodecParameters] = tlv_entry(2) 109 | 110 | 111 | @dataclass 112 | class VideoConfigConfiguration(TLVStruct): 113 | codec_type: VideoCodecTypeValues = tlv_entry(1) 114 | codec_params: Sequence[VideoCodecParameters] = tlv_entry(2) 115 | video_attrs: Sequence[VideoAttrs] = tlv_entry(3) 116 | 117 | 118 | @dataclass 119 | class SupportedVideoStreamConfiguration(TLVStruct): 120 | """ 121 | UUID 00000114-0000-1000-8000-0026BB765291 122 | Type public.hap.characteristic.supported-video-stream-configuration 123 | """ 124 | 125 | config: Sequence[VideoConfigConfiguration] = tlv_entry(1) 126 | 127 | 128 | @dataclass 129 | class SupportedAudioStreamConfiguration(TLVStruct): 130 | """ 131 | UUID 00000115-0000-1000-8000-0026BB765291 132 | Type public.hap.characteristic.supported-audio-stream-configuration 133 | """ 134 | 135 | config: Sequence[AudioCodecConfiguration] = tlv_entry(1) 136 | comfort_noise: u8 = tlv_entry(2) 137 | 138 | 139 | @dataclass 140 | class SupportedRTPConfiguration(TLVStruct): 141 | """ 142 | UUID 00000116-0000-1000-8000-0026BB765291 143 | Type public.hap.characteristic.supported-rtp-configuration 144 | """ 145 | 146 | srtp_crypto_suite: SRTPCryptoSuiteValues = tlv_entry(2) 147 | 148 | 149 | @dataclass 150 | class SelectedRTPStreamConfiguration(TLVStruct): 151 | """ 152 | UUID 00000117-0000-1000-8000-0026BB765291 153 | Type public.hap.characteristic.selected-rtp-stream-configuration 154 | """ 155 | 156 | control: SessionControl = tlv_entry(1) 157 | video_params: SelectedVideoParameters = tlv_entry(2) 158 | audio_params: SelectedAudioParameters = tlv_entry(3) 159 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from typing import NewType 17 | 18 | CharacteristicUUID = NewType("CharacteristicUUID", str) 19 | CharacteristicShortUUID = NewType("CharacteristicShortUUID", str) 20 | -------------------------------------------------------------------------------- /aiohomekit/model/characteristics/units.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | class CharacteristicUnits: 19 | """ 20 | See table 5-6 page 68 21 | """ 22 | 23 | celsius = "celsius" 24 | percentage = "percentage" 25 | arcdegrees = "arcdegrees" 26 | lux = "lux" 27 | seconds = "seconds" 28 | 29 | 30 | class BleCharacteristicUnits: 31 | """ 32 | Mapping taken from Table 6-37 page 130 and https://www.bluetooth.com/specifications/assigned-numbers/units 33 | """ 34 | 35 | celsius = 0x272F 36 | arcdegrees = 0x2763 37 | percentage = 0x27AD 38 | unitless = 0x2700 39 | lux = 0x2731 40 | seconds = 0x2703 41 | -------------------------------------------------------------------------------- /aiohomekit/model/entity_map.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """ 18 | Typing hints for the serialization format used by the JSON part of the HomeKit API. 19 | """ 20 | 21 | from __future__ import annotations 22 | 23 | from typing import TypedDict, Union 24 | 25 | Characteristic = TypedDict( 26 | "Characteristic", 27 | { 28 | "type": str, 29 | "iid": int, 30 | "description": str, 31 | "value": Union[str, float, int, bool], 32 | "perms": list[str], 33 | "unit": str, 34 | "format": str, 35 | "valid-values": list[int], 36 | "minValue": Union[int, float], 37 | "maxValue": Union[int, float], 38 | "minStep": Union[int, float], 39 | "minLen": int, 40 | }, 41 | total=False, 42 | ) 43 | 44 | 45 | class Service(TypedDict, total=False): 46 | type: str 47 | iid: int 48 | characteristics: list[Characteristic] 49 | linked: list[int] 50 | 51 | 52 | class Accessory(TypedDict, total=True): 53 | aid: int 54 | services: list[Service] 55 | 56 | 57 | Accesories = list[Accessory] 58 | -------------------------------------------------------------------------------- /aiohomekit/model/feature_flags.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import enum 18 | 19 | 20 | class FeatureFlags(enum.IntFlag): 21 | SUPPORTS_APPLE_AUTHENTICATION_COPROCESSOR = 1 22 | SUPPORTS_SOFTWARE_AUTHENTICATION = 2 23 | -------------------------------------------------------------------------------- /aiohomekit/model/services/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | __all__ = [ 18 | "Service", 19 | "ServiceShortUUID", 20 | "ServiceUUID", 21 | "ServicesTypes", 22 | ] 23 | 24 | from .service import Service 25 | from .service_types import ServicesTypes 26 | from .types import ServiceShortUUID, ServiceUUID 27 | -------------------------------------------------------------------------------- /aiohomekit/model/services/service.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from __future__ import annotations 18 | 19 | from collections.abc import Iterable, Iterator 20 | from typing import TYPE_CHECKING, Any 21 | 22 | from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes 23 | from aiohomekit.model.characteristics.characteristic import check_convert_value 24 | from aiohomekit.model.services.data import services 25 | from aiohomekit.uuid import normalize_uuid 26 | 27 | if TYPE_CHECKING: 28 | from aiohomekit.model import Accessory 29 | 30 | 31 | class Characteristics: 32 | """Represents a collection of characteristics.""" 33 | 34 | _characteristics: list[Characteristic] 35 | 36 | def __init__(self) -> None: 37 | """Initialise a collection of characteristics.""" 38 | self._characteristics = [] 39 | self._iid_to_characteristic: dict[int, Characteristic] = {} 40 | 41 | def append(self, char: Characteristic) -> None: 42 | """Add a characteristic.""" 43 | self._characteristics.append(char) 44 | self._iid_to_characteristic[char.iid] = char 45 | 46 | def get(self, iid: int) -> Characteristic: 47 | """Get a characteristic by iid.""" 48 | return self._iid_to_characteristic.get(iid) 49 | 50 | def __iter__(self) -> Iterator[Characteristic]: 51 | """Iterate over characteristics.""" 52 | return iter(self._characteristics) 53 | 54 | def filter(self, char_types: Iterable[str] | None = None) -> Iterator[Characteristic]: 55 | """Filter characteristics by type.""" 56 | matches = iter(self) 57 | 58 | if char_types: 59 | char_types = {normalize_uuid(c) for c in char_types} 60 | matches = filter(lambda char: char.type in char_types, matches) 61 | 62 | return matches 63 | 64 | def first(self, char_types: Iterable[str] | None = None) -> Characteristic: 65 | """Get the first characteristic.""" 66 | return next(self.filter(char_types=char_types)) 67 | 68 | 69 | class Service: 70 | """Represents a service on an accessory.""" 71 | 72 | type: str 73 | iid: int 74 | linked: set[Service] 75 | 76 | characteristics: Characteristics 77 | characteristics_by_type: dict[str, Characteristic] 78 | accessory: Accessory 79 | 80 | def __init__( 81 | self, 82 | accessory: Accessory, 83 | service_type: str, 84 | name: str | None = None, 85 | add_required: bool = False, 86 | iid: int | None = None, 87 | ) -> None: 88 | """Initialise a service.""" 89 | self.type = normalize_uuid(service_type) 90 | 91 | self.accessory = accessory 92 | self.iid = iid or accessory.get_next_id() 93 | self.characteristics = Characteristics() 94 | self.characteristics_by_type: dict[str, Characteristic] = {} 95 | self.linked: list[Service] = [] 96 | 97 | if name: 98 | char = self.add_char(CharacteristicsTypes.NAME) 99 | char.set_value(name) 100 | 101 | if add_required: 102 | for required in services[self.type]["required"]: 103 | if required not in self.characteristics_by_type: 104 | self.add_char(required) 105 | 106 | def has(self, char_type: str) -> bool: 107 | """Return True if the service has a characteristic.""" 108 | return normalize_uuid(char_type) in self.characteristics_by_type 109 | 110 | def value(self, char_type: str, default_value: Any | None = None) -> Any: 111 | """Return the value of a characteristic.""" 112 | char_type = normalize_uuid(char_type) 113 | 114 | if char_type not in self.characteristics_by_type: 115 | return default_value 116 | 117 | return self.characteristics_by_type[char_type].value 118 | 119 | def __getitem__(self, key) -> Characteristic: 120 | """Get a characteristic by type.""" 121 | return self.characteristics_by_type[normalize_uuid(key)] 122 | 123 | def add_char(self, char_type: str, **kwargs: Any) -> Characteristic: 124 | """Add a characteristic to the service.""" 125 | char = Characteristic(self, char_type, **kwargs) 126 | self.characteristics.append(char) 127 | self.characteristics_by_type[char.type] = char 128 | return char 129 | 130 | def get_char_by_iid(self, iid: int) -> Characteristic | None: 131 | """Get a characteristic by iid.""" 132 | return self.characteristics.get(iid) 133 | 134 | def add_linked_service(self, service: Service) -> None: 135 | """Add a linked service.""" 136 | self.linked.append(service) 137 | 138 | def build_update(self, payload: dict[str, Any]) -> list[tuple[int, int, Any]]: 139 | """ 140 | Given a payload in the form of {CHAR_TYPE: value}, render in a form suitable to pass 141 | to put_characteristics using aid and iid. 142 | """ 143 | result = [] 144 | 145 | for char_type, value in payload.items(): 146 | char = self[char_type] 147 | value = check_convert_value(value, char) 148 | result.append((self.accessory.aid, char.iid, value)) 149 | 150 | return result 151 | 152 | def to_accessory_and_service_list(self) -> dict[str, Any]: 153 | """Return the service as a dictionary.""" 154 | characteristics_list = [] 155 | for c in self.characteristics: 156 | characteristics_list.append(c.to_accessory_and_service_list()) 157 | 158 | d = { 159 | "iid": self.iid, 160 | "type": self.type, 161 | "characteristics": characteristics_list, 162 | } 163 | if linked := [service.iid for service in self.linked]: 164 | d["linked"] = linked 165 | return d 166 | 167 | @property 168 | def available(self) -> bool: 169 | """Return True if all characteristics are available.""" 170 | return all(c.available for c in self.characteristics) 171 | -------------------------------------------------------------------------------- /aiohomekit/model/services/service_types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | class ServicesTypes: 19 | """ 20 | All known service types. 21 | 22 | There is no centralised repository for all of these. 23 | 24 | * Some have by reverse engineered by open source projects like HAP-NodeJS 25 | * Some are "self documenting" (the name appears in the API endpoints) 26 | * Some are documented in open source 27 | """ 28 | 29 | ACCESSORY_INFORMATION = "0000003E-0000-1000-8000-0026BB765291" 30 | ACCESSORY_METRICS = "00000270-0000-1000-8000-0026BB765291" 31 | ACCESSORY_RUNTIME_INFORMATION = "00000239-0000-1000-8000-0026BB765291" 32 | ACCESS_CODE = "00000260-0000-1000-8000-0026BB765291" 33 | ACCESS_CONTROL = "000000DA-0000-1000-8000-0026BB765291" 34 | AIR_PURIFIER = "000000BB-0000-1000-8000-0026BB765291" 35 | AIR_QUALITY_SENSOR = "0000008D-0000-1000-8000-0026BB765291" 36 | ASSET_UPDATE = "00000267-0000-1000-8000-0026BB765291" 37 | ASSISTANT = "0000026A-0000-1000-8000-0026BB765291" 38 | AUDIO_STREAM_MANAGEMENT = "00000127-0000-1000-8000-0026BB765291" 39 | BATTERY_SERVICE = "00000096-0000-1000-8000-0026BB765291" 40 | BRIDGE_CONFIGURATION = "000000A1-0000-1000-8000-0026BB765291" 41 | BRIDGING_STATE = "00000062-0000-1000-8000-0026BB765291" 42 | CAMERA_CONTROL = "00000111-0000-1000-8000-0026BB765291" 43 | CAMERA_OPERATING_MODE = "0000021A-0000-1000-8000-0026BB765291" 44 | CAMERA_RECORDING_MANAGEMENT = "00000204-0000-1000-8000-0026BB765291" 45 | CAMERA_RTP_STREAM_MANAGEMENT = "00000110-0000-1000-8000-0026BB765291" 46 | CARBON_DIOXIDE_SENSOR = "00000097-0000-1000-8000-0026BB765291" 47 | CARBON_MONOXIDE_SENSOR = "0000007F-0000-1000-8000-0026BB765291" 48 | CLOUD_RELAY = "0000005A-0000-1000-8000-0026BB765291" 49 | CONTACT_SENSOR = "00000080-0000-1000-8000-0026BB765291" 50 | DATA_STREAM_TRANSPORT_MANAGEMENT = "00000129-0000-1000-8000-0026BB765291" 51 | DIAGNOSTICS = "00000237-0000-1000-8000-0026BB765291" 52 | DOOR = "00000081-0000-1000-8000-0026BB765291" 53 | DOORBELL = "00000121-0000-1000-8000-0026BB765291" 54 | FAN = "00000040-0000-1000-8000-0026BB765291" 55 | FAN_V2 = "000000B7-0000-1000-8000-0026BB765291" 56 | FAUCET = "000000D7-0000-1000-8000-0026BB765291" 57 | FILTER_MAINTENANCE = "000000BA-0000-1000-8000-0026BB765291" 58 | GARAGE_DOOR_OPENER = "00000041-0000-1000-8000-0026BB765291" 59 | HEATER_COOLER = "000000BC-0000-1000-8000-0026BB765291" 60 | HUMIDIFIER_DEHUMIDIFIER = "000000BD-0000-1000-8000-0026BB765291" 61 | HUMIDITY_SENSOR = "00000082-0000-1000-8000-0026BB765291" 62 | INPUT_SOURCE = "000000D9-0000-1000-8000-0026BB765291" 63 | IRRIGATION_SYSTEM = "000000CF-0000-1000-8000-0026BB765291" 64 | LEAK_SENSOR = "00000083-0000-1000-8000-0026BB765291" 65 | LIGHTBULB = "00000043-0000-1000-8000-0026BB765291" 66 | LIGHT_SENSOR = "00000084-0000-1000-8000-0026BB765291" 67 | LOCK_MANAGEMENT = "00000044-0000-1000-8000-0026BB765291" 68 | LOCK_MECHANISM = "00000045-0000-1000-8000-0026BB765291" 69 | MICROPHONE = "00000112-0000-1000-8000-0026BB765291" 70 | MOTION_SENSOR = "00000085-0000-1000-8000-0026BB765291" 71 | NFCACCESS = "00000266-0000-1000-8000-0026BB765291" 72 | OCCUPANCY_SENSOR = "00000086-0000-1000-8000-0026BB765291" 73 | OUTLET = "00000047-0000-1000-8000-0026BB765291" 74 | PAIRING = "00000055-0000-1000-8000-0026BB765291" 75 | POWER_MANAGEMENT = "00000221-0000-1000-8000-0026BB765291" 76 | PROTOCOL_INFORMATION = "000000A2-0000-1000-8000-0026BB765291" 77 | SECURITY_SYSTEM = "0000007E-0000-1000-8000-0026BB765291" 78 | SERVICE_LABEL = "000000CC-0000-1000-8000-0026BB765291" 79 | SIRI = "00000133-0000-1000-8000-0026BB765291" 80 | SIRI_ENDPOINT = "00000253-0000-1000-8000-0026BB765291" 81 | SLAT = "000000B9-0000-1000-8000-0026BB765291" 82 | SMART_SPEAKER = "00000228-0000-1000-8000-0026BB765291" 83 | SMOKE_SENSOR = "00000087-0000-1000-8000-0026BB765291" 84 | SPEAKER = "00000113-0000-1000-8000-0026BB765291" 85 | STATEFUL_PROGRAMMABLE_SWITCH = "00000088-0000-1000-8000-0026BB765291" 86 | STATELESS_PROGRAMMABLE_SWITCH = "00000089-0000-1000-8000-0026BB765291" 87 | SWITCH = "00000049-0000-1000-8000-0026BB765291" 88 | TARGET_CONTROL = "00000125-0000-1000-8000-0026BB765291" 89 | TARGET_CONTROL_MANAGEMENT = "00000122-0000-1000-8000-0026BB765291" 90 | TELEVISION = "000000D8-0000-1000-8000-0026BB765291" 91 | TEMPERATURE_SENSOR = "0000008A-0000-1000-8000-0026BB765291" 92 | THERMOSTAT = "0000004A-0000-1000-8000-0026BB765291" 93 | THREAD_TRANSPORT = "00000701-0000-1000-8000-0026BB765291" 94 | TIME_INFORMATION = "00000099-0000-1000-8000-0026BB765291" 95 | TRANSFER_TRANSPORT_MANAGEMENT = "00000203-0000-1000-8000-0026BB765291" 96 | TUNNEL = "00000056-0000-1000-8000-0026BB765291" 97 | VALVE = "000000D0-0000-1000-8000-0026BB765291" 98 | WINDOW = "0000008B-0000-1000-8000-0026BB765291" 99 | WINDOW_COVERING = "0000008C-0000-1000-8000-0026BB765291" 100 | WI_FI_ROUTER = "0000020A-0000-1000-8000-0026BB765291" 101 | WI_FI_SATELLITE = "0000020F-0000-1000-8000-0026BB765291" 102 | WI_FI_TRANSPORT = "0000022A-0000-1000-8000-0026BB765291" 103 | -------------------------------------------------------------------------------- /aiohomekit/model/services/types.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | from typing import NewType 17 | 18 | ServiceUUID = NewType("ServiceUUID", str) 19 | ServiceShortUUID = NewType("ServiceShortUUID", str) 20 | -------------------------------------------------------------------------------- /aiohomekit/model/status_flags.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | from enum import IntFlag 19 | 20 | 21 | class StatusFlags(IntFlag): 22 | UNPAIRED = 0x01 23 | WIFI_UNCONFIGURED = 0x02 24 | PROBLEM_DETECTED = 0x04 25 | -------------------------------------------------------------------------------- /aiohomekit/pdu.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from __future__ import annotations 18 | 19 | import logging 20 | import struct 21 | from collections.abc import Iterable 22 | from enum import Enum 23 | 24 | from aiohomekit.enum import EnumWithDescription 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | STRUCT_BBBH_PACK = struct.Struct(" Iterable[bytes]: 69 | """ 70 | Encodes a PDU. 71 | 72 | The header is required, but the body (including length) is optional. 73 | 74 | For BLE, the PDU must be fragmented to fit into ATT_MTU. The default here is 512. 75 | In a secure session this drops to 496 (16 bytes for the encryption). Some devices 76 | drop this quite a bit. 77 | """ 78 | retval = STRUCT_BBBH_PACK(0, opcode.value, tid, iid) 79 | if not data: 80 | yield retval 81 | return 82 | 83 | # Full header + body size + data 84 | next_size = fragment_size - 7 85 | 86 | yield bytes(retval + STRUCT_H_PACK(len(data)) + data[:next_size]) 87 | data = data[next_size:] 88 | 89 | # Control + tid + data 90 | next_size = fragment_size - 2 91 | for i in range(0, len(data), next_size): 92 | yield STRUCT_BB_PACK(0x80, tid) + data[i : i + next_size] 93 | 94 | 95 | def decode_pdu(expected_tid: int, data: bytes) -> tuple[PDUStatus, bool, bytes]: 96 | control, tid, status = STRUCT_BBB_UNPACK(data[:3]) 97 | status = PDUStatus(status) 98 | 99 | logger.debug( 100 | "Got PDU %s: TID %02x (Expected: %02x), Status:%s, Len:%d - %s", 101 | control, 102 | tid, 103 | expected_tid, 104 | status, 105 | len(data) - 5, 106 | data, 107 | ) 108 | 109 | if tid != expected_tid: 110 | raise ValueError(f"Expected transaction {expected_tid} but got transaction {tid}") 111 | 112 | if status != PDUStatus.SUCCESS: 113 | # We can't currently raise here or it will break the encryption 114 | # stream 115 | logger.warning(f"Transaction {tid} failed with error {status} ({status.description})") 116 | 117 | if len(data) < 5: 118 | return status, 0, b"" 119 | 120 | expected_length = STRUCT_H_UNPACK(data[3:5])[0] 121 | data = data[5:] 122 | 123 | return status, expected_length, data 124 | 125 | 126 | def decode_pdu_continuation(expected_tid, data): 127 | control, tid = STRUCT_BB_UNPACK(data[:2]) 128 | 129 | logger.debug( 130 | "Got PDU %x: TID %02x (Expected: %02x) Len:%d", 131 | control, 132 | tid, 133 | expected_tid, 134 | len(data) - 2, 135 | ) 136 | 137 | if not (control & 0x80): 138 | raise ValueError("Expected continuation flag but isn't set") 139 | 140 | if tid != expected_tid: 141 | raise ValueError(f"Expected transaction {expected_tid} but got transaction {tid}") 142 | 143 | return data[2:] 144 | -------------------------------------------------------------------------------- /aiohomekit/protocol/statuscodes.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from aiohomekit.enum import EnumWithDescription 18 | 19 | 20 | class HapStatusCode(EnumWithDescription): 21 | SUCCESS = 0, "This specifies a success for the request." 22 | INSUFFICIENT_PRIVILEGES = -70401, "Request denied due to insufficient privileges." 23 | UNABLE_TO_COMMUNICATE = ( 24 | -70402, 25 | "Unable to communicate with requested service, e.g. the power to the accessory was turned off.", 26 | ) 27 | RESOURCE_BUSY = -70403, "Resource is busy, try again." 28 | CANT_WRITE_READ_ONLY = -70404, "Cannot write to read only characteristic." 29 | CANT_READ_WRITE_ONLY = -70405, "Cannot read from a write only characteristic." 30 | NOTIFICATION_NOT_SUPPORTED = ( 31 | -70406, 32 | "Notification is not supported for characteristic.", 33 | ) 34 | OUT_OF_RESOURCES = -70407, "Out of resources to process request." 35 | TIMED_OUT = -70408, "Operation timed out." 36 | RESOURCE_NOT_EXIST = -70409, "Resource does not exist." 37 | INVALID_VALUE = -70410, "Accessory received an invalid value in a write request." 38 | INSUFFICIENT_AUTH = -70411, "Insufficient Authorization." 39 | NOT_ALLOWED_IN_CURRENT_STATE = -70412, "Not allowed in current state" 40 | 41 | 42 | def to_status_code(status_code: int) -> HapStatusCode: 43 | # Some HAP implementations return positive values for error code (myq) 44 | return HapStatusCode(abs(status_code) * -1) 45 | 46 | 47 | class _HapBleStatusCodes: 48 | """ 49 | This data is taken from Table 6-26 HAP Status Codes on page 116. 50 | """ 51 | 52 | SUCCESS = 0x00 53 | UNSUPPORTED_PDU = 0x01 54 | MAX_PROCEDURES = 0x02 55 | INSUFFICIENT_AUTHORIZATION = 0x03 56 | INVALID_INSTANCE_ID = 0x04 57 | INSUFFICIENT_AUTHENTICATION = 0x05 58 | INVALID_REQUEST = 0x06 59 | 60 | def __init__(self) -> None: 61 | self._codes = { 62 | _HapBleStatusCodes.SUCCESS: "The request was successful.", 63 | _HapBleStatusCodes.UNSUPPORTED_PDU: "The request failed as the HAP PDU was not recognized or supported.", 64 | _HapBleStatusCodes.MAX_PROCEDURES: "The request failed as the accessory has reached the limit on" 65 | " the simultaneous procedures it can handle.", 66 | _HapBleStatusCodes.INSUFFICIENT_AUTHORIZATION: "Characteristic requires additional authorization data.", 67 | _HapBleStatusCodes.INVALID_INSTANCE_ID: "The HAP Request's characteristic Instance Id did not match" 68 | " the addressed characteristic's instance Id", 69 | _HapBleStatusCodes.INSUFFICIENT_AUTHENTICATION: "Characterisitc access required a secure session to be" 70 | " established.", 71 | _HapBleStatusCodes.INVALID_REQUEST: "Accessory was not able to perform the requested operation", 72 | } 73 | 74 | self._categories_rev = {self._codes[k]: k for k in self._codes.keys()} 75 | 76 | def __getitem__(self, item): 77 | if item in self._codes: 78 | return self._codes[item] 79 | 80 | raise KeyError(f"Item {item} not found") 81 | 82 | 83 | HapBleStatusCodes = _HapBleStatusCodes() 84 | -------------------------------------------------------------------------------- /aiohomekit/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jc2k/aiohomekit/13542a4db36c2e5e374efa70a4542f9a79aca0f4/aiohomekit/py.typed -------------------------------------------------------------------------------- /aiohomekit/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import enum 5 | import logging 6 | import re 7 | import sys 8 | from collections.abc import Awaitable 9 | from typing import Any, TypeVar 10 | 11 | from aiohomekit.const import COAP_TRANSPORT_SUPPORTED, IP_TRANSPORT_SUPPORTED 12 | from aiohomekit.exceptions import MalformedPinError 13 | from aiohomekit.model.characteristics import Characteristic 14 | from aiohomekit.model.feature_flags import FeatureFlags 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | T = TypeVar("T") 19 | 20 | if sys.version_info[:2] < (3, 11): 21 | from async_timeout import timeout as asyncio_timeout 22 | else: 23 | from asyncio import timeout as asyncio_timeout # noqa: F401 24 | 25 | _BACKGROUND_TASKS = set() 26 | 27 | 28 | def async_create_task(coroutine: Awaitable[T], *, name=None) -> asyncio.Task[T]: 29 | """Wrapper for asyncio.create_task that logs errors.""" 30 | task = asyncio.create_task(coroutine, name=name) 31 | _BACKGROUND_TASKS.add(task) 32 | task.add_done_callback(_handle_task_result) 33 | task.add_done_callback(_BACKGROUND_TASKS.discard) 34 | return task 35 | 36 | 37 | def _handle_task_result(task: asyncio.Task) -> None: 38 | """Handle the result of a task.""" 39 | try: 40 | task.result() 41 | except asyncio.CancelledError: 42 | # Ignore cancellations 43 | pass 44 | except Exception: 45 | _LOGGER.exception("Failure running background task: %s", task.get_name()) 46 | 47 | 48 | def clamp_enum_to_char(all_valid_values: enum.EnumMeta, char: Characteristic) -> set[Any]: 49 | """Clamp possible values of an enum to restrictions imposed by a manufacturer.""" 50 | valid_values = set(all_valid_values) 51 | 52 | if char.minValue is not None: 53 | valid_values = {target_state for target_state in valid_values if target_state >= char.minValue} 54 | 55 | if char.maxValue is not None: 56 | valid_values = {target_state for target_state in valid_values if target_state <= char.maxValue} 57 | 58 | if char.valid_values: 59 | valid_values = valid_values.intersection(set(char.valid_values)) 60 | 61 | return valid_values 62 | 63 | 64 | def check_pin_format(pin: str) -> None: 65 | """ 66 | Checks the format of the given pin: XXX-XX-XXX with X being a digit from 0 to 9 67 | 68 | :raises MalformedPinError: if the validation fails 69 | """ 70 | if not re.match(r"^\d\d\d-\d\d-\d\d\d$", pin): 71 | raise MalformedPinError( 72 | "The pin must be of the following XXX-XX-XXX where X is a digit between 0 and 9." 73 | ) 74 | 75 | 76 | def pair_with_auth(ff: FeatureFlags) -> bool: 77 | if ff & FeatureFlags.SUPPORTS_APPLE_AUTHENTICATION_COPROCESSOR: 78 | return True 79 | 80 | if ff & FeatureFlags.SUPPORTS_SOFTWARE_AUTHENTICATION: 81 | return False 82 | 83 | # We don't know what kind of pairing this is, assume no auth 84 | return False 85 | 86 | 87 | def domain_to_name(domain: str) -> str: 88 | """ 89 | Given a Bonjour domain name, return a human readable name. 90 | 91 | Zealous Lizard's Tune Studio._music._tcp.local. -> Zealous Lizard's Tune Studio 92 | Fooo._hap._tcp.local. -> Fooo 93 | Baaar._hap._tcp.local. -> Baar 94 | """ 95 | return domain.partition(".")[0] 96 | 97 | 98 | def domain_supported(domain: str) -> bool: 99 | if domain.endswith("._hap._tcp.local.") and IP_TRANSPORT_SUPPORTED: 100 | return True 101 | if domain.endswith("._hap._udp.local.") and COAP_TRANSPORT_SUPPORTED: 102 | return True 103 | return False 104 | 105 | 106 | def serialize_broadcast_key(broadcast_key: bytes | None) -> str | None: 107 | """Serialize a broadcast key to a string.""" 108 | if broadcast_key is None: 109 | return None 110 | return broadcast_key.hex() 111 | 112 | 113 | def deserialize_broadcast_key(broadcast_key: str | None) -> bytes | None: 114 | """Deserialize a broadcast key from a string.""" 115 | if broadcast_key is None: 116 | return None 117 | return bytes.fromhex(broadcast_key) 118 | -------------------------------------------------------------------------------- /aiohomekit/uuid.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from functools import lru_cache 18 | from uuid import UUID 19 | 20 | BASE_UUID = "-0000-1000-8000-0026BB765291" 21 | 22 | 23 | @lru_cache(maxsize=1024) 24 | def shorten_uuid(value: str) -> str: 25 | """ 26 | Returns the shortned version of a UUID. 27 | 28 | This only applies to official HK services and characteristics. 29 | """ 30 | value = value.upper() 31 | 32 | if value.endswith(BASE_UUID): 33 | value = value.split("-", 1)[0] 34 | return value.lstrip("0") 35 | 36 | return normalize_uuid(value) 37 | 38 | 39 | @lru_cache(maxsize=1024) 40 | def normalize_uuid(value: str) -> str: 41 | """ 42 | Returns a normalized UUID. 43 | 44 | This includes postfixing -0000-1000-8000-0026BB765291 and ensuring the case. 45 | """ 46 | value = value.upper() 47 | 48 | if len(value) <= 8: 49 | prefix = "0" * (8 - len(value)) 50 | return f"{prefix}{value}{BASE_UUID}" 51 | 52 | if len(value) == 36: 53 | return value 54 | 55 | # Handle cases like 34AB8811AC7F4340BAC3FD6A85F9943B 56 | # Reject the rest 57 | try: 58 | return str(UUID(value.zfill(32))).upper() 59 | except ValueError: 60 | raise ValueError(f"{value} doesn't look like a valid UUID or short UUID") 61 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | github_checks: 2 | annotations: false 3 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | persistent=no 7 | 8 | [BASIC] 9 | good-names=id,i,j,k,ex,Run,_,fp 10 | 11 | [MESSAGES CONTROL] 12 | # Reasons disabled: 13 | # format - handled by black 14 | # locally-disabled - it spams too much 15 | # duplicate-code - unavoidable 16 | # cyclic-import - doesn't test if both import on load 17 | # abstract-class-little-used - prevents from setting right foundation 18 | # unused-argument - generic callbacks and setup methods create a lot of warnings 19 | # global-statement - used for the on-demand requirement installation 20 | # redefined-variable-type - this is Python, we're duck typing! 21 | # too-many-* - are not enforced for the sake of readability 22 | # too-few-* - same as too-many-* 23 | # abstract-method - with intro of async there are always methods missing 24 | # inconsistent-return-statements - doesn't handle raise 25 | # unnecessary-pass - readability for functions which only contain pass 26 | # import-outside-toplevel - TODO 27 | # too-many-ancestors - it's too strict. 28 | # wrong-import-order - isort guards this 29 | disable= 30 | format, 31 | abstract-class-little-used, 32 | abstract-method, 33 | cyclic-import, 34 | duplicate-code, 35 | global-statement, 36 | import-outside-toplevel, 37 | inconsistent-return-statements, 38 | locally-disabled, 39 | not-context-manager, 40 | redefined-variable-type, 41 | too-few-public-methods, 42 | too-many-ancestors, 43 | too-many-arguments, 44 | too-many-branches, 45 | too-many-instance-attributes, 46 | too-many-lines, 47 | too-many-locals, 48 | too-many-public-methods, 49 | too-many-return-statements, 50 | too-many-statements, 51 | too-many-boolean-expressions, 52 | unnecessary-pass, 53 | unused-argument, 54 | wrong-import-order 55 | enable= 56 | use-symbolic-message-instead 57 | 58 | [REPORTS] 59 | score=no 60 | 61 | [TYPECHECK] 62 | # For attrs 63 | ignored-classes=_CountingAttr 64 | 65 | [FORMAT] 66 | expected-line-ending-format=LF 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiohomekit" 3 | version = "3.2.15" 4 | description = "An asyncio HomeKit client" 5 | authors = [{ name = "John Carr", email = "john.carr@unrouted.co.uk" }] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | requires-python = ">=3.10" 9 | keywords = ["HomeKit", "home", "automation"] 10 | dynamic = ["classifiers", "dependencies"] 11 | 12 | [project.urls] 13 | "Homepage" = "https://github.com/Jc2k/aiohomekit" 14 | "Repository" = "https://github.com/Jc2k/aiohomekit" 15 | 16 | [tool.poetry] 17 | classifiers = [ 18 | "Topic :: Home Automation", 19 | "Intended Audience :: Developers", 20 | "Intended Audience :: End Users/Desktop", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11" 23 | ] 24 | include = ["aiohomekit/py.typed"] 25 | 26 | [tool.poetry.dependencies] 27 | python = "^3.10,<3.14" 28 | cryptography = ">=2.9.2" 29 | zeroconf = ">=0.132.2" 30 | commentjson = "^0.9.0" 31 | aiocoap = ">=0.4.5" 32 | bleak = ">=0.22.0" 33 | chacha20poly1305-reuseable = ">=0.12.1" 34 | bleak-retry-connector = ">=2.9.0" 35 | orjson = ">=3.7.8" 36 | async-timeout = {version = ">=4.0.2", python = "<3.11"} 37 | chacha20poly1305 = "^0.0.3" 38 | async-interrupt = ">=1.1.1" 39 | aiohappyeyeballs = ">=2.3.0" 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | mypy = "^1.16" 43 | flake8 = ">=4.0.1,<8.0.0" 44 | pytest = ">=7.2,<9.0" 45 | coverage = ">=6.3,<8.0" 46 | pylint = ">=2.12.2,<4.0.0" 47 | pytest-aiohttp = "^1.0.3" 48 | pyupgrade = ">=2.31,<4.0" 49 | pytest-cov = ">=5,<7" 50 | asynctest = "^0.13.0" 51 | aiohttp = ">=3.8.3" 52 | ruff = "^0.11.12" 53 | 54 | [tool.poetry.scripts] 55 | aiohomekitctl = "aiohomekit.__main__:sync_main" 56 | 57 | [tool.pytest.ini_options] 58 | minversion = "6.0" 59 | asyncio_mode = "auto" 60 | 61 | [tool.coverage.run] 62 | omit = ["tests/*"] 63 | 64 | [tool.isort] 65 | profile = "black" 66 | indent = " " 67 | force_sort_within_sections = "true" 68 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 69 | known_first_party = "aiohomekit,tests" 70 | forced_separate = "tests" 71 | combine_as_imports = "true" 72 | extra_standard_library = "_socket" 73 | 74 | [build-system] 75 | requires = ["poetry_core>=2.1.0"] 76 | build-backend = "poetry.core.masonry.api" 77 | 78 | [tool.ruff] 79 | line-length = 110 80 | 81 | [tool.ruff.lint] 82 | ignore = [ 83 | "S101", # use of assert 84 | "S104", # S104 Possible binding to all interfaces 85 | "PLR0912", # too many to fix right now 86 | "TID252", # skip 87 | "PLR0913", # too late to make changes here 88 | "PLR0911", # would be breaking change 89 | "TRY003", # too many to fix 90 | "SLF001", # design choice 91 | "PLR2004" , # too many to fix 92 | "PGH004", # too many to fix 93 | "PGH003", # too many to fix 94 | "SIM110", # this is slower 95 | "PYI034", # enable when we drop Py3.10 96 | "PYI032", # breaks Cython 97 | "PYI041", # breaks Cython 98 | "PERF401", # Cython: closures inside cpdef functions not yet supported 99 | ] 100 | select = [ 101 | "ASYNC", # async rules 102 | "B", # flake8-bugbear 103 | "C4", # flake8-comprehensions 104 | "S", # flake8-bandit 105 | "F", # pyflake 106 | "E", # pycodestyle 107 | "W", # pycodestyle 108 | "UP", # pyupgrade 109 | "I", # isort 110 | "RUF", # ruff specific 111 | "FLY", # flynt 112 | "G", # flake8-logging-format , 113 | "PERF", # Perflint 114 | "PGH", # pygrep-hooks 115 | "PIE", # flake8-pie 116 | "PL", # pylint 117 | "PT", # flake8-pytest-style 118 | "PTH", # flake8-pathlib 119 | "PYI", # flake8-pyi 120 | "RET", # flake8-return 121 | "RSE", # flake8-raise , 122 | "SIM", # flake8-simplify 123 | "SLF", # flake8-self 124 | "SLOT", # flake8-slots 125 | "T100", # Trace found: {name} used 126 | "T20", # flake8-print 127 | "TID", # Tidy imports 128 | "TRY", # tryceratops 129 | ] 130 | 131 | [tool.ruff.lint.per-file-ignores] 132 | "tests/**/*" = [ 133 | "D100", 134 | "D101", 135 | "D102", 136 | "D103", 137 | "D104", 138 | "S101", 139 | "SLF001", 140 | "PLR2004", # too many to fix right now 141 | "PT011", # too many to fix right now 142 | "PT006", # too many to fix right now 143 | "PGH003", # too many to fix right now 144 | "PT007", # too many to fix right now 145 | "PT027", # too many to fix right now 146 | "PLW0603" , # too many to fix right now 147 | "PLR0915", # too many to fix right now 148 | "FLY002", # too many to fix right now 149 | "PT018", # too many to fix right now 150 | "PLR0124", # too many to fix right now 151 | "SIM202" , # too many to fix right now 152 | "PT012" , # too many to fix right now 153 | "TID252", # too many to fix right now 154 | "PLR0913", # skip this one 155 | "SIM102" , # too many to fix right now 156 | "SIM108", # too many to fix right now 157 | "T201", # too many to fix right now 158 | "PT004", # nice to have 159 | ] 160 | "bench/**/*" = [ 161 | "T201", # intended 162 | ] 163 | "examples/**/*" = [ 164 | "T201", # intended 165 | ] 166 | "setup.py" = ["D100"] 167 | "conftest.py" = ["D100"] 168 | "docs/conf.py" = ["D100"] 169 | -------------------------------------------------------------------------------- /scripts/generate_metadata.py: -------------------------------------------------------------------------------- 1 | #! env python 2 | 3 | import json 4 | import plistlib 5 | import textwrap 6 | 7 | from aiohomekit.uuid import normalize_uuid 8 | 9 | plist = "/Applications/HomeKit Accessory Simulator.app/Contents/Frameworks/HAPAccessoryKit.framework/Versions/A/Resources/default.metadata.plist" 10 | with open(plist, "rb") as fp: 11 | data = plistlib.load(fp) 12 | 13 | 14 | enrichment = { 15 | "00000120-0000-1000-8000-0026BB765291": {"struct": ".structs.StreamingStatus"}, 16 | "00000117-0000-1000-8000-0026BB765291": {"struct": ".structs.SelectedRTPStreamConfiguration"}, 17 | "00000116-0000-1000-8000-0026BB765291": { 18 | "struct": ".structs.SupportedRTPConfiguration", 19 | "array": True, 20 | }, 21 | "00000115-0000-1000-8000-0026BB765291": {"struct": ".structs.SupportedAudioStreamConfiguration"}, 22 | "00000114-0000-1000-8000-0026BB765291": {"struct": ".structs.SupportedVideoStreamConfiguration"}, 23 | } 24 | 25 | 26 | characteristics = {} 27 | 28 | for char in data.get("Characteristics", []): 29 | name = char["Name"].replace(".", "_").replace(" ", "_").upper() 30 | 31 | c = characteristics[char["UUID"]] = { 32 | "name": name, 33 | "description": char["Name"], 34 | } 35 | 36 | if char.get("Properties"): 37 | c["perms"] = [] 38 | for perm in char.get("Properties"): 39 | if perm == "read": 40 | c["perms"].append("pr") 41 | if perm == "write": 42 | c["perms"].append("pw") 43 | if perm == "cnotify": 44 | c["perms"].append("ev") 45 | 46 | if char.get("Format"): 47 | c["format"] = char["Format"] 48 | 49 | if char.get("Unit"): 50 | c["unit"] = char["Unit"] 51 | 52 | if "Constraints" not in char: 53 | continue 54 | 55 | constraints = char["Constraints"] 56 | 57 | if constraints.get("MaximumValue"): 58 | c["max_value"] = constraints["MaximumValue"] 59 | if constraints.get("MaximumValue"): 60 | c["min_value"] = constraints["MinimumValue"] 61 | if constraints.get("StepValue"): 62 | c["step_value"] = constraints["StepValue"] 63 | 64 | 65 | with open("aiohomekit/model/characteristics/data.py", "w") as fp: 66 | fp.write("# AUTOGENERATED, DO NOT EDIT\n\n") 67 | 68 | for char in enrichment.values(): 69 | if "struct" in char: 70 | imp, frm = char["struct"].rsplit(".", 1) 71 | fp.write(f"from {imp} import {frm}\n") 72 | fp.write("\n\n") 73 | 74 | fp.write("characteristics = {\n") 75 | 76 | for char_uuid, char in characteristics.items(): 77 | name = json.dumps(char["name"]) 78 | description = json.dumps(char["description"]) 79 | perms = json.dumps(char["perms"]) 80 | format = json.dumps(char["format"]) 81 | 82 | fp.write(f' "{char_uuid}": {{\n') 83 | fp.write(f' "name": {name},\n') 84 | fp.write(f' "description": {description},\n') 85 | fp.write(f' "perms": {perms},\n') 86 | fp.write(f' "format": {format},\n') 87 | 88 | struct = enrichment.get(char_uuid, {}).get("struct") 89 | if struct: 90 | _, frm = struct.rsplit(".", 1) 91 | fp.write(f' "struct": {frm},\n') 92 | 93 | array = enrichment.get(char_uuid, {}).get("array") 94 | if array: 95 | fp.write(f' "array": {array},\n') 96 | 97 | if "unit" in char: 98 | unit = json.dumps(char["unit"]) 99 | fp.write(f' "unit": {unit},\n') 100 | 101 | if "max_value" in char: 102 | max_value = json.dumps(char["max_value"]) 103 | fp.write(f' "max_value": {max_value},\n') 104 | 105 | if "min_value" in char: 106 | min_value = json.dumps(char["min_value"]) 107 | fp.write(f' "min_value": {min_value},\n') 108 | 109 | if "step_value" in char: 110 | step_value = json.dumps(char["step_value"]) 111 | fp.write(f' "min_step": {step_value},\n') 112 | 113 | fp.write(" },\n") 114 | pass 115 | 116 | fp.write("}\n") 117 | 118 | 119 | for serv in data.get("Services", []): 120 | name = serv["Name"].replace(" ", "_").upper() 121 | short = normalize_uuid(serv["UUID"]) 122 | print(f'{name} = "{short}"') 123 | 124 | 125 | services = {} 126 | 127 | for serv in data.get("Services", []): 128 | name = serv["Name"].replace(" ", "_").upper() 129 | 130 | s = services[serv["UUID"]] = { 131 | "name": name, 132 | "description": serv["Name"], 133 | "required": serv.get("RequiredCharacteristics", []), 134 | "optional": serv.get("OptionalCharacteristics", []), 135 | } 136 | 137 | 138 | with open("aiohomekit/model/services/data.py", "w") as fp: 139 | fp.write( 140 | textwrap.dedent(""" 141 | # AUTOGENERATED, DO NOT EDIT 142 | 143 | services = 144 | """).strip() 145 | ) 146 | fp.write(" " + json.dumps(services, indent=4)) 147 | fp.write("\n") 148 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | VERSION=${1:-patch} 4 | 5 | poetry version $VERSION 6 | git commit -a -m "Version bump" 7 | git tag -a `poetry version | awk '{ print $2; }'` 8 | git push --tags 9 | git push 10 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | set -e 3 | alias python="poetry run python" 4 | poetry run find . -name '*.py' -exec pyupgrade --py39-plus {} + 5 | python -m black tests aiohomekit 6 | python -m isort tests aiohomekit 7 | python -m black tests aiohomekit --check --diff 8 | python -m flake8 tests aiohomekit 9 | python -m pytest tests 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | norecursedirs = .git testing_config 4 | 5 | [flake8] 6 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 7 | doctests = True 8 | # To work with Black 9 | max-line-length = 88 10 | # E501: line too long 11 | # W503: Line break occurred before a binary operator 12 | # E203: Whitespace before ':' 13 | # D202 No blank lines allowed after function docstring 14 | # W504 line break after binary operator 15 | ignore = 16 | E501, 17 | W503, 18 | E203, 19 | D202, 20 | W504 21 | 22 | [mypy] 23 | python_version = 3.9 24 | #ignore_errors = true 25 | #follow_imports = silent 26 | #ignore_missing_imports = true 27 | #warn_incomplete_stub = true 28 | #warn_redundant_casts = true 29 | #warn_unused_configs = true 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jc2k/aiohomekit/13542a4db36c2e5e374efa70a4542f9a79aca0f4/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/idevices_switch.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "aid": 1, 4 | "services": [ 5 | { 6 | "type": "0000003E-0000-1000-8000-0026BB765291", 7 | "characteristics": [ 8 | { 9 | "iid": 2, 10 | "perms": ["pr"], 11 | "type": "00000023-0000-1000-8000-0026BB765291", 12 | "value": "iDevices Switch" 13 | }, 14 | { 15 | "iid": 3, 16 | "perms": ["pr"], 17 | "type": "00000020-0000-1000-8000-0026BB765291", 18 | "value": "iDevices LLC" 19 | }, 20 | { 21 | "iid": 4, 22 | "perms": ["pr"], 23 | "type": "00000021-0000-1000-8000-0026BB765291", 24 | "value": "IDEV0001" 25 | }, 26 | { 27 | "iid": 5, 28 | "perms": ["pr"], 29 | "type": "00000030-0000-1000-8000-0026BB765291", 30 | "value": "00080685" 31 | }, 32 | { 33 | "iid": 6, 34 | "perms": ["pw"], 35 | "type": "00000014-0000-1000-8000-0026BB765291" 36 | }, 37 | { 38 | "iid": 7, 39 | "perms": ["pr"], 40 | "type": "00000052-0000-1000-8000-0026BB765291", 41 | "value": "1.2.1" 42 | }, 43 | { 44 | "iid": 8, 45 | "perms": ["pr"], 46 | "type": "00000053-0000-1000-8000-0026BB765291", 47 | "value": "1" 48 | } 49 | ], 50 | "iid": 1, 51 | "stype": "accessory-information" 52 | }, 53 | { 54 | "type": "00000049-0000-1000-8000-0026BB765291", 55 | "characteristics": [ 56 | { 57 | "iid": 11, 58 | "perms": ["pr", "pw", "ev"], 59 | "type": "00000025-0000-1000-8000-0026BB765291", 60 | "ev": true, 61 | "value": false 62 | }, 63 | { 64 | "iid": 12, 65 | "perms": ["pr"], 66 | "type": "00000023-0000-1000-8000-0026BB765291", 67 | "value": "Switch" 68 | } 69 | ], 70 | "iid": 10, 71 | "stype": "switch" 72 | }, 73 | { 74 | "type": "00000043-0000-1000-8000-0026BB765291", 75 | "characteristics": [ 76 | { 77 | "iid": 21, 78 | "perms": ["pr", "pw", "ev"], 79 | "type": "00000025-0000-1000-8000-0026BB765291", 80 | "ev": true, 81 | "value": true 82 | }, 83 | { 84 | "iid": 22, 85 | "perms": ["pr", "pw", "ev"], 86 | "type": "00000013-0000-1000-8000-0026BB765291", 87 | "ev": true, 88 | "value": 261 89 | }, 90 | { 91 | "iid": 23, 92 | "perms": ["pr", "pw", "ev"], 93 | "type": "0000002F-0000-1000-8000-0026BB765291", 94 | "ev": true, 95 | "value": 100 96 | }, 97 | { 98 | "iid": 24, 99 | "perms": ["pr", "pw", "ev"], 100 | "type": "00000008-0000-1000-8000-0026BB765291", 101 | "ev": true, 102 | "value": 100 103 | }, 104 | { 105 | "iid": 25, 106 | "perms": ["pr"], 107 | "type": "00000023-0000-1000-8000-0026BB765291", 108 | "value": "Night Light" 109 | } 110 | ], 111 | "iid": 20, 112 | "stype": "lightbulb" 113 | }, 114 | { 115 | "type": "DA594801-809A-4320-BB49-CB0F9CCCB00E", 116 | "characteristics": [ 117 | { 118 | "iid": 31, 119 | "format": "string", 120 | "perms": ["pr", "ev"], 121 | "type": "DA594802-809A-4320-BB49-CB0F9CCCB00E", 122 | "ev": false, 123 | "value": "U6: 0" 124 | }, 125 | { 126 | "format": "data", 127 | "perms": ["pw"], 128 | "iid": 32, 129 | "type": "DA594803-809A-4320-BB49-CB0F9CCCB00E" 130 | } 131 | ], 132 | "iid": 30, 133 | "stype": "Unknown Service: DA594801-809A-4320-BB49-CB0F9CCCB00E" 134 | } 135 | ] 136 | } 137 | ] 138 | -------------------------------------------------------------------------------- /tests/fixtures/koogeek_ls1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "aid": 1, 4 | "services": [ 5 | { 6 | "characteristics": [ 7 | { 8 | "format": "string", 9 | "iid": 2, 10 | "maxLen": 64, 11 | "perms": ["pr"], 12 | "type": "23", 13 | "value": "Koogeek-LS1-20833F" 14 | }, 15 | { 16 | "format": "string", 17 | "iid": 3, 18 | "maxLen": 64, 19 | "perms": ["pr"], 20 | "type": "20", 21 | "value": "Koogeek" 22 | }, 23 | { 24 | "format": "string", 25 | "iid": 4, 26 | "maxLen": 64, 27 | "perms": ["pr"], 28 | "type": "21", 29 | "value": "LS1" 30 | }, 31 | { 32 | "format": "string", 33 | "iid": 5, 34 | "maxLen": 64, 35 | "perms": ["pr"], 36 | "type": "30", 37 | "value": "AAAA011111111111" 38 | }, 39 | { 40 | "format": "bool", 41 | "iid": 6, 42 | "perms": ["pw"], 43 | "type": "14" 44 | }, 45 | { 46 | "format": "string", 47 | "iid": 23, 48 | "perms": ["pr"], 49 | "type": "52", 50 | "value": "2.2.15" 51 | } 52 | ], 53 | "iid": 1, 54 | "type": "3E" 55 | }, 56 | { 57 | "characteristics": [ 58 | { 59 | "ev": false, 60 | "format": "bool", 61 | "iid": 8, 62 | "perms": ["pr", "pw", "ev"], 63 | "type": "25", 64 | "value": false 65 | }, 66 | { 67 | "ev": false, 68 | "format": "float", 69 | "iid": 9, 70 | "maxValue": 359, 71 | "minStep": 1, 72 | "minValue": 0, 73 | "perms": ["pr", "pw", "ev"], 74 | "type": "13", 75 | "unit": "arcdegrees", 76 | "value": 44 77 | }, 78 | { 79 | "ev": false, 80 | "format": "float", 81 | "iid": 10, 82 | "maxValue": 100, 83 | "minStep": 1, 84 | "minValue": 0, 85 | "perms": ["pr", "pw", "ev"], 86 | "type": "2F", 87 | "unit": "percentage", 88 | "value": 0 89 | }, 90 | { 91 | "ev": false, 92 | "format": "int", 93 | "iid": 11, 94 | "maxValue": 100, 95 | "minStep": 1, 96 | "minValue": 0, 97 | "perms": ["pr", "pw", "ev"], 98 | "type": "8", 99 | "unit": "percentage", 100 | "value": 100 101 | }, 102 | { 103 | "format": "string", 104 | "iid": 12, 105 | "maxLen": 64, 106 | "perms": ["pr"], 107 | "type": "23", 108 | "value": "Light Strip" 109 | } 110 | ], 111 | "iid": 7, 112 | "primary": true, 113 | "type": "43" 114 | }, 115 | { 116 | "characteristics": [ 117 | { 118 | "description": "TIMER_SETTINGS", 119 | "format": "tlv8", 120 | "iid": 14, 121 | "perms": ["pr", "pw"], 122 | "type": "4aaaf942-0dec-11e5-b939-0800200c9a66", 123 | "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 124 | } 125 | ], 126 | "iid": 13, 127 | "type": "4aaaf940-0dec-11e5-b939-0800200c9a66" 128 | }, 129 | { 130 | "characteristics": [ 131 | { 132 | "description": "FW Upgrade supported types", 133 | "format": "string", 134 | "iid": 16, 135 | "perms": ["pr", "hd"], 136 | "type": "151909D2-3802-11E4-916C-0800200C9A66", 137 | "value": "url,data" 138 | }, 139 | { 140 | "description": "FW Upgrade URL", 141 | "format": "string", 142 | "iid": 17, 143 | "maxLen": 256, 144 | "perms": ["pw", "hd"], 145 | "type": "151909D1-3802-11E4-916C-0800200C9A66" 146 | }, 147 | { 148 | "description": "FW Upgrade Status", 149 | "ev": false, 150 | "format": "int", 151 | "iid": 18, 152 | "perms": ["pr", "ev", "hd"], 153 | "type": "151909D6-3802-11E4-916C-0800200C9A66", 154 | "value": 0 155 | }, 156 | { 157 | "description": "FW Upgrade Data", 158 | "format": "data", 159 | "iid": 19, 160 | "perms": ["pw", "hd"], 161 | "type": "151909D7-3802-11E4-916C-0800200C9A66" 162 | } 163 | ], 164 | "hidden": true, 165 | "iid": 15, 166 | "type": "151909D0-3802-11E4-916C-0800200C9A66" 167 | }, 168 | { 169 | "characteristics": [ 170 | { 171 | "description": "Timezone", 172 | "format": "int", 173 | "iid": 21, 174 | "perms": ["pr", "pw"], 175 | "type": "151909D5-3802-11E4-916C-0800200C9A66", 176 | "value": 0 177 | }, 178 | { 179 | "description": "Time value since Epoch", 180 | "format": "int", 181 | "iid": 22, 182 | "perms": ["pr", "pw"], 183 | "type": "151909D4-3802-11E4-916C-0800200C9A66", 184 | "value": 1550348623 185 | } 186 | ], 187 | "iid": 20, 188 | "type": "151909D3-3802-11E4-916C-0800200C9A66" 189 | } 190 | ] 191 | } 192 | ] 193 | -------------------------------------------------------------------------------- /tests/fixtures/lennox_e30.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "aid": 1, 4 | "services": [ 5 | { 6 | "characteristics": [ 7 | { 8 | "format": "bool", 9 | "iid": 2, 10 | "perms": ["pw"], 11 | "type": "14" 12 | }, 13 | { 14 | "format": "string", 15 | "iid": 3, 16 | "perms": ["pr"], 17 | "type": "20", 18 | "value": "Lennox" 19 | }, 20 | { 21 | "format": "string", 22 | "iid": 4, 23 | "perms": ["pr"], 24 | "type": "21", 25 | "value": "E30 2B" 26 | }, 27 | { 28 | "format": "string", 29 | "iid": 5, 30 | "perms": ["pr"], 31 | "type": "23", 32 | "value": "Lennox" 33 | }, 34 | { 35 | "format": "string", 36 | "iid": 6, 37 | "perms": ["pr"], 38 | "type": "30", 39 | "value": "XXXXXXXX" 40 | }, 41 | { 42 | "format": "string", 43 | "iid": 7, 44 | "perms": ["pr"], 45 | "type": "52", 46 | "value": "3.40.XX" 47 | }, 48 | { 49 | "format": "string", 50 | "iid": 8, 51 | "perms": ["pr"], 52 | "type": "53", 53 | "value": "3.0.XX" 54 | } 55 | ], 56 | "iid": 1, 57 | "type": "3E" 58 | }, 59 | { 60 | "characteristics": [ 61 | { 62 | "format": "uint8", 63 | "iid": 101, 64 | "maxValue": 2, 65 | "minStep": 1, 66 | "minValue": 0, 67 | "perms": ["pr", "ev"], 68 | "type": "F", 69 | "value": 1 70 | }, 71 | { 72 | "format": "uint8", 73 | "iid": 102, 74 | "maxValue": 3, 75 | "minStep": 1, 76 | "minValue": 0, 77 | "perms": ["pr", "pw", "ev"], 78 | "type": "33", 79 | "value": 3 80 | }, 81 | { 82 | "format": "float", 83 | "iid": 103, 84 | "maxValue": 100, 85 | "minStep": 0.1, 86 | "minValue": 0, 87 | "perms": ["pr", "ev"], 88 | "type": "11", 89 | "unit": "celsius", 90 | "value": 20.5 91 | }, 92 | { 93 | "format": "float", 94 | "iid": 104, 95 | "maxValue": 32, 96 | "minStep": 0.5, 97 | "minValue": 4.5, 98 | "perms": ["pr", "pw", "ev"], 99 | "type": "35", 100 | "unit": "celsius", 101 | "value": 21 102 | }, 103 | { 104 | "format": "uint8", 105 | "iid": 105, 106 | "maxValue": 1, 107 | "minStep": 1, 108 | "minValue": 0, 109 | "perms": ["pr", "pw", "ev"], 110 | "type": "36", 111 | "value": 0 112 | }, 113 | { 114 | "format": "float", 115 | "iid": 106, 116 | "maxValue": 37, 117 | "minStep": 0.5, 118 | "minValue": 16, 119 | "perms": ["pr", "pw", "ev"], 120 | "type": "D", 121 | "unit": "celsius", 122 | "value": 29.5 123 | }, 124 | { 125 | "format": "float", 126 | "iid": 107, 127 | "maxValue": 100, 128 | "minStep": 1, 129 | "minValue": 0, 130 | "perms": ["pr", "ev"], 131 | "type": "10", 132 | "unit": "percentage", 133 | "value": 34 134 | }, 135 | { 136 | "format": "float", 137 | "iid": 108, 138 | "maxValue": 32, 139 | "minStep": 0.5, 140 | "minValue": 4.5, 141 | "perms": ["pr", "pw", "ev"], 142 | "type": "12", 143 | "unit": "celsius", 144 | "value": 21 145 | } 146 | ], 147 | "iid": 100, 148 | "primary": true, 149 | "type": "4A" 150 | } 151 | ] 152 | } 153 | ] 154 | -------------------------------------------------------------------------------- /tests/fixtures/synthetic_float_minstep.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "aid": 1, 4 | "services": [ 5 | { 6 | "characteristics": [ 7 | { 8 | "format": "float", 9 | "iid": 104, 10 | "maxValue": 32, 11 | "minStep": 1, 12 | "minValue": 4.5, 13 | "perms": ["pr", "pw", "ev"], 14 | "type": "35", 15 | "unit": "celsius", 16 | "value": 21 17 | } 18 | ], 19 | "iid": 100, 20 | "primary": true, 21 | "type": "4A" 22 | } 23 | ] 24 | }, 25 | { 26 | "aid": 2, 27 | "services": [ 28 | { 29 | "characteristics": [ 30 | { 31 | "format": "float", 32 | "iid": 104, 33 | "maxValue": 32, 34 | "minStep": 2, 35 | "minValue": 4.5, 36 | "perms": ["pr", "pw", "ev"], 37 | "type": "35", 38 | "unit": "celsius", 39 | "value": 21 40 | } 41 | ], 42 | "iid": 100, 43 | "primary": true, 44 | "type": "4A" 45 | } 46 | ] 47 | }, 48 | { 49 | "aid": 3, 50 | "services": [ 51 | { 52 | "characteristics": [ 53 | { 54 | "format": "float", 55 | "iid": 104, 56 | "maxValue": 32, 57 | "minStep": 5, 58 | "minValue": 4.5, 59 | "perms": ["pr", "pw", "ev"], 60 | "type": "35", 61 | "unit": "celsius", 62 | "value": 21 63 | } 64 | ], 65 | "iid": 100, 66 | "primary": true, 67 | "type": "4A" 68 | } 69 | ] 70 | }, 71 | { 72 | "aid": 4, 73 | "services": [ 74 | { 75 | "characteristics": [ 76 | { 77 | "format": "float", 78 | "iid": 104, 79 | "maxValue": 32, 80 | "minValue": 4.5, 81 | "perms": ["pr", "pw", "ev"], 82 | "type": "35", 83 | "unit": "celsius", 84 | "value": 21 85 | } 86 | ], 87 | "iid": 100, 88 | "primary": true, 89 | "type": "4A" 90 | } 91 | ] 92 | }, 93 | { 94 | "aid": 5, 95 | "services": [ 96 | { 97 | "characteristics": [ 98 | { 99 | "format": "float", 100 | "iid": 104, 101 | "maxValue": 32, 102 | "minStep": 1, 103 | "perms": ["pr", "pw", "ev"], 104 | "type": "35", 105 | "unit": "celsius", 106 | "value": 21 107 | } 108 | ], 109 | "iid": 100, 110 | "primary": true, 111 | "type": "4A" 112 | } 113 | ] 114 | }, 115 | { 116 | "aid": 6, 117 | "services": [ 118 | { 119 | "characteristics": [ 120 | { 121 | "format": "float", 122 | "iid": 104, 123 | "maxValue": 32, 124 | "minStep": 2, 125 | "perms": ["pr", "pw", "ev"], 126 | "type": "35", 127 | "unit": "celsius", 128 | "value": 21 129 | } 130 | ], 131 | "iid": 100, 132 | "primary": true, 133 | "type": "4A" 134 | } 135 | ] 136 | }, 137 | { 138 | "aid": 7, 139 | "services": [ 140 | { 141 | "characteristics": [ 142 | { 143 | "format": "float", 144 | "iid": 104, 145 | "maxValue": 32, 146 | "minStep": 5, 147 | "perms": ["pr", "pw", "ev"], 148 | "type": "35", 149 | "unit": "celsius", 150 | "value": 21 151 | } 152 | ], 153 | "iid": 100, 154 | "primary": true, 155 | "type": "4A" 156 | } 157 | ] 158 | }, 159 | { 160 | "aid": 8, 161 | "services": [ 162 | { 163 | "characteristics": [ 164 | { 165 | "format": "int", 166 | "iid": 104, 167 | "maxValue": 32, 168 | "minStep": 1, 169 | "minValue": 4, 170 | "perms": ["pr", "pw", "ev"], 171 | "type": "35", 172 | "unit": "celsius", 173 | "value": 21 174 | } 175 | ], 176 | "iid": 100, 177 | "primary": true, 178 | "type": "4A" 179 | } 180 | ] 181 | } 182 | ] 183 | -------------------------------------------------------------------------------- /tests/test_controller.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock, patch 2 | 3 | import pytest 4 | 5 | from aiohomekit.controller import Controller, controller as controller_module 6 | from aiohomekit.controller.abstract import TransportType 7 | from aiohomekit.controller.ble.controller import BleController 8 | from aiohomekit.controller.ip.controller import IpController 9 | from aiohomekit.exceptions import AccessoryDisconnectedError 10 | 11 | 12 | async def test_remove_pairing(controller_and_paired_accessory): 13 | pairing = controller_and_paired_accessory.aliases["alias"] 14 | 15 | # Verify that there is a pairing connected and working 16 | await pairing.get_characteristics([(1, 9)]) 17 | 18 | # Remove pairing from controller 19 | await controller_and_paired_accessory.remove_pairing("alias") 20 | 21 | # Verify now gives an appropriate error 22 | with pytest.raises(AccessoryDisconnectedError): 23 | await pairing.get_characteristics([(1, 9)]) 24 | 25 | 26 | async def test_passing_in_bleak_to_controller(): 27 | """Test we can pass in a bleak scanner instance to the controller. 28 | 29 | Passing in the instance should enable BLE scanning. 30 | """ 31 | with ( 32 | patch.object(controller_module, "BLE_TRANSPORT_SUPPORTED", False), 33 | patch.object(controller_module, "COAP_TRANSPORT_SUPPORTED", False), 34 | patch.object(controller_module, "IP_TRANSPORT_SUPPORTED", False), 35 | ): 36 | controller = Controller(bleak_scanner_instance=AsyncMock(register_detection_callback=MagicMock())) 37 | await controller.async_start() 38 | 39 | assert len(controller.transports) == 1 40 | assert isinstance(controller.transports[TransportType.BLE], BleController) 41 | 42 | 43 | async def test_passing_in_async_zeroconf(mock_asynczeroconf): 44 | """Test we can pass in a zeroconf ServiceBrowser instance to the controller. 45 | 46 | Passing in the instance should enable zeroconf scanning. 47 | """ 48 | with ( 49 | patch.object(controller_module, "BLE_TRANSPORT_SUPPORTED", False), 50 | patch.object(controller_module, "COAP_TRANSPORT_SUPPORTED", False), 51 | patch.object(controller_module, "IP_TRANSPORT_SUPPORTED", False), 52 | ): 53 | controller = Controller(async_zeroconf_instance=mock_asynczeroconf) 54 | await controller.async_start() 55 | 56 | assert len(controller.transports) == 1 57 | assert isinstance(controller.transports[TransportType.IP], IpController) 58 | -------------------------------------------------------------------------------- /tests/test_controller_ble.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from unittest.mock import MagicMock 5 | 6 | from bleak.backends.device import BLEDevice 7 | from bleak.backends.scanner import AdvertisementData 8 | import pytest 9 | 10 | from aiohomekit.characteristic_cache import CharacteristicCacheMemory 11 | from aiohomekit.controller.ble.controller import BleController 12 | 13 | ADVERTISEMENT_DATA_DEFAULTS = { 14 | "local_name": "", 15 | "manufacturer_data": {}, 16 | "service_data": {}, 17 | "service_uuids": [], 18 | "rssi": -127, 19 | "platform_data": ((),), 20 | "tx_power": -127, 21 | } 22 | 23 | BLE_DEVICE_DEFAULTS = { 24 | "name": None, 25 | "rssi": -127, 26 | "details": None, 27 | } 28 | 29 | 30 | def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: 31 | """Generate advertisement data with defaults.""" 32 | new = kwargs.copy() 33 | for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): 34 | new.setdefault(key, value) 35 | return AdvertisementData(**new) 36 | 37 | 38 | def generate_ble_device( 39 | address: str | None = None, 40 | name: str | None = None, 41 | details: Any | None = None, 42 | rssi: int | None = None, 43 | **kwargs: Any, 44 | ) -> BLEDevice: 45 | """Generate a BLEDevice with defaults.""" 46 | new = kwargs.copy() 47 | if address is not None: 48 | new["address"] = address 49 | if name is not None: 50 | new["name"] = name 51 | if details is not None: 52 | new["details"] = details 53 | if rssi is not None: 54 | new["rssi"] = rssi 55 | for key, value in BLE_DEVICE_DEFAULTS.items(): 56 | new.setdefault(key, value) 57 | return BLEDevice(**new) 58 | 59 | 60 | @pytest.fixture 61 | def mock_bleak_scanner() -> MagicMock: 62 | return MagicMock() 63 | 64 | 65 | @pytest.fixture 66 | def ble_controller(mock_bleak_scanner: MagicMock) -> BleController: 67 | controller = BleController(CharacteristicCacheMemory(), mock_bleak_scanner) 68 | return controller 69 | 70 | 71 | def test_discovery_with_none_name(mock_bleak_scanner: MagicMock, ble_controller: BleController) -> None: 72 | ble_device_with_short_name = generate_ble_device(name="Nam", address="00:00:00:00:00:00") 73 | ble_device_with_name = generate_ble_device(name="Name in Full", address="00:00:00:00:00:00") 74 | ble_device = generate_ble_device( 75 | name=None, 76 | address="00:00:00:00:00:00", 77 | ) 78 | adv = generate_advertisement_data( 79 | local_name=None, 80 | manufacturer_data={76: b"\x061\x00\x80\xe7\x14j74\x06\x00\x9f!\x04\x02<\xb9\xeb\x0e"}, 81 | ) 82 | ble_controller._device_detected(ble_device, adv) 83 | assert "80:e7:14:6a:37:34" in ble_controller.discoveries 84 | ble_controller._device_detected(ble_device_with_short_name, adv) 85 | assert "80:e7:14:6a:37:34" in ble_controller.discoveries 86 | assert ble_controller.discoveries["80:e7:14:6a:37:34"].name == "Nam (00:00:00:00:00:00)" 87 | ble_controller._device_detected(ble_device, adv) 88 | assert ble_controller.discoveries["80:e7:14:6a:37:34"].name == "Nam (00:00:00:00:00:00)" 89 | ble_controller._device_detected(ble_device_with_name, adv) 90 | assert ble_controller.discoveries["80:e7:14:6a:37:34"].name == "Name in Full (00:00:00:00:00:00)" 91 | ble_controller._device_detected(ble_device_with_short_name, adv) 92 | assert ble_controller.discoveries["80:e7:14:6a:37:34"].name == "Name in Full (00:00:00:00:00:00)" 93 | -------------------------------------------------------------------------------- /tests/test_controller_coap.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohomekit.controller.coap.connection import CoAPHomeKitConnection 4 | from aiohomekit.controller.coap.pdu import PDUStatus 5 | from aiohomekit.controller.coap.structs import Pdu09Database 6 | 7 | database_nanoleaf_bulb = bytes.fromhex( 8 | """ 9 | 18ff19ff1a02010016ff15f10702010006013e100014e61314\ 10 | 050202000401140a0220000c070100002701000000001314050203000401\ 11 | 200a0210000c071900002701000000001314050204000401210a0210000c\ 12 | 071900002701000000001314050205000401230a0210000c071900002701\ 13 | 000000001314050206000401300a0210000c071900002701000000001314\ 14 | 050207000401520a0210000c071900002701000000001314050208000401\ 15 | 530a0210000c0719000027010000000013230502090004103b94f9856afd\ 16 | c3ba40437fac1188ab340a0250000c07190000270100000000131505020a\ 17 | 00040220020a0250000c071b0000270100000000153d18ff070219ff1000\ 18 | 0601a20f16ff0204001000142e1314050211000401a50a0210000c071b00\ 19 | 002701000000001314050212000401370a0210000c071900002701000000\ 20 | 001569070220000601551000145e13140502220004014c0a0203000c071b\ 21 | 000027010000000013140502230004014e0a0203000c071b000027010000\ 22 | 000013140502240004014f0a0201000c0704000027010000000013140502\ 23 | 25000401500a0230000c071b000027010000000015ff070230000601430f\ 24 | 020100100014ff1314050231000401a50a0210000c071b00002701000000\ 25 | 001314050232000401230a0210000c071900002701000000001314050233\ 26 | 000401250a02b0030c18ff0701000019ff270100000000131e16ff050237\ 27 | 000401ce0a02b0030c07080000270100000d0899000000d6010000000013\ 28 | 1e050234000401080a02b0030c071000ad270100000d0800000000640000\ 29 | 000000132305023c000410bdeeeece71000fa1374da1cf02198ea20a0270\ 30 | 000c071b0000270100000000131505023900040244010a0210000c071b00\ 31 | 00270100000000131505023800040243010a0230000c071b000027010000\ 32 | 0000131905023a0004024b020a15620290030c07040000270100000d0200\ 33 | 14510200001324050235000401130a02b0030c07140063270100000d0800\ 34 | 0000000000b4430e040000803f000013240502360004012f0a0218ffb003\ 35 | 0c07140019ffad270100000d0800000016ff000000c8420e040000803f00\ 36 | 0015ab07027000060201071000149f1314050271000401a50a0210000c07\ 37 | 1b0000270100000000131505027400040206070a0210000c071900002701\ 38 | 00000000131b05027300040202070a0210000c07060000270100000d0400\ 39 | 001f000000131b05027500040203070a0290030c07060000270100000d04\ 40 | 00007f00000013150502760004022b020a0210000c070100002701000000\ 41 | 00131505027700040204070a0230000c071b000027010000000015770702\ 42 | 000a060239021000146b13140502040a0401a50a0210000c071b00002701\ 43 | 00000000131f0502010a04023a184e020a0210000c070819440000270100\ 44 | 000d08000000001636ffffff03000013150502020a04023c020a0211000c\ 45 | 071b000027010000000013150502050a04024a020a0290030c0708000027\ 46 | 010000""" 47 | ) 48 | 49 | 50 | @pytest.fixture 51 | def coap_controller(): 52 | controller = CoAPHomeKitConnection(None, "any", 1234) 53 | controller.info = Pdu09Database.decode(database_nanoleaf_bulb) 54 | return controller 55 | 56 | 57 | def test_write_characteristics(coap_controller: CoAPHomeKitConnection): 58 | values = [ 59 | # On 60 | (1, 51, True), 61 | # Brightness 62 | (1, 52, 100), 63 | # Hue 64 | (1, 53, 360.0), 65 | # Saturation 66 | (1, 54, 100.0), 67 | ] 68 | 69 | tlv_values = coap_controller._write_characteristics_enter(values) 70 | 71 | assert len(tlv_values) == 4 72 | assert tlv_values[0] == b"\x01\x01\x01" 73 | assert tlv_values[1] == b"\x01\x04\x64\x00\x00\x00" 74 | assert tlv_values[2] == b"\x01\x04\x00\x00\xb4\x43" 75 | assert tlv_values[3] == b"\x01\x04\x00\x00\xc8\x42" 76 | 77 | results = coap_controller._write_characteristics_exit(values, [b""] * len(values)) 78 | 79 | assert len(results) == 0 80 | 81 | 82 | def test_read_characteristics(coap_controller: CoAPHomeKitConnection): 83 | ids = ( 84 | # On 85 | (1, 51), 86 | # Brightness 87 | (1, 52), 88 | # Hue 89 | (1, 53), 90 | # Saturation 91 | (1, 54), 92 | ) 93 | pdu_results = [ 94 | b"\x01\x01\x01", 95 | b"\x01\x04\x64\x00\x00\x00", 96 | b"\x01\x04\x00\x00\xb4\x43", 97 | b"\x01\x04\x00\x00\xc8\x42", 98 | ] 99 | 100 | results = coap_controller._read_characteristics_exit(ids, pdu_results) 101 | 102 | assert len(results) == 4 103 | assert results[(1, 51)]["value"] is True 104 | assert results[(1, 52)]["value"] == 100 105 | assert results[(1, 53)]["value"] == 360.0 106 | assert results[(1, 54)]["value"] == 100.0 107 | 108 | 109 | def test_subscribe_to(coap_controller: CoAPHomeKitConnection): 110 | ids = ( 111 | # On 112 | (1, 51), 113 | # Brightness 114 | (1, 52), 115 | # Hue 116 | (1, 53), 117 | # Saturation 118 | (1, 54), 119 | ) 120 | pdu_results = [b""] * len(ids) 121 | 122 | results = coap_controller._subscribe_to_exit(ids, pdu_results) 123 | 124 | assert len(results) == 0 125 | 126 | 127 | def test_subscribe_to_single_failure(coap_controller: CoAPHomeKitConnection): 128 | ids = ( 129 | # On 130 | (1, 51), 131 | ) 132 | pdu_results = [PDUStatus.INVALID_REQUEST] 133 | 134 | results = coap_controller._subscribe_to_exit(ids, pdu_results) 135 | 136 | assert len(results) == 1 137 | assert isinstance(results[(1, 51)], dict) 138 | 139 | 140 | def test_unsubscribe_from(coap_controller: CoAPHomeKitConnection): 141 | ids = ( 142 | # On 143 | (1, 51), 144 | # Brightness 145 | (1, 52), 146 | # Hue 147 | (1, 53), 148 | # Saturation 149 | (1, 54), 150 | ) 151 | pdu_results = [b""] * len(ids) 152 | 153 | results = coap_controller._unsubscribe_from_exit(ids, pdu_results) 154 | 155 | assert len(results) == 0 156 | 157 | 158 | def test_unsubscribe_from_single_failure(coap_controller: CoAPHomeKitConnection): 159 | ids = ( 160 | # On 161 | (1, 51), 162 | ) 163 | pdu_results = [PDUStatus.INVALID_REQUEST] 164 | 165 | results = coap_controller._unsubscribe_from_exit(ids, pdu_results) 166 | 167 | assert len(results) == 1 168 | assert isinstance(results[(1, 51)], dict) 169 | -------------------------------------------------------------------------------- /tests/test_controller_coap_pdu.py: -------------------------------------------------------------------------------- 1 | from aiohomekit.controller.coap.pdu import ( 2 | OpCode, 3 | PDUStatus, 4 | decode_all_pdus, 5 | decode_pdu, 6 | encode_all_pdus, 7 | encode_pdu, 8 | ) 9 | 10 | 11 | def test_encode_without_data(): 12 | req_pdu = encode_pdu(OpCode.CHAR_READ, 0x10, 0x2022, b"") 13 | 14 | assert req_pdu == b"\x00\x03\x10\x22\x20\x00\x00" 15 | 16 | 17 | def test_encode_with_data(): 18 | req_pdu = encode_pdu(OpCode.CHAR_WRITE, 0x20, 0x1234, b"\x01\x02\x03\x04") 19 | 20 | assert req_pdu == b"\x00\x02\x20\x34\x12\x04\x00\x01\x02\x03\x04" 21 | 22 | 23 | def test_encode_all_without_data(): 24 | req_pdu = encode_all_pdus(OpCode.CHAR_READ, [0x10, 0x11], [b"", b""]) 25 | 26 | assert req_pdu == b"\x00\x03\x00\x10\x00\x00\x00" + b"\x00\x03\x01\x11\x00\x00\x00" 27 | 28 | 29 | def test_encode_all_with_data(): 30 | req_pdu = encode_all_pdus(OpCode.CHAR_WRITE, [0x12, 0x13], [b"\x88\x88", b"\x99\x99\x99\x99"]) 31 | 32 | assert ( 33 | req_pdu == b"\x00\x02\x00\x12\x00\x02\x00\x88\x88" + b"\x00\x02\x01\x13\x00\x04\x00\x99\x99\x99\x99" 34 | ) 35 | 36 | 37 | def test_decode_without_data(): 38 | res_pdu = b"\x02\x40\x00\x00\x00" 39 | res_len, res_val = decode_pdu(0x40, res_pdu) 40 | 41 | assert res_len == 0 42 | assert res_val == b"" 43 | 44 | 45 | def test_decode_with_data(): 46 | res_pdu = b"\x02\x50\x00\x06\x00\x01\x01\x01\x02\x01\x00" 47 | res_len, res_val = decode_pdu(0x50, res_pdu) 48 | 49 | assert res_len == 6 50 | assert res_val == b"\x01\x01\x01\x02\x01\x00" 51 | 52 | 53 | def test_decode_all_without_data(): 54 | res_pdu = b"\x02\x20\x00\x00\x00" + b"\x02\x21\x00\x00\x00" 55 | res = decode_all_pdus(0x20, res_pdu) 56 | 57 | assert len(res) == 2 58 | assert isinstance(res[0], bytes) 59 | assert isinstance(res[1], bytes) 60 | assert len(res[0]) == 0 61 | assert len(res[1]) == 0 62 | 63 | 64 | def test_decode_all_with_data(): 65 | res_pdu = b"\x02\x30\x00\x02\x00\x01\x00" + b"\x02\x31\x00\x02\x00\x02\x00" 66 | res = decode_all_pdus(0x30, res_pdu) 67 | 68 | assert len(res) == 2 69 | assert isinstance(res[0], bytes) 70 | assert isinstance(res[1], bytes) 71 | assert res[0] == b"\x01\x00" 72 | assert res[1] == b"\x02\x00" 73 | 74 | 75 | def test_decode_all_with_single_bad_tid(): 76 | res_pdu = b"\x02\x40\x00\x02\x00\x03\x00" + b"\x02\x99\x00\x02\x00\x04\x00" + b"\x02\x42\x00\x00\x00" 77 | res = decode_all_pdus(0x40, res_pdu) 78 | 79 | assert len(res) == 3 80 | assert isinstance(res[0], bytes) 81 | assert isinstance(res[1], PDUStatus) 82 | assert isinstance(res[2], bytes) 83 | assert res[0] == b"\x03\x00" 84 | assert res[1] == PDUStatus.TID_MISMATCH 85 | assert len(res[2]) == 0 86 | 87 | 88 | def test_decode_all_with_single_status_error(): 89 | res_pdu = b"\x02\x50\x00\x00\x00" + b"\x02\x51\x06\x00\x00" + b"\x02\x52\x00\x02\x00\x05\x00" 90 | res = decode_all_pdus(0x50, res_pdu) 91 | 92 | assert len(res) == 3 93 | assert isinstance(res[0], bytes) 94 | assert isinstance(res[1], PDUStatus) 95 | assert isinstance(res[2], bytes) 96 | assert len(res[0]) == 0 97 | assert res[1] == PDUStatus.INVALID_REQUEST 98 | assert res[2] == b"\x05\x00" 99 | 100 | 101 | def test_decode_all_with_single_bad_control(): 102 | res_pdu = b"\x02\x50\x00\x00\x00" + b"\x08\x51\x00\x00\x00" + b"\x02\x52\x00\x02\x00\x06\x00" 103 | res = decode_all_pdus(0x50, res_pdu) 104 | 105 | assert len(res) == 3 106 | assert isinstance(res[0], bytes) 107 | assert isinstance(res[1], PDUStatus) 108 | assert isinstance(res[2], bytes) 109 | assert len(res[0]) == 0 110 | assert res[1] == PDUStatus.BAD_CONTROL 111 | assert res[2] == b"\x06\x00" 112 | 113 | 114 | def test_decode_with_bad_tid(): 115 | res_pdu = b"\x02\x99\x00\x00\x00" 116 | res_len, res_val = decode_pdu(0x60, res_pdu) 117 | 118 | assert res_len == 0 119 | assert res_val == PDUStatus.TID_MISMATCH 120 | 121 | 122 | def test_decode_with_status_error(): 123 | res_pdu = b"\x02\x99\x06\x00\x00" 124 | res_len, res_val = decode_pdu(0x99, res_pdu) 125 | 126 | assert res_len == 0 127 | assert isinstance(res_val, PDUStatus) 128 | assert res_val == PDUStatus.INVALID_REQUEST 129 | 130 | 131 | def test_decode_with_bad_control(): 132 | res_pdu = b"\xcc\x99\x00\x00\x00" 133 | res_len, res_val = decode_pdu(0x99, res_pdu) 134 | 135 | assert res_len == 0 136 | assert res_val == PDUStatus.BAD_CONTROL 137 | -------------------------------------------------------------------------------- /tests/test_crypto_chacha20poly1305.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import pytest 18 | 19 | from aiohomekit.crypto.chacha20poly1305 import ( 20 | ChaCha20Poly1305Decryptor, 21 | ChaCha20Poly1305Encryptor, 22 | DecryptionError, 23 | ) 24 | 25 | 26 | def test_example2_8_2(): 27 | # Test aus 2.8.2 28 | plain_text = ( 29 | b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, " 30 | b"sunscreen would be it." 31 | ) 32 | aad = 0x50515253C0C1C2C3C4C5C6C7.to_bytes(length=12, byteorder="big") 33 | key = 0x808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9F.to_bytes( 34 | length=32, byteorder="big" 35 | ) 36 | iv = 0x4041424344454647.to_bytes(length=8, byteorder="big") 37 | fixed = 0x07000000.to_bytes(length=4, byteorder="big") 38 | r_ = ( 39 | bytes( 40 | [ 41 | 0xD3, 42 | 0x1A, 43 | 0x8D, 44 | 0x34, 45 | 0x64, 46 | 0x8E, 47 | 0x60, 48 | 0xDB, 49 | 0x7B, 50 | 0x86, 51 | 0xAF, 52 | 0xBC, 53 | 0x53, 54 | 0xEF, 55 | 0x7E, 56 | 0xC2, 57 | 0xA4, 58 | 0xAD, 59 | 0xED, 60 | 0x51, 61 | 0x29, 62 | 0x6E, 63 | 0x08, 64 | 0xFE, 65 | 0xA9, 66 | 0xE2, 67 | 0xB5, 68 | 0xA7, 69 | 0x36, 70 | 0xEE, 71 | 0x62, 72 | 0xD6, 73 | 0x3D, 74 | 0xBE, 75 | 0xA4, 76 | 0x5E, 77 | 0x8C, 78 | 0xA9, 79 | 0x67, 80 | 0x12, 81 | 0x82, 82 | 0xFA, 83 | 0xFB, 84 | 0x69, 85 | 0xDA, 86 | 0x92, 87 | 0x72, 88 | 0x8B, 89 | 0x1A, 90 | 0x71, 91 | 0xDE, 92 | 0x0A, 93 | 0x9E, 94 | 0x06, 95 | 0x0B, 96 | 0x29, 97 | 0x05, 98 | 0xD6, 99 | 0xA5, 100 | 0xB6, 101 | 0x7E, 102 | 0xCD, 103 | 0x3B, 104 | 0x36, 105 | 0x92, 106 | 0xDD, 107 | 0xBD, 108 | 0x7F, 109 | 0x2D, 110 | 0x77, 111 | 0x8B, 112 | 0x8C, 113 | 0x98, 114 | 0x03, 115 | 0xAE, 116 | 0xE3, 117 | 0x28, 118 | 0x09, 119 | 0x1B, 120 | 0x58, 121 | 0xFA, 122 | 0xB3, 123 | 0x24, 124 | 0xE4, 125 | 0xFA, 126 | 0xD6, 127 | 0x75, 128 | 0x94, 129 | 0x55, 130 | 0x85, 131 | 0x80, 132 | 0x8B, 133 | 0x48, 134 | 0x31, 135 | 0xD7, 136 | 0xBC, 137 | 0x3F, 138 | 0xF4, 139 | 0xDE, 140 | 0xF0, 141 | 0x8E, 142 | 0x4B, 143 | 0x7A, 144 | 0x9D, 145 | 0xE5, 146 | 0x76, 147 | 0xD2, 148 | 0x65, 149 | 0x86, 150 | 0xCE, 151 | 0xC6, 152 | 0x4B, 153 | 0x61, 154 | 0x16, 155 | ] 156 | ), 157 | bytes( 158 | [ 159 | 0x1A, 160 | 0xE1, 161 | 0x0B, 162 | 0x59, 163 | 0x4F, 164 | 0x09, 165 | 0xE2, 166 | 0x6A, 167 | 0x7E, 168 | 0x90, 169 | 0x2E, 170 | 0xCB, 171 | 0xD0, 172 | 0x60, 173 | 0x06, 174 | 0x91, 175 | ] 176 | ), 177 | ) 178 | nonce = fixed + iv 179 | r = ChaCha20Poly1305Encryptor(key).encrypt(aad, nonce, plain_text) 180 | assert r[:-16] == r_[0], "ciphertext" 181 | assert r[-16:] == r_[1], "tag" 182 | 183 | plain_text_ = ChaCha20Poly1305Decryptor(key).decrypt(aad, nonce, r) 184 | assert plain_text == plain_text_ 185 | 186 | with pytest.raises(DecryptionError): 187 | ChaCha20Poly1305Decryptor(key).decrypt(aad, nonce, r + bytes([0, 1, 2, 3])) 188 | -------------------------------------------------------------------------------- /tests/test_crypto_hkdf.py: -------------------------------------------------------------------------------- 1 | from aiohomekit.crypto import hkdf_derive 2 | 3 | 4 | def test_derive(): 5 | material = hkdf_derive(b"1" * 32, b"Pair-Verify-Encrypt-Salt", b"Pair-Verify-Encrypt-Info") 6 | 7 | assert material == ( 8 | b'\x8fC1v\xe3N\x8c\xa2\x9c\x94\xaaa\xce\xf5"\x94$7/xq\xbf\x8c;M\xe9\xe2\xa5N\xf9\xe5\x08' 9 | ) 10 | -------------------------------------------------------------------------------- /tests/test_crypto_srp.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import pytest 17 | 18 | from aiohomekit.crypto.srp import SrpClient, SrpServer 19 | 20 | # To find short keys 21 | # for _ in range(500000): 22 | # srp = SrpClient("Pair-Setup", "123-45-789") 23 | # pub_key_bytes = SrpClient.to_byte_array(srp.A) 24 | # if len(pub_key_bytes) < 384: 25 | # pprint.pprint(["found key", srp.a]) 26 | 27 | 28 | class ZeroSaltSrpServer(SrpServer): 29 | def _create_salt_bytes(self): 30 | return b"\x00" * 16 31 | 32 | 33 | class LeadingZeroPrivateKeySrpClient(SrpClient): 34 | def generate_private_key(self): 35 | return 292137137271783308929690144371568755687 36 | 37 | 38 | class LeadingZeroPrivateAndPublicKeySrpClient(SrpClient): 39 | def generate_private_key(self): 40 | return 70997313118674976963008287637113704817 41 | 42 | 43 | class LeadingZeroPrivateKeySrpServer(SrpServer): 44 | def generate_private_key(self): 45 | return 292137137271783308929690144371568755687 46 | 47 | 48 | class LeadingZeroPrivateAndPublicKeySrpServer(SrpServer): 49 | def generate_private_key(self): 50 | return 70997313118674976963008287637113704817 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "server_cls, client_cls", 55 | [ 56 | (SrpServer, SrpClient), 57 | (ZeroSaltSrpServer, SrpClient), 58 | (SrpServer, LeadingZeroPrivateKeySrpClient), 59 | (SrpServer, LeadingZeroPrivateAndPublicKeySrpClient), 60 | (ZeroSaltSrpServer, LeadingZeroPrivateAndPublicKeySrpClient), 61 | ( 62 | LeadingZeroPrivateAndPublicKeySrpServer, 63 | LeadingZeroPrivateAndPublicKeySrpClient, 64 | ), 65 | (LeadingZeroPrivateKeySrpServer, LeadingZeroPrivateAndPublicKeySrpClient), 66 | ], 67 | ) 68 | def test_1(server_cls, client_cls): 69 | # step M1 70 | 71 | # step M2 72 | setup_code = "123-45-678" # transmitted on second channel 73 | server: SrpServer = server_cls("Pair-Setup", setup_code) 74 | server_pub_key = server.get_public_key_bytes() 75 | server_salt = server.get_salt() 76 | 77 | # step M3 78 | client: SrpClient = client_cls("Pair-Setup", setup_code) 79 | client.set_salt(server_salt) 80 | client.set_server_public_key(server_pub_key) 81 | 82 | client_pub_key = client.get_public_key_bytes() 83 | clients_proof_bytes = client.get_proof_bytes() 84 | 85 | # step M4 86 | server.set_client_public_key(client_pub_key) 87 | server.get_shared_secret() 88 | assert server.verify_clients_proof_bytes(clients_proof_bytes) is True 89 | servers_proof = server.get_proof_bytes(clients_proof_bytes) 90 | 91 | # step M5 92 | assert client.verify_servers_proof_bytes(servers_proof) is True, ( 93 | f"proof mismatch: server_key:{server.b} client_key:{client.a} server_salt:{server.salt}" 94 | ) 95 | -------------------------------------------------------------------------------- /tests/test_enum.py: -------------------------------------------------------------------------------- 1 | from aiohomekit.enum import EnumWithDescription 2 | 3 | 4 | class EnumTest(EnumWithDescription): 5 | RED = 1, "The colour is red" 6 | BLUE = 2, "This colour is blue" 7 | 8 | 9 | def test_value_isnt_tuple(): 10 | assert EnumTest.RED.value == 1 11 | 12 | 13 | def test_casting(): 14 | assert EnumTest(1) == EnumTest.RED 15 | 16 | 17 | def test_has_description(): 18 | assert EnumTest.RED.description == "The colour is red" 19 | assert EnumTest(1).description == "The colour is red" 20 | -------------------------------------------------------------------------------- /tests/test_hkjson.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | import aiohomekit.hkjson as hkjson 17 | 18 | 19 | def test_loads_trailing_comma(): 20 | """Test we can decode with a trailing comma.""" 21 | result = hkjson.loads( 22 | '{"characteristics":[{"aid":10,"iid":12,"value":27.0},{"aid":10,"iid":13,"value":20.5},]}' 23 | ) 24 | assert result == { 25 | "characteristics": [ 26 | {"aid": 10, "iid": 12, "value": 27.0}, 27 | {"aid": 10, "iid": 13, "value": 20.5}, 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import pytest 18 | 19 | from aiohomekit.http import HttpStatusCodes 20 | 21 | 22 | def test_1(): 23 | assert HttpStatusCodes[HttpStatusCodes.INTERNAL_SERVER_ERROR] == "Internal Server Error" 24 | 25 | 26 | def test_unknown_code(): 27 | with pytest.raises(KeyError): 28 | HttpStatusCodes[99] 29 | -------------------------------------------------------------------------------- /tests/test_ip_discovery.py: -------------------------------------------------------------------------------- 1 | from aiohomekit import Controller 2 | from aiohomekit.controller.ip import IpDiscovery, IpPairing 3 | from aiohomekit.model.categories import Categories 4 | from aiohomekit.zeroconf import HomeKitService 5 | 6 | 7 | async def test_pair(controller_and_unpaired_accessory: tuple[Controller, int]): 8 | controller, port = controller_and_unpaired_accessory 9 | 10 | discovery = IpDiscovery( 11 | controller, 12 | HomeKitService( 13 | name="Test", 14 | id="00:01:02:03:04:05", 15 | model="Test", 16 | feature_flags=0, 17 | status_flags=1, 18 | config_num=0, 19 | state_num=0, 20 | category=Categories.OTHER, 21 | protocol_version="1.0", 22 | type="_hap._tcp.local", 23 | address="127.0.0.1", 24 | addresses=["127.0.0.1"], 25 | port=port, 26 | ), 27 | ) 28 | 29 | finish_pairing = await discovery.async_start_pairing("alias") 30 | pairing = await finish_pairing("031-45-154") 31 | 32 | assert isinstance(pairing, IpPairing) 33 | 34 | assert await pairing.get_characteristics([(1, 9)]) == { 35 | (1, 9): {"value": False}, 36 | } 37 | 38 | 39 | async def test_identify(controller_and_unpaired_accessory: tuple[Controller, int]): 40 | controller, port = controller_and_unpaired_accessory 41 | 42 | discovery = IpDiscovery( 43 | controller, 44 | HomeKitService( 45 | name="Test", 46 | id="00:01:02:03:04:05", 47 | model="Test", 48 | feature_flags=0, 49 | status_flags=0, 50 | config_num=0, 51 | state_num=0, 52 | category=Categories.OTHER, 53 | protocol_version="1.0", 54 | type="_hap._tcp.local", 55 | address="127.0.0.1", 56 | addresses=["127.0.0.1"], 57 | port=port, 58 | ), 59 | ) 60 | 61 | identified = await discovery.async_identify() 62 | assert identified is True 63 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test the AIO CLI variant.""" 2 | 3 | import json 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from aiohomekit.__main__ import main 9 | 10 | 11 | async def test_help(): 12 | with mock.patch("sys.stdout") as stdout: 13 | with pytest.raises(SystemExit): 14 | await main(["-h"]) 15 | printed = stdout.write.call_args[0][0] 16 | 17 | assert printed.startswith("usage: ") 18 | assert "discover" in printed 19 | 20 | 21 | async def test_get_accessories(pairing): 22 | with mock.patch("sys.stdout") as stdout: 23 | await main(["-f", "tests-pairing.json", "accessories", "-a", "alias"]) 24 | printed = stdout.write.call_args_list[0][0][0] 25 | assert printed.startswith("1.1: >0000003E-0000-1000-8000-0026BB765291") 26 | 27 | with mock.patch("sys.stdout") as stdout: 28 | await main(["-f", "tests-pairing.json", "accessories", "-a", "alias", "-o", "json"]) 29 | printed = stdout.write.call_args_list[0][0][0] 30 | accessories = json.loads(printed) 31 | assert accessories[0]["aid"] == 1 32 | assert accessories[0]["services"][0]["iid"] == 1 33 | assert accessories[0]["services"][0]["characteristics"][0]["iid"] == 2 34 | 35 | 36 | async def test_get_characteristic(pairing): 37 | with mock.patch("sys.stdout") as stdout: 38 | await main(["-f", "tests-pairing.json", "get", "-a", "alias", "-c", "1.9"]) 39 | printed = stdout.write.call_args_list[0][0][0] 40 | assert json.loads(printed) == {"1.9": {"value": False}} 41 | 42 | 43 | async def test_put_characteristic(pairing): 44 | with mock.patch("sys.stdout"): 45 | await main(["-f", "tests-pairing.json", "put", "-a", "alias", "-c", "1.9", "true"]) 46 | 47 | characteristics = await pairing.get_characteristics([(1, 9)]) 48 | assert characteristics[(1, 9)] == {"value": True} 49 | 50 | 51 | async def test_list_pairings(pairing): 52 | with mock.patch("sys.stdout") as stdout: 53 | await main(["-f", "tests-pairing.json", "list-pairings", "-a", "alias"]) 54 | printed = "".join(write[0][0] for write in stdout.write.call_args_list) 55 | assert printed == ( 56 | "Pairing Id: decc6fa3-de3e-41c9-adba-ef7409821bfc\n" 57 | "\tPublic Key: 0xd708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8\n" 58 | "\tPermissions: 1 (admin)\n" 59 | ) 60 | -------------------------------------------------------------------------------- /tests/test_meshcop.py: -------------------------------------------------------------------------------- 1 | from aiohomekit.meshcop import Meshcop 2 | 3 | 4 | def test_parse_tlv() -> None: 5 | """Test the TLV parser.""" 6 | dataset_tlv = ( 7 | "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" 8 | "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" 9 | "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" 10 | ) 11 | 12 | struct = Meshcop.decode(bytes.fromhex(dataset_tlv)) 13 | 14 | assert struct.networkname == "OpenThreadDemo" 15 | assert struct.channel == 15 16 | assert struct.panid == 4660 17 | assert struct.extpanid == bytes.fromhex("1111111122222222") 18 | assert struct.pskc == bytes.fromhex("445f2b5ca6f2a93a55ce570a70efeecb") 19 | assert struct.networkkey == bytes.fromhex("00112233445566778899aabbccddeeff") 20 | assert struct.meshlocalprefix == bytes.fromhex("fdad70bfe5aa15dd") 21 | assert struct.securitypolicy == bytes.fromhex("02a0f7f8") 22 | assert struct.activetimestamp == bytes.fromhex("0000000000010000") 23 | assert struct.channelmask == bytes.fromhex("0004001fffe0") 24 | -------------------------------------------------------------------------------- /tests/test_pdu.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2022 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import pytest 18 | 19 | from aiohomekit.pdu import ( 20 | OpCode, 21 | PDUStatus, 22 | decode_pdu, 23 | decode_pdu_continuation, 24 | encode_pdu, 25 | ) 26 | 27 | 28 | def test_encode(): 29 | assert list(encode_pdu(OpCode.CHAR_SIG_READ, 55, 1)) == [b"\x00\x017\x01\x00"] 30 | 31 | 32 | def test_encode_with_body(): 33 | assert list(encode_pdu(OpCode.CHAR_SIG_READ, 44, 1, b"SOMEDATA")) == [ 34 | b"\x00\x01,\x01\x00\x08\x00SOMEDATA" 35 | ] 36 | 37 | 38 | def test_encode_with_fragments(): 39 | result = list(encode_pdu(OpCode.CHAR_SIG_READ, 44, 1, b"ABCD" * 64, fragment_size=256)) 40 | 41 | assert result == [ 42 | b"\x00\x01,\x01\x00\x00\x01ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDA" 43 | b"BCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDA" 44 | b"BCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDA" 45 | b"BCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDA", 46 | b"\x80,BCDABCD", 47 | ] 48 | 49 | 50 | def test_decode(): 51 | assert decode_pdu(23, b"\x00\x17\x00") == (PDUStatus.SUCCESS, 0, b"") 52 | 53 | 54 | def test_decode_with_body(): 55 | assert decode_pdu(23, b"\x00\x17\x00\x08\x00SOMEDATA") == ( 56 | PDUStatus.SUCCESS, 57 | 8, 58 | b"SOMEDATA", 59 | ) 60 | 61 | 62 | def test_decode_invalid_tid(): 63 | with pytest.raises(ValueError): 64 | decode_pdu(24, b"\x00\x17\x00") 65 | 66 | 67 | def test_decode_invalid_status(): 68 | assert decode_pdu(23, b"\x00\x17\x01") == (PDUStatus.UNSUPPORTED_PDU, 0, b"") 69 | 70 | 71 | def test_decode_continuation(): 72 | assert decode_pdu_continuation(23, b"\x80\x17SOMEDATA") == b"SOMEDATA" 73 | 74 | 75 | def test_decode_continuation_invalid_cbit(): 76 | with pytest.raises(ValueError): 77 | decode_pdu_continuation(23, b"\x00\x17SOMEDATA") 78 | 79 | 80 | def test_decode_continuation_invalid_tid(): 81 | with pytest.raises(ValueError): 82 | decode_pdu_continuation(24, b"\x80\x17SOMEDATA") 83 | -------------------------------------------------------------------------------- /tests/test_protocol_tlv.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2019 aiohomekit team 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import unittest 18 | 19 | from aiohomekit.protocol.tlv import TLV, TlvParseException 20 | 21 | 22 | class TestTLV(unittest.TestCase): 23 | def test_long_values_1(self): 24 | val = [ 25 | [TLV.kTLVType_State, TLV.M3], 26 | [TLV.kTLVType_Certificate, (300 * "a").encode()], 27 | [TLV.kTLVType_Identifier, b"hello"], 28 | ] 29 | res = TLV.decode_bytearray(TLV.encode_list(val)) 30 | self.assertEqual(val, res) 31 | 32 | def test_long_values_2(self): 33 | val = [ 34 | [TLV.kTLVType_State, TLV.M3], 35 | [TLV.kTLVType_Certificate, (150 * "a" + 150 * "b").encode()], 36 | [TLV.kTLVType_Identifier, b"hello"], 37 | ] 38 | res = TLV.decode_bytearray(TLV.encode_list(val)) 39 | self.assertEqual(val, res) 40 | 41 | def test_long_values_decode_bytearray_to_list(self): 42 | example = bytearray.fromhex("060103" + ("09FF" + 255 * "61" + "092D" + 45 * "61") + "010568656c6c6f") 43 | expected = [ 44 | [6, bytearray(b"\x03")], 45 | [9, bytearray(300 * b"a")], 46 | [1, bytearray(b"hello")], 47 | ] 48 | 49 | data = TLV.decode_bytearray(example) 50 | self.assertListEqual(data, expected) 51 | 52 | def test_long_values_decode_bytes_to_list(self): 53 | example = bytes( 54 | bytearray.fromhex("060103" + ("09FF" + 255 * "61" + "092D" + 45 * "61") + "010568656c6c6f") 55 | ) 56 | expected = [ 57 | [6, bytearray(b"\x03")], 58 | [9, bytearray(300 * b"a")], 59 | [1, bytearray(b"hello")], 60 | ] 61 | 62 | data = TLV.decode_bytes(example) 63 | self.assertListEqual(data, expected) 64 | 65 | # def test_long_values_decode_bytearray(self): 66 | # example = bytearray.fromhex('060103' + ('09FF' + 255 * '61' + '092D' + 45 * '61') + '010568656c6c6f') 67 | # expected = { 68 | # 6: bytearray(b'\x03'), 69 | # 9: bytearray(300 * b'a'), 70 | # 1: bytearray(b'hello') 71 | # } 72 | # 73 | # data = TLV.decode_bytearray(example) 74 | # self.assertDictEqual(data, expected) 75 | # 76 | # def test_decode_bytearray_not_enough_data(self): 77 | # example = bytearray.fromhex('060103' + '09FF' + 25 * '61') # should have been 255 '61' 78 | # self.assertRaises(TlvParseException, TLV.decode_bytearray, example) 79 | 80 | def test_decode_bytearray_to_list_not_enough_data(self): 81 | example = bytearray.fromhex("060103" + "09FF" + 25 * "61") # should have been 255 '61' 82 | self.assertRaises(TlvParseException, TLV.decode_bytearray, example) 83 | 84 | def test_decode_bytes_to_list_not_enough_data(self): 85 | example = bytes(bytearray.fromhex("060103" + "09FF" + 25 * "61")) # should have been 255 '61' 86 | self.assertRaises(TlvParseException, TLV.decode_bytes, example) 87 | 88 | def test_encode_list_key_error(self): 89 | example = [ 90 | ( 91 | -1, 92 | "hello", 93 | ), 94 | ] 95 | self.assertRaises(ValueError, TLV.encode_list, example) 96 | example = [ 97 | ( 98 | 256, 99 | "hello", 100 | ), 101 | ] 102 | self.assertRaises(ValueError, TLV.encode_list, example) 103 | example = [ 104 | ( 105 | "test", 106 | "hello", 107 | ), 108 | ] 109 | self.assertRaises(ValueError, TLV.encode_list, example) 110 | 111 | def test_to_string_for_list(self): 112 | example = [ 113 | ( 114 | 1, 115 | "hello", 116 | ), 117 | ] 118 | res = TLV.to_string(example) 119 | self.assertEqual(res, "[\n 1 (Identifier): (5 bytes/) hello\n]\n") 120 | example = [ 121 | ( 122 | 1, 123 | "hello", 124 | ), 125 | ( 126 | 2, 127 | "world", 128 | ), 129 | ] 130 | res = TLV.to_string(example) 131 | self.assertEqual( 132 | res, 133 | "[\n 1 (Identifier): (5 bytes/) hello\n 2 (Salt): (5 bytes/) world\n]\n", 134 | ) 135 | 136 | def test_to_string_for_dict(self): 137 | example = {1: "hello"} 138 | res = TLV.to_string(example) 139 | self.assertEqual(res, "{\n 1 (Identifier): (5 bytes/) hello\n}\n") 140 | example = {1: "hello", 2: "world"} 141 | res = TLV.to_string(example) 142 | self.assertEqual( 143 | res, 144 | "{\n 1 (Identifier): (5 bytes/) hello\n 2 (Salt): (5 bytes/) world\n}\n", 145 | ) 146 | 147 | def test_to_string_for_dict_bytearray(self): 148 | example = {1: bytearray([0x42, 0x23])} 149 | res = TLV.to_string(example) 150 | self.assertEqual(res, "{\n 1 (Identifier): (2 bytes/) 0x4223\n}\n") 151 | 152 | def test_to_string_for_list_bytearray(self): 153 | example = [[1, bytearray([0x42, 0x23])]] 154 | res = TLV.to_string(example) 155 | self.assertEqual(res, "[\n 1 (Identifier): (2 bytes/) 0x4223\n]\n") 156 | 157 | def test_separator_list(self): 158 | val = [ 159 | [TLV.kTLVType_State, TLV.M3], 160 | TLV.kTLVType_Separator_Pair, 161 | [TLV.kTLVType_State, TLV.M4], 162 | ] 163 | res = TLV.decode_bytearray(TLV.encode_list(val)) 164 | self.assertEqual(val, res) 165 | 166 | def test_separator_list_error(self): 167 | val = [ 168 | [TLV.kTLVType_State, TLV.M3], 169 | [TLV.kTLVType_Separator, "test"], 170 | [TLV.kTLVType_State, TLV.M4], 171 | ] 172 | self.assertRaises(ValueError, TLV.encode_list, val) 173 | 174 | def test_filter(self): 175 | example = bytes(bytearray.fromhex("060103" + "010203")) 176 | expected = [ 177 | [6, bytearray(b"\x03")], 178 | ] 179 | 180 | data = TLV.decode_bytes(example, expected=[6]) 181 | self.assertListEqual(data, expected) 182 | -------------------------------------------------------------------------------- /tests/test_statuscodes.py: -------------------------------------------------------------------------------- 1 | """Test status codes.""" 2 | 3 | from aiohomekit.protocol.statuscodes import HapStatusCode, to_status_code 4 | 5 | 6 | async def test_normalized_statuscodes(): 7 | """Verify we account for quirks in HAP implementations.""" 8 | assert to_status_code(70411) == HapStatusCode.INSUFFICIENT_AUTH 9 | assert to_status_code(-70411) == HapStatusCode.INSUFFICIENT_AUTH 10 | -------------------------------------------------------------------------------- /tests/test_testing.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from aiohomekit.model import Accessories, Accessory 4 | from aiohomekit.model.characteristics import CharacteristicsTypes 5 | from aiohomekit.model.services import ServicesTypes 6 | from aiohomekit.protocol.statuscodes import HapStatusCode 7 | from aiohomekit.testing import FakeController 8 | 9 | 10 | async def test_pairing(): 11 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 12 | controller = FakeController() 13 | device = controller.add_device(accessories) 14 | 15 | discovery = await controller.async_find(device.description.id) 16 | finish_pairing = await discovery.async_start_pairing("alias") 17 | pairing = await finish_pairing("111-22-333") 18 | 19 | chars_and_services = await pairing.list_accessories_and_characteristics() 20 | assert isinstance(chars_and_services, list) 21 | 22 | 23 | async def test_get_and_set(): 24 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 25 | controller = FakeController() 26 | device = controller.add_device(accessories) 27 | 28 | discovery = await controller.async_find(device.description.id) 29 | finish_pairing = await discovery.async_start_pairing("alias") 30 | pairing = await finish_pairing("111-22-333") 31 | 32 | pairing.dispatcher_connect(accessories.process_changes) 33 | 34 | chars = await pairing.get_characteristics([(1, 10)]) 35 | assert chars == {(1, 10): {"value": 0}} 36 | 37 | chars = await pairing.put_characteristics([(1, 10, 1)]) 38 | assert chars == {} 39 | 40 | chars = await pairing.get_characteristics([(1, 10)]) 41 | assert chars == {(1, 10): {"value": 1}} 42 | 43 | 44 | async def test_get_failure(): 45 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 46 | 47 | char = accessories.aid(1).characteristics.iid(10) 48 | char.status = HapStatusCode.UNABLE_TO_COMMUNICATE 49 | 50 | controller = FakeController() 51 | device = controller.add_device(accessories) 52 | 53 | discovery = await controller.async_find(device.description.id) 54 | finish_pairing = await discovery.async_start_pairing("alias") 55 | pairing = await finish_pairing("111-22-333") 56 | 57 | chars = await pairing.get_characteristics([(1, 10)]) 58 | assert chars == {(1, 10): {"status": -70402}} 59 | 60 | 61 | async def test_put_failure(): 62 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 63 | 64 | char = accessories.aid(1).characteristics.iid(10) 65 | char.status = HapStatusCode.UNABLE_TO_COMMUNICATE 66 | 67 | controller = FakeController() 68 | device = controller.add_device(accessories) 69 | 70 | discovery = await controller.async_find(device.description.id) 71 | finish_pairing = await discovery.async_start_pairing("alias") 72 | pairing = await finish_pairing("111-22-333") 73 | 74 | chars = await pairing.put_characteristics([(1, 10, 1)]) 75 | assert chars == {(1, 10): {"status": -70402}} 76 | 77 | 78 | async def test_update_named_service_events(): 79 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 80 | controller = FakeController() 81 | pairing = await controller.add_paired_device(accessories, "alias") 82 | 83 | await pairing.subscribe([(1, 8)]) 84 | pairing.dispatcher_connect(accessories.process_changes) 85 | 86 | # Simulate that the state was changed on the device itself. 87 | pairing.testing.update_named_service("Light Strip", {CharacteristicsTypes.ON: True}) 88 | 89 | assert accessories.aid(1).characteristics.iid(8).value == 1 90 | 91 | 92 | async def test_update_named_service_events_manual_accessory(id_factory): 93 | accessories = Accessories() 94 | accessory = Accessory.create_with_info( 95 | id_factory(), 96 | name="TestLight", 97 | manufacturer="Test Mfr", 98 | model="Test Bulb", 99 | serial_number="1234", 100 | firmware_revision="1.1", 101 | ) 102 | service = accessory.add_service(ServicesTypes.LIGHTBULB, name="Light Strip") 103 | on_char = service.add_char(CharacteristicsTypes.ON) 104 | accessories.add_accessory(accessory) 105 | 106 | controller = FakeController() 107 | pairing = await controller.add_paired_device(accessories, "alias") 108 | 109 | callback = mock.Mock() 110 | await pairing.subscribe([(accessory.aid, on_char.iid)]) 111 | pairing.dispatcher_connect(callback) 112 | 113 | # Simulate that the state was changed on the device itself. 114 | pairing.testing.update_named_service("Light Strip", {CharacteristicsTypes.ON: True}) 115 | 116 | assert callback.call_args_list == [mock.call({(accessory.aid, on_char.iid): {"value": 1}})] 117 | 118 | 119 | async def test_update_named_service_events_manual_accessory_auto_requires(id_factory): 120 | accessories = Accessories() 121 | accessory = Accessory.create_with_info( 122 | id_factory(), 123 | name="TestLight", 124 | manufacturer="Test Mfr", 125 | model="Test Bulb", 126 | serial_number="1234", 127 | firmware_revision="1.1", 128 | ) 129 | service = accessory.add_service(ServicesTypes.LIGHTBULB, name="Light Strip", add_required=True) 130 | on_char = service[CharacteristicsTypes.ON] 131 | accessories.add_accessory(accessory) 132 | 133 | controller = FakeController() 134 | pairing = await controller.add_paired_device(accessories, "alias") 135 | 136 | callback = mock.Mock() 137 | await pairing.subscribe([(accessory.aid, on_char.iid)]) 138 | pairing.dispatcher_connect(callback) 139 | 140 | # Simulate that the state was changed on the device itself. 141 | pairing.testing.update_named_service("Light Strip", {CharacteristicsTypes.ON: True}) 142 | 143 | assert callback.call_args_list == [mock.call({(accessory.aid, on_char.iid): {"value": 1}})] 144 | 145 | 146 | async def test_update_aid_iid_events(): 147 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 148 | controller = FakeController() 149 | pairing = await controller.add_paired_device(accessories, "alias") 150 | 151 | callback = mock.Mock() 152 | await pairing.subscribe([(1, 8)]) 153 | pairing.dispatcher_connect(callback) 154 | 155 | # Simulate that the state was changed on the device itself. 156 | pairing.testing.update_aid_iid([(1, 8, True)]) 157 | 158 | assert callback.call_args_list == [mock.call({(1, 8): {"value": 1}})] 159 | 160 | 161 | async def test_events_are_filtered(): 162 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 163 | controller = FakeController() 164 | pairing = await controller.add_paired_device(accessories, "alias") 165 | 166 | await pairing.subscribe([(1, 10)]) 167 | pairing.dispatcher_connect(accessories.process_changes) 168 | 169 | # Simulate that the state was changed on the device itself. 170 | pairing.testing.update_named_service("Light Strip", {CharacteristicsTypes.ON: True}) 171 | 172 | assert accessories.aid(1).characteristics.iid(8).value == 1 173 | 174 | 175 | async def test_camera(): 176 | accessories = Accessories.from_file("tests/fixtures/koogeek_ls1.json") 177 | controller = FakeController() 178 | pairing = await controller.add_paired_device(accessories, "alias") 179 | result = await pairing.image(1, 640, 480) 180 | assert len(result) > 0 181 | -------------------------------------------------------------------------------- /tests/test_tlv8.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import IntEnum 3 | 4 | from aiohomekit.tlv8 import TLVStruct, tlv_entry, u8, u64, u128 5 | 6 | 7 | def test_example_1(): 8 | # Based on 14.1.2 example 1 in R2 spec 9 | 10 | @dataclass 11 | class DummyStruct(TLVStruct): 12 | state: u8 = tlv_entry(7) 13 | message: str = tlv_entry(1) 14 | 15 | raw = b"\x07\x01\x03\x01\x05\x68\x65\x6c\x6c\x6f" 16 | 17 | result = DummyStruct.decode(raw) 18 | 19 | assert result.state == 3 20 | assert result.message == "hello" 21 | 22 | assert result.encode() == raw 23 | 24 | 25 | def test_example_2(): 26 | # Based on 14.1.2 example 1 in R2 spec 27 | 28 | @dataclass 29 | class DummyStruct(TLVStruct): 30 | state: u8 = tlv_entry(6) 31 | certificate: str = tlv_entry(9) 32 | identifier: str = tlv_entry(1) 33 | 34 | raw = b"\x06\x01\x03\x09\xff" + b"a" * 255 + b"\x09\x2d" + b"a" * 45 + b"\x01\x05hello" 35 | 36 | result = DummyStruct.decode(raw) 37 | 38 | assert result.state == 3 39 | assert result.certificate == "a" * 300 40 | assert result.identifier == "hello" 41 | 42 | assert result.encode() == raw 43 | 44 | 45 | def test_ignore_field(): 46 | @dataclass 47 | class DummyStruct(TLVStruct): 48 | # fields stored locally for each house boat 49 | number_of_residents: u8 = field(init=False, default=1) 50 | 51 | # fields pulled from satellite network 52 | sharks_nearby: u8 = tlv_entry(1) 53 | days_ago_sharks_ate: u8 = tlv_entry(3) 54 | sharks_have_coherent_monochromatic_light_sources: u8 = tlv_entry(5) 55 | 56 | def risk_of_shark_attack(self): 57 | return 100 # the old algorithm was incorrect 58 | 59 | raw = b"\x01\x01\xf0" + b"\x03\x01\x00" + b"\x05\x01\x01" 60 | 61 | result = DummyStruct.decode(raw) 62 | 63 | assert result.number_of_residents == 1 64 | assert result.sharks_nearby == 240 65 | assert result.days_ago_sharks_ate == 0 66 | assert result.sharks_have_coherent_monochromatic_light_sources == 1 67 | 68 | result.number_of_residents = 0 69 | 70 | assert result.encode() == raw 71 | 72 | 73 | def test_int_enum(): 74 | class FooValues(IntEnum): 75 | ACTIVE = 1 76 | INACTIVE = 0 77 | 78 | @dataclass 79 | class DummyStruct(TLVStruct): 80 | foo: FooValues = tlv_entry(1) 81 | 82 | result = DummyStruct.decode(b"\x01\x01\x01") 83 | assert result.foo == FooValues.ACTIVE 84 | 85 | assert DummyStruct(foo=FooValues.ACTIVE).encode() == b"\x01\x01\x01" 86 | assert DummyStruct(foo=FooValues.INACTIVE).encode() == b"\x01\x01\x00" 87 | 88 | 89 | def test_int_64b(): 90 | @dataclass 91 | class DummyStruct(TLVStruct): 92 | prefix: u64 = tlv_entry(48) 93 | 94 | raw = b"\x30\x08" + b"\xbb\xe8\xec