├── .coveragerc ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── accessories ├── AM2302.py ├── BMP180.py ├── DisplaySwitch.py ├── FakeFan.py ├── Http.py ├── LightBulb.py ├── MotionSensor.py ├── NeoPixelLightStrip.py ├── RPI_Relay.py ├── SDS011.py ├── ShutdownSwitch.py ├── TSL2591.py ├── TV.py └── TemperatureSensor.py ├── adaptive_lightbulb.py ├── busy_home.py ├── camera_main.py ├── codecov.yml ├── docs ├── Makefile ├── README.rst ├── make.bat └── source │ ├── api │ ├── accessory.rst │ ├── accessory_driver.rst │ ├── bridge.rst │ ├── characteristic.rst │ ├── index.rst │ ├── loader.rst │ ├── service.rst │ ├── state.rst │ └── util.rst │ ├── conf.py │ ├── index.rst │ └── intro │ ├── examples.rst │ ├── install.rst │ ├── overview.rst │ └── tutorial.rst ├── main.py ├── pyhap ├── __init__.py ├── accessories │ └── README.md ├── accessory.py ├── accessory_driver.py ├── camera.py ├── characteristic.py ├── const.py ├── encoder.py ├── hap_crypto.py ├── hap_event.py ├── hap_handler.py ├── hap_protocol.py ├── hap_server.py ├── hsrp.py ├── iid_manager.py ├── loader.py ├── params.py ├── resources │ ├── characteristics.json │ ├── services.json │ └── snapshot.jpg ├── service.py ├── state.py ├── tlv.py └── util.py ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── requirements_all.txt ├── requirements_docs.txt ├── requirements_test.txt ├── scripts ├── gen_hap_types.py ├── pickle_to_state.py ├── release └── setup ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_accessory.py ├── test_accessory_driver.py ├── test_camera.py ├── test_characteristic.py ├── test_encoder.py ├── test_hap_crypto.py ├── test_hap_handler.py ├── test_hap_protocol.py ├── test_hap_server.py ├── test_hsrp.py ├── test_iid_manager.py ├── test_loader.py ├── test_service.py ├── test_state.py ├── test_tlv.py └── test_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = pyhap/* 4 | omit = 5 | tests/* 6 | pyhap/accessories/* 7 | 8 | [report] 9 | # Regexes for lines to exclude from consideration 10 | exclude_lines = 11 | # Have to re-enable the standard pragma 12 | pragma: no cover 13 | 14 | # Don't complain about missing debug-only code: 15 | def __repr__ 16 | 17 | # Don't complain if tests don't hit defensive assertion code: 18 | raise AssertionError 19 | raise NotImplementedError 20 | 21 | # TYPE_CHECKING and @overload blocks are never executed during pytest run 22 | if TYPE_CHECKING: 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | - name: Lint with tox 27 | run: TOXENV=lint tox 28 | - name: pylint with tox 29 | run: TOXENV=pylint tox 30 | - name: Docs with tox 31 | run: TOXENV=docs tox 32 | 33 | coverage: 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | matrix: 38 | python-version: ["3.10"] 39 | 40 | steps: 41 | - uses: actions/checkout@v1 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v2 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | pip install tox tox-gh-actions 50 | - name: Test with tox 51 | run: TOXENV=codecov tox 52 | - name: Upload coverage to Codecov 53 | uses: codecov/codecov-action@v1 54 | with: 55 | files: ./coverage.xml 56 | directory: ./coverage/reports/ 57 | flags: unittests 58 | env_vars: OS,PYTHON 59 | name: codecov-umbrella 60 | fail_ci_if_error: true 61 | path_to_write_report: ./coverage/codecov_report.txt 62 | verbose: true 63 | 64 | bandit: 65 | runs-on: ubuntu-latest 66 | 67 | strategy: 68 | matrix: 69 | python-version: ["3.10"] 70 | 71 | steps: 72 | - uses: actions/checkout@v1 73 | - name: Set up Python ${{ matrix.python-version }} 74 | uses: actions/setup-python@v2 75 | with: 76 | python-version: ${{ matrix.python-version }} 77 | - name: Install dependencies 78 | run: | 79 | python -m pip install --upgrade pip 80 | pip install tox tox-gh-actions 81 | - name: Bandit 82 | run: TOXENV=bandit tox 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | *.pyc 4 | *.pyo 5 | env 6 | env* 7 | dist 8 | *.egg 9 | *.egg-info 10 | _mailinglist 11 | .cache/ 12 | .idea 13 | __pycache__ 14 | .pytest_cache/ 15 | .tox/ 16 | .venv 17 | venv/ 18 | docs/build/doctrees 19 | docs/build/html 20 | .coverage 21 | .coverage.* 22 | /htmlcov 23 | 24 | # python venv 25 | bin 26 | lib 27 | lib64 28 | pyvenv.cfg 29 | share 30 | 31 | # pip 32 | pip-selfcheck.json 33 | 34 | # HAP-python-generated files 35 | accessory.pickle 36 | /*.state 37 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | cdce8p 2 | jslay88 3 | Ivan Kalchev - Maintainer of the library 4 | Lee Marlow 5 | Matthew Schinckel 6 | Miroslav Kudrnac 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | HAP-python is licensed under the Apache License, version 2.0. 2 | 3 | This package/repository contains code from the HAP-python project as well as 4 | some open-source works. 5 | 6 | File Copyrights 7 | =============== 8 | 9 | All fines not explicitly mentioned below 10 | What: HAP-python 11 | Source URL: https://github.com/ikalchev/HAP-python 12 | Copyright: HAP-python Authors (see AUTHORS file) 13 | License: Apache License 2.0 14 | License text: See below 15 | 16 | pyhap/accessory_driver.py 17 | What: Async helper methods 18 | Methods: 19 | callback 20 | is_callback 21 | AccessoryDriver.start 22 | AccessoryDriver.stop 23 | AccessoryDriver.async_stop 24 | AccessoryDriver.add_job 25 | AccessoryDriver.async_add_job 26 | AccessoryDriver.async_run_job 27 | Inspired from: Home Assistant [1] 28 | Original file: 29 | https://github.com/home-assistant/home-assistant/blob/master/homeassistant/core.py 30 | 31 | setup.py 32 | Inspired from: Home Assistant [1] 33 | Original file: 34 | https://github.com/home-assistant/home-assistant/blob/master/setup.py 35 | 36 | scripts/release 37 | Inspired from: Home Assistant [1] 38 | File: script/release 39 | https://github.com/home-assistant/home-assistant/blob/master/script/release 40 | 41 | scripts/setup 42 | Inspired from: Home Assistant [1] 43 | File: script/setup 44 | https://github.com/home-assistant/home-assistant/blob/master/script/setup 45 | 46 | Credit 47 | ====== 48 | 49 | Some HAP know-how 50 | What: HAP-NodeJS 51 | Source URL: https://github.com/KhaosT/HAP-NodeJS 52 | Copyright: Khaos Tian 53 | License: Apache License 2.0 54 | License URL: https://github.com/KhaosT/HAP-NodeJS/blob/master/LICENSE 55 | 56 | Some Async know-how 57 | What: Home Assistant [1] 58 | 59 | HomeKit Accessory Protocol Specification 60 | Source URL: https://developer.apple.com/homekit/specification/ 61 | 62 | Footnotes 63 | ========= 64 | 65 | [1] Home Assistant 66 | Source URL: https://github.com/home-assistant/home-assistant 67 | Copyright: The Home Assistant Authors 68 | License: Apache License 2.0 69 | License URL: https://github.com/home-assistant/home-assistant/blob/dev/LICENSE.md 70 | 71 | =============================================================================== 72 | 73 | Apache License 74 | Version 2.0, January 2004 75 | http://www.apache.org/licenses/ 76 | 77 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 78 | 79 | 1. Definitions. 80 | 81 | "License" shall mean the terms and conditions for use, reproduction, 82 | and distribution as defined by Sections 1 through 9 of this document. 83 | 84 | "Licensor" shall mean the copyright owner or entity authorized by 85 | the copyright owner that is granting the License. 86 | 87 | "Legal Entity" shall mean the union of the acting entity and all 88 | other entities that control, are controlled by, or are under common 89 | control with that entity. For the purposes of this definition, 90 | "control" means (i) the power, direct or indirect, to cause the 91 | direction or management of such entity, whether by contract or 92 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 93 | outstanding shares, or (iii) beneficial ownership of such entity. 94 | 95 | "You" (or "Your") shall mean an individual or Legal Entity 96 | exercising permissions granted by this License. 97 | 98 | "Source" form shall mean the preferred form for making modifications, 99 | including but not limited to software source code, documentation 100 | source, and configuration files. 101 | 102 | "Object" form shall mean any form resulting from mechanical 103 | transformation or translation of a Source form, including but 104 | not limited to compiled object code, generated documentation, 105 | and conversions to other media types. 106 | 107 | "Work" shall mean the work of authorship, whether in Source or 108 | Object form, made available under the License, as indicated by a 109 | copyright notice that is included in or attached to the work 110 | (an example is provided in the Appendix below). 111 | 112 | "Derivative Works" shall mean any work, whether in Source or Object 113 | form, that is based on (or derived from) the Work and for which the 114 | editorial revisions, annotations, elaborations, or other modifications 115 | represent, as a whole, an original work of authorship. For the purposes 116 | of this License, Derivative Works shall not include works that remain 117 | separable from, or merely link (or bind by name) to the interfaces of, 118 | the Work and Derivative Works thereof. 119 | 120 | "Contribution" shall mean any work of authorship, including 121 | the original version of the Work and any modifications or additions 122 | to that Work or Derivative Works thereof, that is intentionally 123 | submitted to Licensor for inclusion in the Work by the copyright owner 124 | or by an individual or Legal Entity authorized to submit on behalf of 125 | the copyright owner. For the purposes of this definition, "submitted" 126 | means any form of electronic, verbal, or written communication sent 127 | to the Licensor or its representatives, including but not limited to 128 | communication on electronic mailing lists, source code control systems, 129 | and issue tracking systems that are managed by, or on behalf of, the 130 | Licensor for the purpose of discussing and improving the Work, but 131 | excluding communication that is conspicuously marked or otherwise 132 | designated in writing by the copyright owner as "Not a Contribution." 133 | 134 | "Contributor" shall mean Licensor and any individual or Legal Entity 135 | on behalf of whom a Contribution has been received by Licensor and 136 | subsequently incorporated within the Work. 137 | 138 | 2. Grant of Copyright License. Subject to the terms and conditions of 139 | this License, each Contributor hereby grants to You a perpetual, 140 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 141 | copyright license to reproduce, prepare Derivative Works of, 142 | publicly display, publicly perform, sublicense, and distribute the 143 | Work and such Derivative Works in Source or Object form. 144 | 145 | 3. Grant of Patent License. Subject to the terms and conditions of 146 | this License, each Contributor hereby grants to You a perpetual, 147 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 148 | (except as stated in this section) patent license to make, have made, 149 | use, offer to sell, sell, import, and otherwise transfer the Work, 150 | where such license applies only to those patent claims licensable 151 | by such Contributor that are necessarily infringed by their 152 | Contribution(s) alone or by combination of their Contribution(s) 153 | with the Work to which such Contribution(s) was submitted. If You 154 | institute patent litigation against any entity (including a 155 | cross-claim or counterclaim in a lawsuit) alleging that the Work 156 | or a Contribution incorporated within the Work constitutes direct 157 | or contributory patent infringement, then any patent licenses 158 | granted to You under this License for that Work shall terminate 159 | as of the date such litigation is filed. 160 | 161 | 4. Redistribution. You may reproduce and distribute copies of the 162 | Work or Derivative Works thereof in any medium, with or without 163 | modifications, and in Source or Object form, provided that You 164 | meet the following conditions: 165 | 166 | (a) You must give any other recipients of the Work or 167 | Derivative Works a copy of this License; and 168 | 169 | (b) You must cause any modified files to carry prominent notices 170 | stating that You changed the files; and 171 | 172 | (c) You must retain, in the Source form of any Derivative Works 173 | that You distribute, all copyright, patent, trademark, and 174 | attribution notices from the Source form of the Work, 175 | excluding those notices that do not pertain to any part of 176 | the Derivative Works; and 177 | 178 | (d) If the Work includes a "NOTICE" text file as part of its 179 | distribution, then any Derivative Works that You distribute must 180 | include a readable copy of the attribution notices contained 181 | within such NOTICE file, excluding those notices that do not 182 | pertain to any part of the Derivative Works, in at least one 183 | of the following places: within a NOTICE text file distributed 184 | as part of the Derivative Works; within the Source form or 185 | documentation, if provided along with the Derivative Works; or, 186 | within a display generated by the Derivative Works, if and 187 | wherever such third-party notices normally appear. The contents 188 | of the NOTICE file are for informational purposes only and 189 | do not modify the License. You may add Your own attribution 190 | notices within Derivative Works that You distribute, alongside 191 | or as an addendum to the NOTICE text from the Work, provided 192 | that such additional attribution notices cannot be construed 193 | as modifying the License. 194 | 195 | You may add Your own copyright statement to Your modifications and 196 | may provide additional or different license terms and conditions 197 | for use, reproduction, or distribution of Your modifications, or 198 | for any such Derivative Works as a whole, provided Your use, 199 | reproduction, and distribution of the Work otherwise complies with 200 | the conditions stated in this License. 201 | 202 | 5. Submission of Contributions. Unless You explicitly state otherwise, 203 | any Contribution intentionally submitted for inclusion in the Work 204 | by You to the Licensor shall be under the terms and conditions of 205 | this License, without any additional terms or conditions. 206 | Notwithstanding the above, nothing herein shall supersede or modify 207 | the terms of any separate license agreement you may have executed 208 | with Licensor regarding such Contributions. 209 | 210 | 6. Trademarks. This License does not grant permission to use the trade 211 | names, trademarks, service marks, or product names of the Licensor, 212 | except as required for reasonable and customary use in describing the 213 | origin of the Work and reproducing the content of the NOTICE file. 214 | 215 | 7. Disclaimer of Warranty. Unless required by applicable law or 216 | agreed to in writing, Licensor provides the Work (and each 217 | Contributor provides its Contributions) on an "AS IS" BASIS, 218 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 219 | implied, including, without limitation, any warranties or conditions 220 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 221 | PARTICULAR PURPOSE. You are solely responsible for determining the 222 | appropriateness of using or redistributing the Work and assume any 223 | risks associated with Your exercise of permissions under this License. 224 | 225 | 8. Limitation of Liability. In no event and under no legal theory, 226 | whether in tort (including negligence), contract, or otherwise, 227 | unless required by applicable law (such as deliberate and grossly 228 | negligent acts) or agreed to in writing, shall any Contributor be 229 | liable to You for damages, including any direct, indirect, special, 230 | incidental, or consequential damages of any character arising as a 231 | result of this License or out of the use or inability to use the 232 | Work (including but not limited to damages for loss of goodwill, 233 | work stoppage, computer failure or malfunction, or any and all 234 | other commercial damages or losses), even if such Contributor 235 | has been advised of the possibility of such damages. 236 | 237 | 9. Accepting Warranty or Additional Liability. While redistributing 238 | the Work or Derivative Works thereof, You may choose to offer, 239 | and charge a fee for, acceptance of support, warranty, indemnity, 240 | or other liability obligations and/or rights consistent with this 241 | License. However, in accepting such obligations, You may act only 242 | on Your own behalf and on Your sole responsibility, not on behalf 243 | of any other Contributor, and only if You agree to indemnify, 244 | defend, and hold each Contributor harmless for any liability 245 | incurred by, or claims asserted against, such Contributor by reason 246 | of your accepting any such warranty or additional liability. 247 | 248 | END OF TERMS AND CONDITIONS 249 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | recursive-include pyhap/resources * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/HAP-python.svg)](https://badge.fury.io/py/HAP-python) [![Build Status](https://github.com/ikalchev/HAP-python/workflows/CI/badge.svg)](https://github.com/ikalchev/HAP-python) [![codecov](https://codecov.io/gh/ikalchev/HAP-python/branch/master/graph/badge.svg)](https://codecov.io/gh/ikalchev/HAP-python) [![Python Versions](https://img.shields.io/pypi/pyversions/HAP-python.svg)](https://pypi.python.org/pypi/HAP-python/) [![Documentation Status](https://readthedocs.org/projects/hap-python/badge/?version=latest)](http://hap-python.readthedocs.io/en/latest/?badge=latest) [![Downloads](https://pepy.tech/badge/hap-python)](https://pepy.tech/project/hap-python) 2 | # HAP-python 3 | 4 | HomeKit Accessory Protocol implementation in python 3. 5 | With this project, you can integrate your own smart devices and add them to your 6 | iOS Home app. Since Siri is integrated with the Home app, you can start voice-control your 7 | accessories right away. 8 | 9 | Main features: 10 | 11 | * Camera - HAP-python supports the camera accessory from version 2.3.0! 12 | * asyncio support - You can run various tasks or accessories in the event loop. 13 | * Out of the box support for Apple-defined services - see them in [the resources folder](pyhap/resources). 14 | * Secure pairing by just scanning the QR code. 15 | * Integrated with the home automation framework [Home Assistant](https://github.com/home-assistant/home-assistant). 16 | 17 | The project was developed for a Raspberry Pi, but it should work on other platforms. To kick-start things, 18 | you can open `main.py` or `busy_home.py`, where you will find some fake accessories. 19 | Just run one of them, for example `python3 busy_home.py`, and you can add it in 20 | the Home app (be sure to be in the same network). 21 | Stop it by hitting Ctrl+C. 22 | 23 | There are example accessories as well as integrations with real products 24 | in [the accessories folder](accessories). See how to configure your camera in 25 | [camera_main.py](camera_main.py). 26 | 27 | ## Table of Contents 28 | 1. [API](#API) 29 | 2. [Installation](#Installation) 30 | 3. [Setting up a camera](#Camera) 31 | 4. [Run at boot (and a Switch to shutdown your device)](#AtBoot) 32 | 5. [Notice](#Notice) 33 | 34 | ## Installation 35 | 36 | As of version 3.5.1, HAP-python no longer supports python older than 3.6, because we 37 | are moving to asyncio. If your platform does not have a compatible python out of the 38 | box, you can install it manually or just use an older version of HAP-python. 39 | 40 | As a prerequisite, you will need Avahi/Bonjour installed (due to zeroconf package). 41 | On a Raspberry Pi, you can get it with: 42 | ``` 43 | $ sudo apt-get install libavahi-compat-libdnssd-dev 44 | ``` 45 | `avahi-utils` may also fit the bill. Then, you can install with `pip3` (you will need `sudo` or `--user` for the install): 46 | ```sh 47 | $ pip3 install HAP-python[QRCode] 48 | ``` 49 | 50 | This will install HAP-python in your python packages, so that you can import it as `pyhap`. To uninstall, just do: 51 | ``` 52 | $ pip3 uninstall HAP-python 53 | ``` 54 | 55 | ## API 56 | 57 | A typical flow for using HAP-python starts with implementing an Accessory. This is done by 58 | subclassing [Accessory](pyhap/accessory.py) and putting in place a few details 59 | (see below). After that, you give your accessory to an AccessoryDriver to manage. This 60 | will take care of advertising it on the local network, setting a HAP server and 61 | running the Accessory. Take a look at [main.py](main.py) for a quick start on that. 62 | 63 | ```python 64 | from pyhap.accessory import Accessory, Category 65 | import pyhap.loader as loader 66 | 67 | class TemperatureSensor(Accessory): 68 | """Implementation of a mock temperature sensor accessory.""" 69 | 70 | category = Category.SENSOR # This is for the icon in the iOS Home app. 71 | 72 | def __init__(self, *args, **kwargs): 73 | """Here, we just store a reference to the current temperature characteristic and 74 | add a method that will be executed every time its value changes. 75 | """ 76 | # If overriding this method, be sure to call the super's implementation first. 77 | super().__init__(*args, **kwargs) 78 | 79 | # Add the services that this Accessory will support with add_preload_service here 80 | temp_service = self.add_preload_service('TemperatureSensor') 81 | self.temp_char = temp_service.get_characteristic('CurrentTemperature') 82 | 83 | # Having a callback is optional, but you can use it to add functionality. 84 | self.temp_char.setter_callback = self.temperature_changed 85 | 86 | def temperature_changed(self, value): 87 | """This will be called every time the value of the CurrentTemperature 88 | is changed. Use setter_callbacks to react to user actions, e.g. setting the 89 | lights On could fire some GPIO code to turn on a LED (see pyhap/accessories/LightBulb.py). 90 | """ 91 | print('Temperature changed to: ', value) 92 | 93 | @Accessory.run_at_interval(3) # Run this method every 3 seconds 94 | # The `run` method can be `async` as well 95 | def run(self): 96 | """We override this method to implement what the accessory will do when it is 97 | started. 98 | 99 | We set the current temperature to a random number. The decorator runs this method 100 | every 3 seconds. 101 | """ 102 | self.temp_char.set_value(random.randint(18, 26)) 103 | 104 | # The `stop` method can be `async` as well 105 | def stop(self): 106 | """We override this method to clean up any resources or perform final actions, as 107 | this is called by the AccessoryDriver when the Accessory is being stopped. 108 | """ 109 | print('Stopping accessory.') 110 | ``` 111 | 112 | ## Service Callbacks 113 | 114 | When you are working with tightly coupled characteristics such as "On" and "Brightness," 115 | you may need to use a service callback to receive all changes in a single request. 116 | 117 | With characteristic callbacks, you do now know that a "Brightness" characteristic is 118 | about to be processed right after an "On" and may end up setting a LightBulb to 100% 119 | and then dim it back down to the expected level. 120 | 121 | ```python 122 | from pyhap.accessory import Accessory 123 | from pyhap.const import Category 124 | import pyhap.loader as loader 125 | 126 | class Light(Accessory): 127 | """Implementation of a mock light accessory.""" 128 | 129 | category = Category.CATEGORY_LIGHTBULB # This is for the icon in the iOS Home app. 130 | 131 | def __init__(self, *args, **kwargs): 132 | """Here, we just store a reference to the on and brightness characteristics and 133 | add a method that will be executed every time their value changes. 134 | """ 135 | # If overriding this method, be sure to call the super's implementation first. 136 | super().__init__(*args, **kwargs) 137 | 138 | # Add the services that this Accessory will support with add_preload_service here 139 | serv_light = self.add_preload_service('Lightbulb') 140 | self.char_on = serv_light.configure_char('On', value=self._state) 141 | self.char_brightness = serv_light.configure_char('Brightness', value=100) 142 | 143 | serv_light.setter_callback = self._set_chars 144 | 145 | def _set_chars(self, char_values): 146 | """This will be called every time the value of the on of the 147 | characteristics on the service changes. 148 | """ 149 | if "On" in char_values: 150 | print('On changed to: ', char_values["On"]) 151 | if "Brightness" in char_values: 152 | print('Brightness changed to: ', char_values["Brightness"]) 153 | 154 | @Accessory.run_at_interval(3) # Run this method every 3 seconds 155 | # The `run` method can be `async` as well 156 | def run(self): 157 | """We override this method to implement what the accessory will do when it is 158 | started. 159 | 160 | We set the current temperature to a random number. The decorator runs this method 161 | every 3 seconds. 162 | """ 163 | self.char_on.set_value(random.randint(0, 1)) 164 | self.char_brightness.set_value(random.randint(1, 100)) 165 | 166 | # The `stop` method can be `async` as well 167 | def stop(self): 168 | """We override this method to clean up any resources or perform final actions, as 169 | this is called by the AccessoryDriver when the Accessory is being stopped. 170 | """ 171 | print('Stopping accessory.') 172 | ``` 173 | 174 | ## Setting up a camera 175 | 176 | The [Camera accessory](pyhap/camera.py) implements the HomeKit Protocol for negotiating stream settings, 177 | such as the picture width and height, number of audio channels and others. 178 | Starting a video and/or audio stream is very platform specific. Because of this, 179 | you need to figure out what video and audio settings your camera supports and set them 180 | in the `options` parameter that is passed to the `Camera` Accessory. Refer to the 181 | documentation for the `Camera` contructor for the settings you need to specify. 182 | 183 | By default, HAP-python will execute the `ffmpeg` command with the negotiated parameters 184 | when the stream should be started and will `terminate` the started process when the 185 | stream should be stopped (see the default: `Camera.FFMPEG_CMD`). 186 | If the default command is not supported or correctly formatted for your platform, 187 | the streaming can fail. 188 | 189 | For these cases, HAP-python has hooks so that you can insert your own command or implement 190 | the logic for starting or stopping the stream. There are two options: 191 | 192 | 1. Pass your own command that will be executed when the stream should be started. 193 | 194 | You pass the command as a value to the key `start_stream_cmd` in the `options` parameter to 195 | the constuctor of the `Camera` Accessory. The command is formatted using the 196 | negotiated stream configuration parameters. For example, if the negotiated width 197 | is 640 and you pass `foo start -width {width}`, the command will be formatted as 198 | `foo start -width 640`. 199 | 200 | The full list of negotiated stream configuration parameters can be found in the 201 | documentation for the `Camera.start` method. 202 | 203 | 2. Implement your own logic to start, stop and reconfigure the stream. 204 | 205 | If you need more flexibility in managing streams, you can directly implement the 206 | `Camera` methods `start`, `stop` and `reconfigure`. Each will be called when the 207 | stream should be respectively started, stopped or reconfigured. The start and 208 | reconfigure methods are given the negotiated stream configuration parameters. 209 | 210 | Have a look at the documentation of these methods for more information. 211 | 212 | Finally, if you can take snapshots from the camera, you may want to implement the 213 | `Camera.snapshot` method. By default, this serves a stock photo. 214 | 215 | ## Run at boot 216 | This is a quick way to get `HAP-python` to run at boot on a Raspberry Pi. It is recommended 217 | to turn on "Wait for network" in `raspi-config`. If this turns to be unreliable, see 218 | [this](https://www.raspberrypi.org/forums/viewtopic.php?f=66&t=187225). 219 | 220 | Copy the below in `/etc/systemd/system/HAP-python.service` (needs sudo). 221 | ``` 222 | [Unit] 223 | Description = HAP-python daemon 224 | Wants = pigpiod.service # Remove this if you don't depend on pigpiod 225 | After = local-fs.target network-online.target pigpiod.service 226 | 227 | [Service] 228 | User = lesserdaemon # It's a good idea to use some unprivileged system user 229 | # Script starting HAP-python, e.g. main.py 230 | # Be careful to set any paths you use, e.g. for persisting the state. 231 | ExecStart = /usr/bin/python3 /home/lesserdaemon/.hap-python/hap-python.py 232 | 233 | [Install] 234 | WantedBy = multi-user.target 235 | ``` 236 | 237 | Test that everything is fine by doing: 238 | 239 | ```sh 240 | > sudo systemctl start HAP-python 241 | > systemctl status HAP-python 242 | > sudo journalctl -u HAP-python # to see the output of the start up script. 243 | > sudo systemctl stop HAP-python 244 | ``` 245 | 246 | To enable or disable at boot, do: 247 | 248 | ```sh 249 | > sudo systemctl enable HAP-python 250 | > sudo systemctl disable HAP-python 251 | ``` 252 | 253 | ### Shutdown switch 254 | 255 | If you are running `HAP-python` on a Raspberry Pi, you may want to add a 256 | [Shutdown Switch](pyhap/accessories/ShutdownSwitch.py) to your Home. This is a 257 | Switch Accessory, which, when triggered, executes `sudo shutdown -h now`, i.e. 258 | it shutdowns and halts the Pi. This allows you to safely unplug it. 259 | 260 | For the above to work, you need to enable passwordless `/sbin/shutdown` to whichever 261 | user is running `HAP-python`. For example, do: 262 | ```sh 263 | $ sudo visudo # and add the line: " ALL=NOPASSWD: /sbin/shutdown". 264 | ``` 265 | 266 | ## Notice 267 | 268 | Some HAP know-how was taken from [HAP-NodeJS by KhaosT](https://github.com/KhaosT/HAP-NodeJS). 269 | 270 | I am not aware of any bugs, but I am more than confident that such exist. If you find any, 271 | please report and I will try to fix them. 272 | 273 | Suggestions are always welcome. 274 | 275 | Have fun! 276 | -------------------------------------------------------------------------------- /accessories/AM2302.py: -------------------------------------------------------------------------------- 1 | """An Accessory for the AM2302 temperature and humidity sensor. 2 | Assumes the DHT22 module is in a package called sensors. 3 | Also, make sure pigpiod is running. 4 | The DHT22 module was taken from 5 | https://www.raspberrypi.org/forums/viewtopic.php?f=37&t=71336 6 | """ 7 | import time 8 | import random 9 | 10 | import pigpio 11 | import sensors.DHT22 as DHT22 12 | 13 | from pyhap.accessory import Accessory 14 | from pyhap.const import CATEGORY_SENSOR 15 | 16 | 17 | class AM2302(Accessory): 18 | 19 | category = CATEGORY_SENSOR 20 | 21 | def __init__(self, *args, pin=4, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | self.pin = pin 24 | 25 | serv_temp = self.add_preload_service('TemperatureSensor') 26 | serv_humidity = self.add_preload_service('HumiditySensor') 27 | 28 | self.char_temp = serv_temp.get_characteristic('CurrentTemperature') 29 | self.char_humidity = serv_humidity \ 30 | .get_characteristic('CurrentRelativeHumidity') 31 | 32 | self.sensor = DHT22.sensor(pigpio.pi(), pin) 33 | 34 | def __getstate__(self): 35 | state = super().__getstate__() 36 | state['sensor'] = None 37 | return state 38 | 39 | def __setstate__(self, state): 40 | self.__dict__.update(state) 41 | self.sensor = DHT22.sensor(pigpio.pi(), self.pin) 42 | 43 | @Accessory.run_at_interval(10) 44 | def run(self): 45 | self.sensor.trigger() 46 | time.sleep(0.2) 47 | t = self.sensor.temperature() 48 | h = self.sensor.humidity() 49 | self.char_temp.set_value(t) 50 | self.char_humidity.set_value(h) 51 | -------------------------------------------------------------------------------- /accessories/BMP180.py: -------------------------------------------------------------------------------- 1 | # An Accessory for the BMP180 sensor. 2 | # This assumes the bmp180 module is in a package called sensors. 3 | # Assume you have a bmp module with BMP180 class with read() method. 4 | from sensors.bmp180 import BMP180 as sensor 5 | 6 | from pyhap.accessory import Accessory 7 | from pyhap.const import CATEGORY_SENSOR 8 | 9 | 10 | class BMP180(Accessory): 11 | 12 | category = CATEGORY_SENSOR 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | serv_temp = self.add_preload_service('TemperatureSensor') 18 | self.char_temp = serv_temp.get_characteristic('CurrentTemperature') 19 | 20 | self.sensor = sensor() 21 | 22 | def __getstate__(self): 23 | state = super().__getstate__() 24 | state['sensor'] = None 25 | return state 26 | 27 | def __setstate__(self, state): 28 | self.__dict__.update(state) 29 | self.sensor = sensor() 30 | 31 | @Accessory.run_at_interval(30) 32 | def run(self): 33 | temp, _pressure = self.sensor.read() 34 | self.char_temp.set_value(temp) 35 | -------------------------------------------------------------------------------- /accessories/DisplaySwitch.py: -------------------------------------------------------------------------------- 1 | # An Accessory for viewing/controlling the status of a Mac display. 2 | import subprocess 3 | 4 | from pyhap.accessory import Accessory 5 | from pyhap.const import CATEGORY_SWITCH 6 | 7 | 8 | def get_display_state(): 9 | result = subprocess.check_output(['pmset', '-g', 'powerstate', 'IODisplayWrangler']) 10 | return int(result.strip().split(b'\n')[-1].split()[1]) >= 4 11 | 12 | 13 | def set_display_state(state): 14 | if state: 15 | subprocess.call(['caffeinate', '-u', '-t', '1']) 16 | else: 17 | subprocess.call(['pmset', 'displaysleepnow']) 18 | 19 | 20 | class DisplaySwitch(Accessory): 21 | """ 22 | An accessory that will display, and allow setting, the display status 23 | of the Mac that this code is running on. 24 | """ 25 | 26 | category = CATEGORY_SWITCH 27 | 28 | def __init__(self, *args, **kwargs): 29 | super().__init__(*args, **kwargs) 30 | 31 | serv_switch = self.add_preload_service('Switch') 32 | self.display = serv_switch.configure_char( 33 | 'On', setter_callback=self.set_display) 34 | 35 | @Accessory.run_at_interval(1) 36 | def run(self): 37 | # We can't just use .set_value(state), because that will 38 | # trigger our listener. 39 | state = get_display_state() 40 | if self.display.value != state: 41 | self.display.value = state 42 | self.display.notify() 43 | 44 | def set_display(self, state): 45 | if get_display_state() != state: 46 | set_display_state(state) 47 | -------------------------------------------------------------------------------- /accessories/FakeFan.py: -------------------------------------------------------------------------------- 1 | """A fake fan that does nothing but to demonstrate optional characteristics.""" 2 | import logging 3 | 4 | from pyhap.accessory import Accessory 5 | from pyhap.const import CATEGORY_FAN 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class FakeFan(Accessory): 11 | """A fake fan accessory that logs changes to its rotation speed and direction.""" 12 | 13 | category = CATEGORY_FAN 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | # Add the fan service. Also add optional characteristics to it. 19 | serv_fan = self.add_preload_service( 20 | 'Fan', chars=['RotationSpeed', 'RotationDirection']) 21 | 22 | self.char_rotation_speed = serv_fan.configure_char( 23 | 'RotationSpeed', setter_callback=self.set_rotation_speed) 24 | self.char_rotation_direction = serv_fan.configure_char( 25 | 'RotationDirection', setter_callback=self.set_rotation_direction) 26 | 27 | def set_rotation_speed(self, value): 28 | logger.debug("Rotation speed changed: %s", value) 29 | 30 | def set_rotation_direction(self, value): 31 | logger.debug("Rotation direction changed: %s", value) 32 | -------------------------------------------------------------------------------- /accessories/Http.py: -------------------------------------------------------------------------------- 1 | """This module provides HttpAccessory - an accessory that 2 | allows remote devices to provide HAP services by sending POST 3 | requests. 4 | """ 5 | import json 6 | import threading 7 | import logging 8 | from http.server import HTTPServer, BaseHTTPRequestHandler 9 | 10 | from pyhap.accessory import Bridge 11 | from pyhap.const import CATEGORY_OTHER 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class HttpBridgeHandler(BaseHTTPRequestHandler): 17 | """Handles requests and passes value updates to an HttpAccessory. 18 | 19 | The POST request should contain json data with the format: 20 | { "aid": 21 | "services": { 22 | : { 23 | : value, 24 | } 25 | } 26 | } 27 | 28 | Example: 29 | { "aid": 2 30 | "services": { 31 | TemperatureSensor" : { 32 | "CurrentTemperature": 20 33 | } 34 | } 35 | } 36 | """ 37 | 38 | def __init__(self, http_accessory, sock, client_addr, server): 39 | """Create a handler that passes updates to the given HttpAccessory. 40 | """ 41 | self.http_accessory = http_accessory 42 | super().__init__(sock, client_addr, server) 43 | 44 | def respond_ok(self): 45 | """Reply with code 200 (OK) and close the connection. 46 | """ 47 | self.send_response(200) 48 | self.send_header("Content-Type", "text/html") 49 | self.send_header("Content-Length", 0) 50 | self.end_headers() 51 | self.close_connection = 1 52 | 53 | def respond_err(self): 54 | """Reply with code 400 and close the connection. 55 | """ 56 | self.send_response(400) 57 | self.send_header("Content-Type", "text/html") 58 | self.send_header("Content-Length", 0) 59 | self.end_headers() 60 | self.close_connection = 1 61 | 62 | def do_POST(self): 63 | """Read the payload as json and update the state of the accessory. 64 | """ 65 | length = int(self.headers["Content-Length"]) 66 | try: 67 | # The below decode is necessary only for python <3.6, because loads prior 3.6 68 | # doesn't know bytes/bytearray. 69 | content = self.rfile.read(length).decode('utf-8') 70 | data = json.loads(content) 71 | except Exception as e: 72 | logger.error("Bad POST request; Error was: %s", str(e)) 73 | self.respond_err() 74 | else: 75 | self.http_accessory.update_state(data) 76 | self.respond_ok() 77 | 78 | 79 | class HttpBridge(Bridge): 80 | """An accessory that listens to HTTP requests containing characteristic updates. 81 | 82 | Simple devices/implementations can just HTTP POST data as: 83 | 84 | { 85 | : int, 86 | "services": { 87 | : { 88 | : value 89 | ... 90 | } 91 | ... 92 | } 93 | } 94 | 95 | Then this accessory takes care of communicating this update to any HAP clients. 96 | 97 | The way you configure a HttpBridge is by adding Accessory objects. You can specify 98 | the Accessory AIDs, which will be needed when making POST requests. In the 99 | example below, we add three accessories to a HTTP Bridge: 100 | >>> # get loaders 101 | >>> service_loader = loader.get_serv_loader() 102 | >>> char_loader = loader.get_char_loader() 103 | >>> 104 | >>> # Create an accessory with the temperature sensor service. 105 | >>> temperature_acc_1 = Accessory("temp1") 106 | >>> temperature_acc_1.add_service(service_loader.get("TemperatureSensor")) 107 | >>> 108 | >>> # Create an accessory with the temperature sensor service. 109 | >>> # Also, add an optional characteristic Name to the service. 110 | >>> temperature_acc_2 = Accessory("temp2") 111 | >>> temp_service = service_loader.get("TemperatureSensor") 112 | >>> temp_service.add_characteristic(char_loader.get("StatusLowBattery")) 113 | >>> temperature_acc_2.add_service(temp_service) 114 | >>> 115 | >>> # Create a lightbulb accessory. 116 | >>> light_bulb_acc = Accessory("bulb") 117 | >>> light_bulb_acc.add_service(service_loader.get("Lightbulb")) 118 | >>> 119 | >>> # Finally, create the HTTP Bridge and add all accessories to it. 120 | >>> http_bridge = HttpBridge("HTTP Bridge", address=("", 51111)) 121 | >>> for accessory in (temperature_acc_1, temperature_acc_2, light_bulb_acc): 122 | ... http_bridge.add_accessory(accessory) 123 | >>> 124 | >>> # add to an accessory driver and start as usual 125 | 126 | After the above you can HTTP POST updates to the local address at port 51111. 127 | """ 128 | 129 | category = CATEGORY_OTHER 130 | 131 | def __init__(self, address, *args, **kwargs): 132 | """Initialise and add the given services. 133 | 134 | @param address: The address-port on which to listen for requests. 135 | @type address: tuple(str, int) 136 | 137 | @param accessories: 138 | """ 139 | super().__init__(*args, **kwargs) 140 | 141 | # For exclusive access to updates. Slight overkill... 142 | self.update_lock = None 143 | self.server_thread = None 144 | self._set_server(address) 145 | 146 | def _set_server(self, address): 147 | """Set up a HTTPServer to listen on the given address. 148 | """ 149 | self.server = HTTPServer(address, lambda *a: HttpBridgeHandler(self, *a)) 150 | self.server_thread = threading.Thread(target=self.server.serve_forever) 151 | self.update_lock = threading.Lock() 152 | 153 | def __getstate__(self): 154 | """Return the state of this instance, less the server and server thread. 155 | 156 | Also add the server address. All this is because we cannot pickle such 157 | objects and to allow to recover the server using the address. 158 | """ 159 | state = super().__getstate__() 160 | state['server'] = None 161 | state['server_thread'] = None 162 | state['update_lock'] = None 163 | state['address'] = self.server.server_address 164 | return state 165 | 166 | def __setstate__(self, state): 167 | """Load the state and set up the server with the address in the state. 168 | """ 169 | self.__dict__.update(state) 170 | self._set_server(state['address']) 171 | 172 | def update_state(self, data): 173 | """Update the characteristics from the received data. 174 | 175 | Expected to be called from HapHttpHandler. Updates are thread-safe. 176 | 177 | @param data: A dict of values that should be set, e.g.: 178 | { 179 | : int, 180 | : { 181 | : value 182 | ... 183 | } 184 | ... 185 | } 186 | @type data: dict 187 | """ 188 | aid = data['aid'] 189 | logger.debug("Got update from accessory with aid: %d", aid) 190 | accessory = self.accessories[aid] 191 | service_data = data['services'] 192 | for service, char_data in service_data.items(): 193 | service_obj = accessory.get_service(service) 194 | for char, value in char_data.items(): 195 | char_obj = service_obj.get_characteristic(char) 196 | with self.update_lock: 197 | char_obj.set_value(value) 198 | 199 | def stop(self): 200 | """Stop the server. 201 | """ 202 | super().stop() 203 | logger.debug("Stopping HTTP bridge server.") 204 | self.server.shutdown() 205 | self.server.server_close() 206 | 207 | def run(self): 208 | """Start the server - can listen for requests. 209 | """ 210 | logger.debug("Starting HTTP bridge server.") 211 | self.server_thread.start() 212 | -------------------------------------------------------------------------------- /accessories/LightBulb.py: -------------------------------------------------------------------------------- 1 | # An Accessory for a LED attached to pin 11. 2 | import logging 3 | 4 | import RPi.GPIO as GPIO 5 | 6 | from pyhap.accessory import Accessory 7 | from pyhap.const import CATEGORY_LIGHTBULB 8 | 9 | 10 | class LightBulb(Accessory): 11 | 12 | category = CATEGORY_LIGHTBULB 13 | 14 | @classmethod 15 | def _gpio_setup(_cls, pin): 16 | if GPIO.getmode() is None: 17 | GPIO.setmode(GPIO.BOARD) 18 | GPIO.setup(pin, GPIO.OUT) 19 | 20 | def __init__(self, *args, pin=11, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | 23 | serv_light = self.add_preload_service('Lightbulb') 24 | self.char_on = serv_light.configure_char( 25 | 'On', setter_callback=self.set_bulb) 26 | 27 | self.pin = pin 28 | self._gpio_setup(pin) 29 | 30 | def __setstate__(self, state): 31 | self.__dict__.update(state) 32 | self._gpio_setup(self.pin) 33 | 34 | def set_bulb(self, value): 35 | if value: 36 | GPIO.output(self.pin, GPIO.HIGH) 37 | else: 38 | GPIO.output(self.pin, GPIO.LOW) 39 | 40 | def stop(self): 41 | super().stop() 42 | GPIO.cleanup() 43 | -------------------------------------------------------------------------------- /accessories/MotionSensor.py: -------------------------------------------------------------------------------- 1 | # An Accessory for a MotionSensor 2 | import random 3 | 4 | import RPi.GPIO as GPIO 5 | 6 | from pyhap.accessory import Accessory 7 | from pyhap.const import CATEGORY_SENSOR 8 | 9 | 10 | class MotionSensor(Accessory): 11 | 12 | category = CATEGORY_SENSOR 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | serv_motion = self.add_preload_service('MotionSensor') 18 | self.char_detected = serv_motion.configure_char('MotionDetected') 19 | GPIO.setmode(GPIO.BCM) 20 | GPIO.setup(7, GPIO.IN) 21 | GPIO.add_event_detect(7, GPIO.RISING, callback=self._detected) 22 | 23 | def _detected(self, _pin): 24 | self.char_detected.set_value(True) 25 | 26 | def stop(self): 27 | super().stop() 28 | GPIO.cleanup() 29 | -------------------------------------------------------------------------------- /accessories/NeoPixelLightStrip.py: -------------------------------------------------------------------------------- 1 | """ 2 | An Accessory for Adafruit NeoPixels attached to GPIO Pin18 3 | Tested using Python 3.5/3.6 Raspberry Pi 4 | This device uses all available services for the Homekit Lightbulb API 5 | Note: RPi GPIO must be PWM. Neopixels.py will warn if wrong GPIO is used 6 | at runtime 7 | Note: This Class requires the installation of rpi_ws281x lib 8 | Follow the instllation instructions; 9 | git clone https://github.com/jgarff/rpi_ws281x.git 10 | cd rpi_ws281x 11 | scons 12 | 13 | cd python 14 | sudo python3.6 setup.py install 15 | https://learn.adafruit.com/neopixels-on-raspberry-pi/software 16 | 17 | Apple Homekit API Call Order 18 | User changes light settings on iOS device 19 | Changing Brightness - State - Hue - Brightness 20 | Changing Color - Saturation - Hue 21 | Changing Temp/Sat - Saturation - Hue 22 | Changing State - State 23 | """ 24 | 25 | from neopixel import * 26 | 27 | from pyhap.accessory import Accessory 28 | from pyhap.const import CATEGORY_LIGHTBULB 29 | 30 | 31 | class NeoPixelLightStrip(Accessory): 32 | 33 | category = CATEGORY_LIGHTBULB 34 | 35 | def __init__(self, LED_count, is_GRB, LED_pin, 36 | LED_freq_hz, LED_DMA, LED_brightness, 37 | LED_invert, *args, **kwargs): 38 | 39 | """ 40 | LED_Count - the number of LEDs in the array 41 | is_GRB - most neopixels are GRB format - Normal:True 42 | LED_pin - must be PWM pin 18 - Normal:18 43 | LED_freq_hz - frequency of the neopixel leds - Normal:800000 44 | LED_DMA - Normal:10 45 | LED_Brightness - overall brightness - Normal:255 46 | LED_invert - Normal:False 47 | For more information regarding these settings 48 | please review rpi_ws281x source code 49 | """ 50 | 51 | super().__init__(*args, **kwargs) 52 | 53 | # Set our neopixel API services up using Lightbulb base 54 | serv_light = self.add_preload_service( 55 | 'Lightbulb', chars=['On', 'Hue', 'Saturation', 'Brightness']) 56 | 57 | # Configure our callbacks 58 | self.char_hue = serv_light.configure_char( 59 | 'Hue', setter_callback=self.set_hue) 60 | self.char_saturation = serv_light.configure_char( 61 | 'Saturation', setter_callback=self.set_saturation) 62 | self.char_on = serv_light.configure_char( 63 | 'On', setter_callback=self.set_state) 64 | self.char_on = serv_light.configure_char( 65 | 'Brightness', setter_callback=self.set_brightness) 66 | 67 | # Set our instance variables 68 | self.accessory_state = 0 # State of the neo light On/Off 69 | self.hue = 0 # Hue Value 0 - 360 Homekit API 70 | self.saturation = 100 # Saturation Values 0 - 100 Homekit API 71 | self.brightness = 100 # Brightness value 0 - 100 Homekit API 72 | 73 | self.is_GRB = is_GRB # Most neopixels are Green Red Blue 74 | self.LED_count = LED_count 75 | 76 | self.neo_strip = Adafruit_NeoPixel(LED_count, LED_pin, LED_freq_hz, 77 | LED_DMA, LED_invert, LED_brightness) 78 | self.neo_strip.begin() 79 | 80 | def set_state(self, value): 81 | self.accessory_state = value 82 | if value == 1: # On 83 | self.set_hue(self.hue) 84 | else: 85 | self.update_neopixel_with_color(0, 0, 0) # Off 86 | 87 | def set_hue(self, value): 88 | # Lets only write the new RGB values if the power is on 89 | # otherwise update the hue value only 90 | if self.accessory_state == 1: 91 | self.hue = value 92 | rgb_tuple = self.hsv_to_rgb( 93 | self.hue, self.saturation, self.brightness) 94 | if len(rgb_tuple) == 3: 95 | self.update_neopixel_with_color( 96 | rgb_tuple[0], rgb_tuple[1], rgb_tuple[2]) 97 | else: 98 | self.hue = value 99 | 100 | def set_brightness(self, value): 101 | self.brightness = value 102 | self.set_hue(self.hue) 103 | 104 | def set_saturation(self, value): 105 | self.saturation = value 106 | self.set_hue(self.hue) 107 | 108 | def update_neopixel_with_color(self, red, green, blue): 109 | for i in range(self.LED_count): 110 | if(self.is_GRB): 111 | self.neo_strip.setPixelColor(i, Color(int(green), 112 | int(red), 113 | int(blue))) 114 | else: 115 | self.neo_strip.setPixelColor(i, Color(int(red), 116 | int(green), 117 | int(blue))) 118 | 119 | self.neo_strip.show() 120 | 121 | def hsv_to_rgb(self, h, s, v): 122 | """ 123 | This function takes 124 | h - 0 - 360 Deg 125 | s - 0 - 100 % 126 | v - 0 - 100 % 127 | """ 128 | 129 | hPri = h / 60 130 | s = s / 100 131 | v = v / 100 132 | 133 | if s <= 0.0: 134 | return int(0), int(0), int(0) 135 | 136 | C = v * s # Chroma 137 | X = C * (1 - abs(hPri % 2 - 1)) 138 | 139 | RGB_Pri = [0.0, 0.0, 0.0] 140 | 141 | if 0 <= hPri <= 1: 142 | RGB_Pri = [C, X, 0] 143 | elif 1 <= hPri <= 2: 144 | RGB_Pri = [X, C, 0] 145 | elif 2 <= hPri <= 3: 146 | RGB_Pri = [0, C, X] 147 | elif 3 <= hPri <= 4: 148 | RGB_Pri = [0, X, C] 149 | elif 4 <= hPri <= 5: 150 | RGB_Pri = [X, 0, C] 151 | elif 5 <= hPri <= 6: 152 | RGB_Pri = [C, 0, X] 153 | else: 154 | RGB_Pri = [0, 0, 0] 155 | 156 | m = v - C 157 | 158 | return int((RGB_Pri[0] + m) * 255), int((RGB_Pri[1] + m) * 255), int((RGB_Pri[2] + m) * 255) 159 | -------------------------------------------------------------------------------- /accessories/RPI_Relay.py: -------------------------------------------------------------------------------- 1 | # code for connecting relay to homebridge, losely based on the on/off screen function in this repo. 2 | # can also be used to control other GPIO based devices 3 | # Usage: 4 | # relay parameters: GPIO pin, timer, reverse on/off, starting state 5 | # bridge.add_accessory(RelaySwitch(38, 0, 0, 1, driver, 'Name', )) 6 | # can also be used with switch characteristic with minor changes. 7 | # feel free to improve 8 | 9 | def _gpio_setup(pin): 10 | if GPIO.getmode() is None: 11 | GPIO.setmode(GPIO.BOARD) 12 | GPIO.setup(pin, GPIO.OUT) 13 | 14 | 15 | def set_gpio_state(pin, state, reverse): 16 | if state: 17 | if reverse: 18 | GPIO.output(pin, 1) 19 | else: 20 | GPIO.output(pin, 0) 21 | else: 22 | if reverse: 23 | GPIO.output(pin, 0) 24 | else: 25 | GPIO.output(pin, 1) 26 | #logging.info("Setting pin: %s to state: %s", pin, state) 27 | 28 | 29 | def get_gpio_state(pin, reverse): 30 | if GPIO.input(pin): 31 | if reverse: 32 | return int(1) 33 | else: 34 | return int(0) 35 | else: 36 | if reverse: 37 | return int(0) 38 | else: 39 | return int(1) 40 | 41 | 42 | class RelaySwitch(Accessory): 43 | category = CATEGORY_OUTLET 44 | 45 | def __init__(self, pin_number, counter, reverse, startstate, *args, **kwargs): 46 | super().__init__(*args, **kwargs) 47 | 48 | self.pin_number = pin_number 49 | self.counter = counter 50 | self.reverse = reverse 51 | self.startstate = startstate 52 | 53 | _gpio_setup(self.pin_number) 54 | 55 | serv_switch = self.add_preload_service('Outlet') 56 | self.relay_on = serv_switch.configure_char( 57 | 'On', setter_callback=self.set_relay) 58 | 59 | self.relay_in_use = serv_switch.configure_char( 60 | 'OutletInUse', setter_callback=self.get_relay_in_use) 61 | 62 | self.timer = 1 63 | 64 | self.set_relay(startstate) 65 | 66 | @Accessory.run_at_interval(1) 67 | def run(self): 68 | state = get_gpio_state(self.pin_number, self.reverse) 69 | 70 | if self.relay_on.value != state: 71 | self.relay_on.value = state 72 | self.relay_on.notify() 73 | self.relay_in_use.notify() 74 | 75 | oldstate = 1 76 | 77 | if state != oldstate: 78 | self.timer = 1 79 | oldstate == state 80 | 81 | if self.timer == self.counter: 82 | set_gpio_state(self.pin_number, 0, self.reverse) 83 | self.timer = 1 84 | 85 | self.timer = self.timer + 1 86 | #logging.info("counter %s state is %s", self.timer, state) 87 | 88 | 89 | def set_relay(self, state): 90 | if get_gpio_state(self.pin_number, self.reverse) != state: 91 | if state: 92 | set_gpio_state(self.pin_number, 1, self.reverse) 93 | else: 94 | set_gpio_state(self.pin_number, 0, self.reverse) 95 | 96 | def get_relay_in_use(self, state): 97 | return True 98 | 99 | -------------------------------------------------------------------------------- /accessories/SDS011.py: -------------------------------------------------------------------------------- 1 | """ 2 | An Accessory wrapper for the SDS011 air particulate density sensor. 3 | 4 | The density sensor implementation can be found here: 5 | https://github.com/ikalchev/py-sds011 6 | Place the file under a package named sensors in your python path, 7 | or change the import altogether. 8 | """ 9 | import time 10 | import logging 11 | 12 | from sensors.SDS011 import SDS011 as AirSensor 13 | from pyhap.accessory import Accessory 14 | from pyhap.const import CATEGORY_SENSOR 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class SDS011(Accessory): 20 | """Accessory wrapper for SDS011. 21 | """ 22 | 23 | category = CATEGORY_SENSOR 24 | 25 | SORTED_PM_QUALITY_MAP = ((200, 5), (150, 4), (100, 3), (50, 2), (0, 1)) 26 | """ 27 | Threshold-to-state tuples. These show the state for which the threshold is 28 | lower boundary. Uses something like Air Quality Index (AQI). 29 | 30 | The UI shows: 31 | 1 - Excellent 32 | 2 - Good 33 | 3 - Fair 34 | 4 - Inferior 35 | 5 - Poor 36 | """ 37 | 38 | def __init__(self, serial_port, *args, sleep_duration_s=15*60, 39 | calib_duration_s=15, **kwargs): 40 | """Initialize and start SDS011 on the given port. 41 | 42 | @param serial_port: The SDS011 port, e.g. /dev/ttyUSB0. 43 | @type serial_port: str 44 | 45 | @param sleep_duration_s: The amount of time in seconds the sensor sleeps before 46 | waking it up for reading. Defaults to 15 minutes. 47 | @type sleep_duration_s: float 48 | 49 | @param calib_duration_s: The amount of time in seconds to wait after the sensor 50 | has been waken up before reading its data. Defaults to 15 seconds. 51 | @type calib_duration_s: float 52 | """ 53 | self.pm25_quality = None 54 | self.pm25_density = None 55 | self.pm10_quality = None 56 | self.pm10_density = None 57 | super().__init__(*args, **kwargs) 58 | 59 | # PM2.5 60 | air_quality_pm25 = self.add_preload_service( 61 | 'AirQualitySensor', chars=['Name', 'AirParticulateSize', 62 | 'AirParticulateDensity']) 63 | air_quality_pm25.configure_char('AirParticulateSize', value=0) 64 | air_quality_pm25.configure_char('Name', value='PM2.5') 65 | self.pm25_quality = air_quality_pm25.configure_char('AirQuality') 66 | self.pm25_density = air_quality_pm25.configure_char( 67 | 'AirParticulateDensity') 68 | 69 | # PM10 70 | air_quality_pm10 = self.add_preload_service( 71 | 'AirQualitySensor', chars=['Name', 'AirParticulateSize', 72 | 'AirParticulateDensity']) 73 | air_quality_pm10.configure_char('AirParticulateSize', value=1) 74 | air_quality_pm10.configure_char('Name', value='PM10') 75 | self.pm10_quality = air_quality_pm10.configure_char('AirQuality') 76 | self.pm10_density = air_quality_pm10.configure_char( 77 | 'AirParticulateDensity') 78 | 79 | self.sleep_duration_s = sleep_duration_s 80 | self.calib_duration_s = calib_duration_s 81 | self.serial_port = serial_port 82 | self.sensor = AirSensor(serial_port) 83 | self.sensor.sleep(sleep=False) 84 | 85 | def __getstate__(self): 86 | """Get the state, less the sensor. 87 | """ 88 | state = super(SDS011, self).__getstate__() 89 | state['sensor'] = None 90 | return state 91 | 92 | def __setstate__(self, state): 93 | """Set the state of this Accessory and initialize the sensor 94 | with the serial port in the state. 95 | """ 96 | self.__dict__.update(state) 97 | self.sensor = AirSensor(self.serial_port) 98 | 99 | def get_quality_classification(self, pm, is_pm25=False): 100 | """Get the air quality classification based on the PM density. 101 | 102 | Uses Air Quality Index (AQI), without averaging for an hour. 103 | 104 | @see: SDS011.SORTED_PM_QUALITY_MAP 105 | 106 | @rtype: int 107 | """ 108 | assert pm >= 0 109 | return next(state for threshold, state in self.SORTED_PM_QUALITY_MAP 110 | if threshold <= pm) 111 | 112 | def run(self): 113 | """Start updating the air quality readings. 114 | 115 | Initially, we read from the sensor and update the values. Then we put 116 | it in sleep mode and while the sentinel is not set: 117 | - Sleep for `self.sleep_duration_s`. 118 | - Wake up and wait `self.calib_duration_s` seconds. 119 | - Get the sensor's readings and update. 120 | """ 121 | pm25, pm10 = self.sensor.query() 122 | self.pm25_density.set_value(pm25) 123 | self.pm25_quality.set_value( 124 | self.get_quality_classification(pm25, is_pm25=True)) 125 | self.pm10_density.set_value(pm10) 126 | self.pm10_quality.set_value( 127 | self.get_quality_classification(pm10, is_pm25=False)) 128 | self.sensor.sleep() 129 | while not self.driver.stop_event.wait(self.sleep_duration_s): 130 | logger.debug("Waking up sensor.") 131 | self.sensor.sleep(sleep=False) 132 | time.sleep(self.calib_duration_s) 133 | pm25, pm10 = self.sensor.query() 134 | self.pm25_density.set_value(pm25) 135 | self.pm25_quality.set_value( 136 | self.get_quality_classification(pm25, is_pm25=True)) 137 | self.pm10_density.set_value(pm10) 138 | self.pm10_quality.set_value( 139 | self.get_quality_classification(pm10, is_pm25=False)) 140 | self.sensor.sleep() 141 | logger.debug("Read cycle done. Sleeping.") 142 | -------------------------------------------------------------------------------- /accessories/ShutdownSwitch.py: -------------------------------------------------------------------------------- 1 | """Provides a switch accessory that executes sudo shutdown -h. 2 | 3 | This allows you to halt a Raspberry Pi and then plug it off safely. 4 | 5 | NOTE: For this to work, you need to allow passwordless /sbin/shutdown to 6 | whichever user is running HAP-python. For example, you can do: 7 | $ sudo visudo 8 | $ # add the line "hap-user ALL=NOPASSWD: /sbin/shutdown" 9 | """ 10 | import os 11 | import logging 12 | 13 | from pyhap.accessory import Accessory 14 | from pyhap.const import CATEGORY_SWITCH 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class ShutdownSwitch(Accessory): 20 | """A switch accessory that executes sudo shutdown.""" 21 | 22 | category = CATEGORY_SWITCH 23 | 24 | def __init__(self, *args, **kwargs): 25 | """Initialize and set a shutdown callback to the On characteristic.""" 26 | super().__init__(*args, **kwargs) 27 | 28 | serv_switch = self.add_preload_service('Switch') 29 | self.char_on = serv_switch.configure_char( 30 | 'On', setter_callback=self.execute_shutdown) 31 | 32 | def execute_shutdown(self, _value): 33 | """Execute shutdown -h.""" 34 | logger.info("Executing shutdown command.") 35 | os.system("sudo shutdown -h now") 36 | -------------------------------------------------------------------------------- /accessories/TSL2591.py: -------------------------------------------------------------------------------- 1 | # An Accessory for the TSL2591 digital light sensor. 2 | # The TSL2591.py module was taken from https://github.com/maxlklaxl/python-tsl2591 3 | 4 | import tsl2591 5 | 6 | from pyhap.accessory import Accessory 7 | from pyhap.const import CATEGORY_SENSOR 8 | 9 | 10 | class TSL2591(Accessory): 11 | category = CATEGORY_SENSOR 12 | 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | serv_light = self.add_preload_service('LightSensor') 17 | self.char_lux = serv_light.configure_char('CurrentAmbientLightLevel') 18 | 19 | self.tsl = tsl2591.Tsl2591() 20 | 21 | def __getstate__(self): 22 | state = super().__getstate__() 23 | state["tsl"] = None 24 | return state 25 | 26 | def __setstate__(self, state): 27 | self.__dict__.update(state) 28 | self.tsl = tsl2591.Tsl2591() 29 | 30 | @Accessory.run_at_interval(10) 31 | def run(self): 32 | full, ir = self.tsl.get_full_luminosity() 33 | lux = min(max(0.001, self.tsl.calculate_lux(full, ir)), 10000) 34 | self.char_lux.set_value(lux) 35 | -------------------------------------------------------------------------------- /accessories/TV.py: -------------------------------------------------------------------------------- 1 | from pyhap.accessory import Accessory 2 | from pyhap.const import CATEGORY_TELEVISION 3 | 4 | 5 | class TV(Accessory): 6 | 7 | category = CATEGORY_TELEVISION 8 | 9 | NAME = 'Sample TV' 10 | SOURCES = { 11 | 'HDMI 1': 3, 12 | 'HDMI 2': 3, 13 | 'HDMI 3': 3, 14 | } 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(TV, self).__init__(*args, **kwargs) 18 | 19 | self.set_info_service( 20 | manufacturer='HaPK', 21 | model='Raspberry Pi', 22 | firmware_revision='1.0', 23 | serial_number='1' 24 | ) 25 | 26 | tv_service = self.add_preload_service( 27 | 'Television', ['Name', 28 | 'ConfiguredName', 29 | 'Active', 30 | 'ActiveIdentifier', 31 | 'RemoteKey', 32 | 'SleepDiscoveryMode'], 33 | ) 34 | self._active = tv_service.configure_char( 35 | 'Active', value=0, 36 | setter_callback=self._on_active_changed, 37 | ) 38 | tv_service.configure_char( 39 | 'ActiveIdentifier', value=1, 40 | setter_callback=self._on_active_identifier_changed, 41 | ) 42 | tv_service.configure_char( 43 | 'RemoteKey', setter_callback=self._on_remote_key, 44 | ) 45 | tv_service.configure_char('Name', value=self.NAME) 46 | # TODO: implement persistence for ConfiguredName 47 | tv_service.configure_char('ConfiguredName', value=self.NAME) 48 | tv_service.configure_char('SleepDiscoveryMode', value=1) 49 | 50 | for idx, (source_name, source_type) in enumerate(self.SOURCES.items()): 51 | input_source = self.add_preload_service('InputSource', ['Name', 'Identifier']) 52 | input_source.configure_char('Name', value=source_name) 53 | input_source.configure_char('Identifier', value=idx + 1) 54 | # TODO: implement persistence for ConfiguredName 55 | input_source.configure_char('ConfiguredName', value=source_name) 56 | input_source.configure_char('InputSourceType', value=source_type) 57 | input_source.configure_char('IsConfigured', value=1) 58 | input_source.configure_char('CurrentVisibilityState', value=0) 59 | 60 | tv_service.add_linked_service(input_source) 61 | 62 | tv_speaker_service = self.add_preload_service( 63 | 'TelevisionSpeaker', ['Active', 64 | 'VolumeControlType', 65 | 'VolumeSelector'] 66 | ) 67 | tv_speaker_service.configure_char('Active', value=1) 68 | # Set relative volume control 69 | tv_speaker_service.configure_char('VolumeControlType', value=1) 70 | tv_speaker_service.configure_char( 71 | 'Mute', setter_callback=self._on_mute, 72 | ) 73 | tv_speaker_service.configure_char( 74 | 'VolumeSelector', setter_callback=self._on_volume_selector, 75 | ) 76 | 77 | def _on_active_changed(self, value): 78 | print('Turn %s' % ('on' if value else 'off')) 79 | 80 | def _on_active_identifier_changed(self, value): 81 | print('Change input to %s' % list(self.SOURCES.keys())[value-1]) 82 | 83 | def _on_remote_key(self, value): 84 | print('Remote key %d pressed' % value) 85 | 86 | def _on_mute(self, value): 87 | print('Mute' if value else 'Unmute') 88 | 89 | def _on_volume_selector(self, value): 90 | print('%screase volume' % ('In' if value == 0 else 'De')) 91 | 92 | 93 | def main(): 94 | import logging 95 | import signal 96 | 97 | from pyhap.accessory_driver import AccessoryDriver 98 | 99 | logging.basicConfig(level=logging.INFO) 100 | 101 | driver = AccessoryDriver(port=51826) 102 | accessory = TV(driver, 'TV') 103 | driver.add_accessory(accessory=accessory) 104 | 105 | signal.signal(signal.SIGTERM, driver.signal_handler) 106 | driver.start() 107 | 108 | 109 | if __name__ == '__main__': 110 | main() 111 | -------------------------------------------------------------------------------- /accessories/TemperatureSensor.py: -------------------------------------------------------------------------------- 1 | # An Accessory mocking a temperature sensor. 2 | # It changes its value every few seconds. 3 | import random 4 | 5 | from pyhap.accessory import Accessory 6 | from pyhap.const import CATEGORY_SENSOR 7 | 8 | 9 | class TemperatureSensor(Accessory): 10 | 11 | category = CATEGORY_SENSOR 12 | 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | 16 | serv_temp = self.add_preload_service('TemperatureSensor') 17 | self.char_temp = serv_temp.configure_char('CurrentTemperature') 18 | 19 | @Accessory.run_at_interval(3) 20 | def run(self): 21 | self.char_temp.set_value(random.randint(18, 26)) 22 | -------------------------------------------------------------------------------- /adaptive_lightbulb.py: -------------------------------------------------------------------------------- 1 | """This virtual lightbulb implements the bare minimum needed for HomeKit 2 | controller to recognize it as having AdaptiveLightning 3 | """ 4 | import logging 5 | import signal 6 | import random 7 | import tlv8 8 | import base64 9 | 10 | from pyhap.accessory import Accessory 11 | from pyhap.accessory_driver import AccessoryDriver 12 | from pyhap.const import (CATEGORY_LIGHTBULB, 13 | HAP_REPR_IID) 14 | from pyhap.loader import get_loader 15 | 16 | # Define tlv8 Keys and Values 17 | SUPPORTED_TRANSITION_CONFIGURATION = 1 18 | CHARACTERISTIC_IID = 1 19 | TRANSITION_TYPE = 2 20 | 21 | BRIGHTNESS = 1 22 | COLOR_TEMPERATURE = 2 23 | 24 | logging.basicConfig(level=logging.DEBUG, format="[%(module)s] %(message)s") 25 | 26 | def bytes_to_base64_string(value: bytes) -> str: 27 | return base64.b64encode(value).decode('ASCII') 28 | 29 | class LightBulb(Accessory): 30 | """Fake lightbulb, logs what the client sets.""" 31 | 32 | category = CATEGORY_LIGHTBULB 33 | 34 | def __init__(self, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | 37 | serv_light = self.add_preload_service('Lightbulb', [ 38 | # The names here refer to the Characteristic name defined 39 | # in characteristic.json 40 | "Brightness", 41 | "ColorTemperature", 42 | "ActiveTransitionCount", 43 | "TransitionControl", 44 | "SupportedTransitionConfiguration"]) 45 | 46 | self.char_on = serv_light.configure_char( 47 | 'On', setter_callback=self.set_on) 48 | self.char_br = serv_light.configure_char( 49 | 'Brightness', setter_callback=self.set_brightness) 50 | self.char_ct = serv_light.configure_char( 51 | 'ColorTemperature', setter_callback=self.set_ct, value=140) 52 | 53 | # Via this structure we advertise to the controller that we are 54 | # capable of autonomous transitions between states on brightness 55 | # and color temperature. 56 | supported_transitions = [tlv8.Entry(SUPPORTED_TRANSITION_CONFIGURATION, [ 57 | tlv8.Entry(CHARACTERISTIC_IID, self.char_br.to_HAP()[HAP_REPR_IID]), 58 | tlv8.Entry(TRANSITION_TYPE, BRIGHTNESS), 59 | tlv8.Entry(CHARACTERISTIC_IID, self.char_ct.to_HAP()[HAP_REPR_IID]), 60 | tlv8.Entry(TRANSITION_TYPE, COLOR_TEMPERATURE) 61 | ])] 62 | 63 | bytes_data = tlv8.encode(supported_transitions) 64 | b64str = bytes_to_base64_string(bytes_data) 65 | 66 | self.char_atc = serv_light.configure_char( 67 | 'ActiveTransitionCount', setter_callback=self.set_atc) 68 | self.char_tc = serv_light.configure_char( 69 | 'TransitionControl', setter_callback=self.set_tc) 70 | self.char_stc = serv_light.configure_char( 71 | 'SupportedTransitionConfiguration', 72 | value=b64str) 73 | 74 | def set_on(self, value): 75 | logging.info("Write On State: %s", value) 76 | 77 | def set_ct(self, value): 78 | logging.info("Bulb color temp: %s", value) 79 | 80 | def set_atc(self, value): 81 | logging.info("Write to ActiveTransactionCount: %s", value) 82 | 83 | def set_tc(self, value): 84 | logging.info("Write to TransitionControl: %s", value) 85 | 86 | def set_brightness(self, value): 87 | logging.info("Bulb brightness: %s", value) 88 | 89 | driver = AccessoryDriver(port=51826, persist_file='adaptive_lightbulb.state') 90 | driver.add_accessory(accessory=LightBulb(driver, 'Lightbulb')) 91 | signal.signal(signal.SIGTERM, driver.signal_handler) 92 | driver.start() 93 | 94 | -------------------------------------------------------------------------------- /busy_home.py: -------------------------------------------------------------------------------- 1 | """Starts a fake fan, lightbulb, garage door and a TemperatureSensor 2 | """ 3 | import logging 4 | import signal 5 | import random 6 | 7 | from pyhap.accessory import Accessory, Bridge 8 | from pyhap.accessory_driver import AccessoryDriver 9 | from pyhap.const import (CATEGORY_FAN, 10 | CATEGORY_LIGHTBULB, 11 | CATEGORY_GARAGE_DOOR_OPENER, 12 | CATEGORY_SENSOR) 13 | 14 | 15 | logging.basicConfig(level=logging.INFO, format="[%(module)s] %(message)s") 16 | 17 | 18 | class TemperatureSensor(Accessory): 19 | """Fake Temperature sensor, measuring every 3 seconds.""" 20 | 21 | category = CATEGORY_SENSOR 22 | 23 | def __init__(self, *args, **kwargs): 24 | super().__init__(*args, **kwargs) 25 | 26 | serv_temp = self.add_preload_service('TemperatureSensor') 27 | self.char_temp = serv_temp.configure_char('CurrentTemperature') 28 | 29 | @Accessory.run_at_interval(3) 30 | async def run(self): 31 | self.char_temp.set_value(random.randint(18, 26)) 32 | 33 | 34 | class FakeFan(Accessory): 35 | """Fake Fan, only logs whatever the client set.""" 36 | 37 | category = CATEGORY_FAN 38 | 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(*args, **kwargs) 41 | 42 | # Add the fan service. Also add optional characteristics to it. 43 | serv_fan = self.add_preload_service( 44 | 'Fan', chars=['RotationSpeed', 'RotationDirection']) 45 | 46 | self.char_rotation_speed = serv_fan.configure_char( 47 | 'RotationSpeed', setter_callback=self.set_rotation_speed) 48 | self.char_rotation_direction = serv_fan.configure_char( 49 | 'RotationDirection', setter_callback=self.set_rotation_direction) 50 | 51 | def set_rotation_speed(self, value): 52 | logging.debug("Rotation speed changed: %s", value) 53 | 54 | def set_rotation_direction(self, value): 55 | logging.debug("Rotation direction changed: %s", value) 56 | 57 | class LightBulb(Accessory): 58 | """Fake lightbulb, logs what the client sets.""" 59 | 60 | category = CATEGORY_LIGHTBULB 61 | 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | 65 | serv_light = self.add_preload_service('Lightbulb') 66 | self.char_on = serv_light.configure_char( 67 | 'On', setter_callback=self.set_bulb) 68 | 69 | def set_bulb(self, value): 70 | logging.info("Bulb value: %s", value) 71 | 72 | class GarageDoor(Accessory): 73 | """Fake garage door.""" 74 | 75 | category = CATEGORY_GARAGE_DOOR_OPENER 76 | 77 | def __init__(self, *args, **kwargs): 78 | super().__init__(*args, **kwargs) 79 | 80 | self.add_preload_service('GarageDoorOpener')\ 81 | .configure_char( 82 | 'TargetDoorState', setter_callback=self.change_state) 83 | 84 | def change_state(self, value): 85 | logging.info("Bulb value: %s", value) 86 | self.get_service('GarageDoorOpener')\ 87 | .get_characteristic('CurrentDoorState')\ 88 | .set_value(value) 89 | 90 | def get_bridge(driver): 91 | bridge = Bridge(driver, 'Bridge') 92 | 93 | bridge.add_accessory(LightBulb(driver, 'Lightbulb')) 94 | bridge.add_accessory(FakeFan(driver, 'Big Fan')) 95 | bridge.add_accessory(GarageDoor(driver, 'Garage')) 96 | bridge.add_accessory(TemperatureSensor(driver, 'Sensor')) 97 | 98 | return bridge 99 | 100 | 101 | driver = AccessoryDriver(port=51826, persist_file='busy_home.state') 102 | driver.add_accessory(accessory=get_bridge(driver)) 103 | signal.signal(signal.SIGTERM, driver.signal_handler) 104 | driver.start() 105 | -------------------------------------------------------------------------------- /camera_main.py: -------------------------------------------------------------------------------- 1 | """An example of how to setup and start an Accessory. 2 | 3 | This is: 4 | 1. Create the Accessory object you want. 5 | 2. Add it to an AccessoryDriver, which will advertise it on the local network, 6 | setup a server to answer client queries, etc. 7 | """ 8 | import logging 9 | import signal 10 | 11 | from pyhap.accessory_driver import AccessoryDriver 12 | from pyhap import camera, util 13 | 14 | logging.basicConfig(level=logging.INFO, format="[%(module)s] %(message)s") 15 | 16 | 17 | # Specify the audio and video configuration that your device can support 18 | # The HAP client will choose from these when negotiating a session. 19 | options = { 20 | "video": { 21 | "codec": { 22 | "profiles": [ 23 | camera.VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"], 24 | camera.VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["MAIN"], 25 | camera.VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["HIGH"] 26 | ], 27 | "levels": [ 28 | camera.VIDEO_CODEC_PARAM_LEVEL_TYPES['TYPE3_1'], 29 | camera.VIDEO_CODEC_PARAM_LEVEL_TYPES['TYPE3_2'], 30 | camera.VIDEO_CODEC_PARAM_LEVEL_TYPES['TYPE4_0'], 31 | ], 32 | }, 33 | "resolutions": [ 34 | # Width, Height, framerate 35 | [320, 240, 15], # Required for Apple Watch 36 | [1024, 768, 30], 37 | [640, 480, 30], 38 | [640, 360, 30], 39 | [480, 360, 30], 40 | [480, 270, 30], 41 | [320, 240, 30], 42 | [320, 180, 30], 43 | ], 44 | }, 45 | "audio": { 46 | "codecs": [ 47 | { 48 | 'type': 'OPUS', 49 | 'samplerate': 24, 50 | }, 51 | { 52 | 'type': 'AAC-eld', 53 | 'samplerate': 16 54 | } 55 | ], 56 | }, 57 | "srtp": True, 58 | 59 | # hard code the address if auto-detection does not work as desired: e.g. "192.168.1.226" 60 | "address": util.get_local_address(), 61 | } 62 | 63 | 64 | # Start the accessory on port 51826 65 | driver = AccessoryDriver(port=51826) 66 | acc = camera.Camera(options, driver, "Camera") 67 | driver.add_accessory(accessory=acc) 68 | 69 | # We want KeyboardInterrupts and SIGTERM (terminate) to be handled by the driver itself, 70 | # so that it can gracefully stop the accessory, server and advertising. 71 | signal.signal(signal.SIGTERM, driver.signal_handler) 72 | # Start it! 73 | driver.start() 74 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "header, diff, tree" 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = HAP-python 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/README.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ========================================== 4 | HAP-python documentation quick start guide 5 | ========================================== 6 | 7 | This file provides a quick guide on how to compile the HAP-python documentation. 8 | 9 | 10 | Setup the environment 11 | --------------------- 12 | 13 | To compile the documentation you need Sphinx Python library. To install it 14 | and all its dependencies run the following command from this dir 15 | 16 | :: 17 | 18 | pip install -r requirements.txt 19 | 20 | 21 | Compile the documentation 22 | ------------------------- 23 | 24 | To compile the documentation (to classic HTML output) run the following command 25 | from this dir:: 26 | 27 | make html 28 | 29 | Documentation will be generated (in HTML format) inside the ``build/html`` dir. 30 | 31 | 32 | View the documentation 33 | ---------------------- 34 | 35 | To view the documentation run the following command:: 36 | 37 | make htmlview 38 | 39 | This command will fire up your default browser and open the main page of your 40 | (previously generated) HTML documentation. 41 | 42 | 43 | Start over 44 | ---------- 45 | 46 | To cleanup all generated documentation files and start from scratch run:: 47 | 48 | make clean 49 | 50 | Keep in mind that this command won't touch any documentation source files. 51 | 52 | 53 | Recreating documentation on the fly 54 | ----------------------------------- 55 | 56 | There is a way to recreate the doc automatically when you make changes, you 57 | need to install watchdog (``pip install watchdog``) and then use:: 58 | 59 | make watch 60 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=HAP-python 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/api/accessory.rst: -------------------------------------------------------------------------------- 1 | .. _api-accessory: 2 | 3 | ========= 4 | Accessory 5 | ========= 6 | 7 | Base class for HAP Accessories. 8 | 9 | .. autoclass:: pyhap.accessory.Accessory 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/api/accessory_driver.rst: -------------------------------------------------------------------------------- 1 | .. _api-accessory_driver: 2 | 3 | =============== 4 | AccessoryDriver 5 | =============== 6 | 7 | Accessory Driver class to host an Accessory. 8 | 9 | .. autoclass:: pyhap.accessory_driver.AccessoryDriver 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/api/bridge.rst: -------------------------------------------------------------------------------- 1 | .. _api-bridge: 2 | 3 | ====== 4 | Bridge 5 | ====== 6 | 7 | Bridge Class to host multiple HAP Accessories. 8 | 9 | .. autoclass:: pyhap.accessory.Bridge 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/api/characteristic.rst: -------------------------------------------------------------------------------- 1 | .. _api-characteristic: 2 | 3 | ============== 4 | Characteristic 5 | ============== 6 | 7 | Characteristic Base class for a HAP Accessory ``Service``. 8 | 9 | .. seealso:: pyhap.service.Service 10 | 11 | .. autoclass:: pyhap.characteristic.Characteristic 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/source/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api-index: 2 | 3 | ========= 4 | API Index 5 | ========= 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :glob: 10 | 11 | * 12 | -------------------------------------------------------------------------------- /docs/source/api/loader.rst: -------------------------------------------------------------------------------- 1 | .. _api-loader: 2 | 3 | ====== 4 | Loader 5 | ====== 6 | 7 | Useful for creating a ``Service`` or ``Characteristic``. 8 | 9 | .. autoclass:: pyhap.loader.Loader 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/api/service.rst: -------------------------------------------------------------------------------- 1 | .. _api-service: 2 | 3 | ======= 4 | Service 5 | ======= 6 | 7 | Service Base class for a HAP ``Accessory``. 8 | 9 | .. autoclass:: pyhap.service.Service 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/source/api/state.rst: -------------------------------------------------------------------------------- 1 | .. _api-state: 2 | 3 | ===== 4 | State 5 | ===== 6 | 7 | .. autoclass:: pyhap.state.State 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/source/api/util.rst: -------------------------------------------------------------------------------- 1 | .. _api-util: 2 | 3 | ==== 4 | Util 5 | ==== 6 | 7 | Utilities Module 8 | 9 | .. automodule:: pyhap.util 10 | :members: 11 | :undoc-members: 12 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../../')) 18 | from pyhap.const import __version__ 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'HAP-python' 24 | copyright = '2018, Ivan Kalchev' 25 | author = 'Ivan Kalchev' 26 | 27 | # The short X.Y version 28 | version = '' 29 | # The full version, including alpha/beta/rc tags 30 | release = '.'.join(map(str, __version__)) 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | 'sphinx.ext.autodoc', 'sphinx.ext.viewcode' 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | #language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path . 68 | exclude_patterns = ['.build'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = 'sphinx' 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'sphinx_rtd_theme' 80 | 81 | # Theme options are theme-specific and customize the look and feel of a theme 82 | # further. For a list of options available for each theme, see the 83 | # documentation. 84 | # 85 | # html_theme_options = {} 86 | 87 | # Add any paths that contain custom static files (such as style sheets) here, 88 | # relative to this directory. They are copied after the builtin static files, 89 | # so a file named "default.css" will overwrite the builtin "default.css". 90 | import sphinx_rtd_theme 91 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 92 | 93 | # Custom sidebar templates, must be a dictionary that maps document names 94 | # to template names. 95 | # 96 | # The default sidebars (for documents that don't match any pattern) are 97 | # defined by theme itself. Builtin themes are using these templates by 98 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 99 | # 'searchbox.html']``. 100 | # 101 | # html_sidebars = {} 102 | 103 | 104 | # -- Options for HTMLHelp output --------------------------------------------- 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'HAP-pythondoc' 108 | 109 | 110 | # -- Options for LaTeX output ------------------------------------------------ 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'HAP-python.tex', 'HAP-python Documentation', 135 | 'Ivan Kalchev', 'manual'), 136 | ] 137 | 138 | 139 | # -- Options for manual page output ------------------------------------------ 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [ 144 | (master_doc, 'hap-python', 'HAP-python Documentation', 145 | [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ---------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | (master_doc, 'HAP-python', 'HAP-python Documentation', 156 | author, 'HAP-python', 'One line description of project.', 157 | 'Miscellaneous'), 158 | ] 159 | 160 | 161 | # -- Extension configuration ------------------------------------------------- 162 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | Welcome to HAP-python's documentation! 3 | ====================================== 4 | 5 | This documentation contains everything you need to know about 6 | HAP-python. 7 | 8 | Getting Help 9 | ============ 10 | 11 | Having Trouble? Post an issue on the GitHub repo, with as much 12 | information as possible about your issue. 13 | 14 | .. _issue tracker: https://github.com/ikalchev/HAP-python/issues 15 | 16 | 17 | First Steps 18 | =========== 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | :hidden: 23 | 24 | intro/overview 25 | intro/install 26 | intro/tutorial 27 | intro/examples 28 | 29 | :doc:`intro/overview` 30 | Brief explanation of HAP-python, and the possible use cases. 31 | 32 | :doc:`intro/install` 33 | How to install HAP-python on a Debian based system, such as 34 | a Raspberry Pi, or Ubuntu. 35 | 36 | :doc:`intro/tutorial` 37 | Get started building your first HomeKit Accessory. 38 | 39 | :doc:`intro/examples` 40 | A set of prebuilt accessories to either build your own class 41 | around, or to use as a starting point into your own custom 42 | Accessory class. 43 | 44 | 45 | API Reference 46 | ============= 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | :hidden: 51 | 52 | api/index 53 | 54 | :doc:`api/index` 55 | API documentation for HAP-python. 56 | -------------------------------------------------------------------------------- /docs/source/intro/examples.rst: -------------------------------------------------------------------------------- 1 | .. _intro-examples: 2 | 3 | ======== 4 | Examples 5 | ======== 6 | 7 | Need to fill in 8 | -------------------------------------------------------------------------------- /docs/source/intro/install.rst: -------------------------------------------------------------------------------- 1 | .. _intro-install: 2 | 3 | ================== 4 | Installation Guide 5 | ================== 6 | 7 | Before We Begin 8 | =============== 9 | 10 | HAP-python requires Python 3.4+. 11 | This guide will cover the current version of Raspbian and Ubuntu LTS. 12 | It is somewhat safe to assume the process for newer versions of Ubuntu 13 | will work. 14 | 15 | 16 | Installing Pre-Requisites 17 | ========================= 18 | 19 | Raspbian Stretch 20 | ---------------- 21 | 22 | As a prerequisite, you will need Avahi/Bonjour installed (due to ``zeroconf`` package):: 23 | 24 | sudo apt install libavahi-compat-libdnssd-dev 25 | 26 | 27 | Ubuntu 16.04 LTS 28 | ---------------- 29 | 30 | Same with Raspbian, we will need to install Avahi/Bonjour, but a fresh 16.04 install will 31 | require the ``python3-dev`` package as well:: 32 | 33 | sudo apt install libavahi-compat-libdnssd-dev python3-dev 34 | 35 | 36 | Installing HAP-python 37 | ===================== 38 | 39 | Make a directory for your project, and ``cd`` into it:: 40 | 41 | ~ $ mkdir hk_project 42 | ~ $ cd hk_project 43 | ~/hk_project $ 44 | 45 | It is best to use a virtualenv for most Python projects, we can use one here as well. 46 | Make sure that you have the ``venv`` module installed for Python 3:: 47 | 48 | sudo apt install python3-venv 49 | 50 | To create a virtualenv and activate it, simply run these commands inside your project 51 | directory:: 52 | 53 | python3 -m venv venv 54 | source venv/bin/activate 55 | 56 | Because we used a Python 3 virtualenv and activated it, we can install ``HAP-python`` 57 | with ``pip``:: 58 | 59 | pip install HAP-python 60 | -------------------------------------------------------------------------------- /docs/source/intro/overview.rst: -------------------------------------------------------------------------------- 1 | .. _intro-overview: 2 | 3 | ====================== 4 | HAP-python at a glance 5 | ====================== 6 | 7 | HAP-python is an application framework to build out Accessories or Bridges 8 | for Apple's HomeKit protocol. 9 | 10 | Need to fill in the rest. 11 | -------------------------------------------------------------------------------- /docs/source/intro/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _intro-tutorial: 2 | 3 | ========= 4 | Tutorials 5 | ========= 6 | 7 | Need to fill in 8 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """An example of how to setup and start an Accessory. 2 | 3 | This is: 4 | 1. Create the Accessory object you want. 5 | 2. Add it to an AccessoryDriver, which will advertise it on the local network, 6 | setup a server to answer client queries, etc. 7 | """ 8 | import logging 9 | import signal 10 | import random 11 | 12 | from pyhap.accessory import Accessory, Bridge 13 | from pyhap.accessory_driver import AccessoryDriver 14 | import pyhap.loader as loader 15 | from pyhap import camera 16 | from pyhap.const import CATEGORY_SENSOR 17 | 18 | logging.basicConfig(level=logging.INFO, format="[%(module)s] %(message)s") 19 | 20 | 21 | class TemperatureSensor(Accessory): 22 | """Fake Temperature sensor, measuring every 3 seconds.""" 23 | 24 | category = CATEGORY_SENSOR 25 | 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, **kwargs) 28 | 29 | serv_temp = self.add_preload_service('TemperatureSensor') 30 | self.char_temp = serv_temp.configure_char('CurrentTemperature') 31 | 32 | @Accessory.run_at_interval(3) 33 | async def run(self): 34 | self.char_temp.set_value(random.randint(18, 26)) 35 | 36 | 37 | def get_bridge(driver): 38 | """Call this method to get a Bridge instead of a standalone accessory.""" 39 | bridge = Bridge(driver, 'Bridge') 40 | temp_sensor = TemperatureSensor(driver, 'Sensor 2') 41 | temp_sensor2 = TemperatureSensor(driver, 'Sensor 1') 42 | bridge.add_accessory(temp_sensor) 43 | bridge.add_accessory(temp_sensor2) 44 | 45 | return bridge 46 | 47 | 48 | def get_accessory(driver): 49 | """Call this method to get a standalone Accessory.""" 50 | return TemperatureSensor(driver, 'MyTempSensor') 51 | 52 | 53 | # Start the accessory on port 51826 54 | driver = AccessoryDriver(port=51826) 55 | 56 | # Change `get_accessory` to `get_bridge` if you want to run a Bridge. 57 | driver.add_accessory(accessory=get_accessory(driver)) 58 | 59 | # We want SIGTERM (terminate) to be handled by the driver itself, 60 | # so that it can gracefully stop the accessory, server and advertising. 61 | signal.signal(signal.SIGTERM, driver.signal_handler) 62 | 63 | # Start it! 64 | driver.start() 65 | -------------------------------------------------------------------------------- /pyhap/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ROOT = os.path.abspath(os.path.dirname(__file__)) 4 | RESOURCE_DIR = os.path.join(ROOT, "resources") 5 | 6 | CHARACTERISTICS_FILE = os.path.join(RESOURCE_DIR, "characteristics.json") 7 | SERVICES_FILE = os.path.join(RESOURCE_DIR, "services.json") 8 | 9 | 10 | # Flag if QR Code dependencies are installed. 11 | # Installation with `pip install HAP-python[QRCode]`. 12 | SUPPORT_QR_CODE = False 13 | try: 14 | import base36 # noqa: F401 15 | import pyqrcode # noqa: F401 16 | 17 | SUPPORT_QR_CODE = True 18 | except ImportError: 19 | pass 20 | -------------------------------------------------------------------------------- /pyhap/accessories/README.md: -------------------------------------------------------------------------------- 1 | # Adding accessories as HAP-python subpackages 2 | 3 | If you have implemented an Accessory for a device for HAP-python 4 | and want to share it with others as a subpackage, you can do so using native namespace 5 | packages. Just do the following: 6 | 7 | - Make sure you have the following directory structure: 8 | ``` 9 | pyhap/ 10 | # NO __init__.py here !!! 11 | accessories/ 12 | # NO __init__.py here !!! 13 | bulb/ 14 | __init__.py 15 | ... the code for the bulb accessory ... 16 | ``` 17 | - Have this in your `setup.py`: 18 | ```python 19 | setup( 20 | ... 21 | packages=['pyhap.accessories.bulb'], 22 | ... 23 | ) 24 | ``` 25 | 26 | If you upload your package to pip, other users can use your code as 27 | `pip install HAP-python-bulb` or, alternatively, they can just `git clone` and do 28 | `python3 setup.py install`. Others can then use your code by doing: 29 | ``` 30 | import pyhap.accessories.bulb 31 | ``` 32 | 33 | See [here](https://packaging.python.org/guides/packaging-namespace-packages/#native-namespace-packages) 34 | for more information. 35 | -------------------------------------------------------------------------------- /pyhap/const.py: -------------------------------------------------------------------------------- 1 | """This module contains constants used by other modules.""" 2 | MAJOR_VERSION = 4 3 | MINOR_VERSION = 9 4 | PATCH_VERSION = 2 5 | __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" 6 | __version__ = f"{__short_version__}.{PATCH_VERSION}" 7 | REQUIRED_PYTHON_VER = (3, 7) 8 | 9 | BASE_UUID = "-0000-1000-8000-0026BB765291" 10 | 11 | # ### Misc ### 12 | STANDALONE_AID = 1 # Standalone accessory ID (i.e. not bridged) 13 | 14 | # ### Default values ### 15 | DEFAULT_CONFIG_VERSION = 1 16 | DEFAULT_PORT = 51827 17 | 18 | # ### Configuration version ### 19 | MAX_CONFIG_VERSION = 65535 20 | 21 | # ### CATEGORY values ### 22 | # Category is a hint to iOS clients about what "type" of Accessory this 23 | # represents, for UI only. 24 | CATEGORY_OTHER = 1 25 | CATEGORY_BRIDGE = 2 26 | CATEGORY_FAN = 3 27 | CATEGORY_GARAGE_DOOR_OPENER = 4 28 | CATEGORY_LIGHTBULB = 5 29 | CATEGORY_DOOR_LOCK = 6 30 | CATEGORY_OUTLET = 7 31 | CATEGORY_SWITCH = 8 32 | CATEGORY_THERMOSTAT = 9 33 | CATEGORY_SENSOR = 10 34 | CATEGORY_ALARM_SYSTEM = 11 35 | CATEGORY_DOOR = 12 36 | CATEGORY_WINDOW = 13 37 | CATEGORY_WINDOW_COVERING = 14 38 | CATEGORY_PROGRAMMABLE_SWITCH = 15 39 | CATEGORY_RANGE_EXTENDER = 16 40 | CATEGORY_CAMERA = 17 41 | CATEGORY_VIDEO_DOOR_BELL = 18 42 | CATEGORY_AIR_PURIFIER = 19 43 | CATEGORY_HEATER = 20 44 | CATEGORY_AIR_CONDITIONER = 21 45 | CATEGORY_HUMIDIFIER = 22 46 | CATEGORY_DEHUMIDIFIER = 23 47 | CATEGORY_SPEAKER = 26 48 | CATEGORY_SPRINKLER = 28 49 | CATEGORY_FAUCET = 29 50 | CATEGORY_SHOWER_HEAD = 30 51 | CATEGORY_TELEVISION = 31 52 | CATEGORY_TARGET_CONTROLLER = 32 # Remote Controller 53 | 54 | 55 | # ### HAP Permissions ### 56 | HAP_PERMISSION_HIDDEN = "hd" 57 | HAP_PERMISSION_NOTIFY = "ev" 58 | HAP_PERMISSION_READ = "pr" 59 | HAP_PERMISSION_WRITE = "pw" 60 | HAP_PERMISSION_WRITE_RESPONSE = "wr" 61 | 62 | 63 | # ### HAP representation ### 64 | HAP_REPR_ACCS = "accessories" 65 | HAP_REPR_AID = "aid" 66 | HAP_REPR_CHARS = "characteristics" 67 | HAP_REPR_DESC = "description" 68 | HAP_REPR_FORMAT = "format" 69 | HAP_REPR_IID = "iid" 70 | HAP_REPR_MAX_LEN = "maxLen" 71 | HAP_REPR_PERM = "perms" 72 | HAP_REPR_PID = "pid" 73 | HAP_REPR_PRIMARY = "primary" 74 | HAP_REPR_SERVICES = "services" 75 | HAP_REPR_LINKED = "linked" 76 | HAP_REPR_STATUS = "status" 77 | HAP_REPR_TTL = "ttl" 78 | HAP_REPR_TYPE = "type" 79 | HAP_REPR_VALUE = "value" 80 | HAP_REPR_VALID_VALUES = "valid-values" 81 | HAP_REPR_WRITE_RESPONSE = "r" 82 | 83 | HAP_PROTOCOL_VERSION = "01.01.00" 84 | HAP_PROTOCOL_SHORT_VERSION = "1.1" 85 | 86 | 87 | # Status codes for underlying HAP calls 88 | class HAP_SERVER_STATUS: 89 | SUCCESS = 0 90 | INSUFFICIENT_PRIVILEGES = -70401 91 | SERVICE_COMMUNICATION_FAILURE = -70402 92 | RESOURCE_BUSY = -70403 93 | READ_ONLY_CHARACTERISTIC = -70404 94 | WRITE_ONLY_CHARACTERISTIC = -70405 95 | NOTIFICATION_NOT_SUPPORTED = -70406 96 | OUT_OF_RESOURCE = -70407 97 | OPERATION_TIMED_OUT = -70408 98 | RESOURCE_DOES_NOT_EXIST = -70409 99 | INVALID_VALUE_IN_REQUEST = -70410 100 | INSUFFICIENT_AUTHORIZATION = -70411 101 | 102 | 103 | class HAP_PERMISSIONS: 104 | USER = b"\x00" 105 | ADMIN = b"\x01" 106 | 107 | 108 | # Client properties 109 | CLIENT_PROP_PERMS = "permissions" 110 | -------------------------------------------------------------------------------- /pyhap/encoder.py: -------------------------------------------------------------------------------- 1 | """This module contains various Accessory encoders. 2 | 3 | These are used to persist and load the state of the Accessory, so that 4 | it can work properly after a restart. 5 | """ 6 | import json 7 | import uuid 8 | 9 | from cryptography.hazmat.primitives import serialization 10 | from cryptography.hazmat.primitives.asymmetric import ed25519 11 | 12 | from .const import CLIENT_PROP_PERMS 13 | from .state import State 14 | 15 | 16 | class AccessoryEncoder: 17 | """This class defines the Accessory encoder interface. 18 | 19 | The AccessoryEncoder is used by the AccessoryDriver to persist and restore the 20 | state of an Accessory between restarts. This is needed in order to allow iOS 21 | clients to see the same MAC, public key, etc. of the Accessory they paired with, thus 22 | allowing an Accessory to "be remembered". 23 | 24 | The idea is: 25 | - The Accessory(ies) is created and added to an AccessoryDriver. 26 | - The AccessoryDriver checks if a given file, containing the Accessory's state 27 | exists. If so, it loads the state into the Accessory. Otherwise, it 28 | creates the file and persists the state of the Accessory. 29 | - On every change of the accessory - config change, new (un)paired clients, 30 | the state is updated. 31 | 32 | You can implement your own encoding logic, but the minimum set of properties that 33 | must be persisted are: 34 | - Public and private keys. 35 | - UUID and public key of all paired clients. 36 | - MAC address. 37 | - Config version - ok, this is debatable, but it retains the consistency. 38 | - Accessories Hash 39 | 40 | The default implementation persists the above properties. 41 | 42 | Note also that AIDs and IIDs must also survive a restore. However, this is managed 43 | by the Accessory and Bridge classes. 44 | 45 | @see: AccessoryDriver.persist AccessoryDriver.load AccessoryDriver.__init__ 46 | """ 47 | 48 | @staticmethod 49 | def persist(fp, state: State): 50 | """Persist the state of the given Accessory to the given file object. 51 | 52 | Persists: 53 | - MAC address. 54 | - Public and private key. 55 | - UUID and public key of paired clients. 56 | - Config version. 57 | - Accessories Hash 58 | """ 59 | paired_clients = { 60 | str(client): bytes.hex(key) for client, key in state.paired_clients.items() 61 | } 62 | client_properties = { 63 | str(client): props for client, props in state.client_properties.items() 64 | } 65 | client_uuid_to_bytes = { 66 | str(client): bytes.hex(key) for client, key in state.uuid_to_bytes.items() 67 | } 68 | config_state = { 69 | "mac": state.mac, 70 | "config_version": state.config_version, 71 | "paired_clients": paired_clients, 72 | "client_properties": client_properties, 73 | "accessories_hash": state.accessories_hash, 74 | "client_uuid_to_bytes": client_uuid_to_bytes, 75 | "private_key": bytes.hex( 76 | state.private_key.private_bytes( 77 | encoding=serialization.Encoding.Raw, 78 | format=serialization.PrivateFormat.Raw, 79 | encryption_algorithm=serialization.NoEncryption(), 80 | ) 81 | ), 82 | "public_key": bytes.hex( 83 | state.public_key.public_bytes( 84 | encoding=serialization.Encoding.Raw, 85 | format=serialization.PublicFormat.Raw, 86 | ) 87 | ), 88 | } 89 | json.dump(config_state, fp) 90 | 91 | @staticmethod 92 | def load_into(fp, state: State) -> None: 93 | """Load the accessory state from the given file object into the given Accessory. 94 | 95 | @see: AccessoryEncoder.persist 96 | """ 97 | loaded = json.load(fp) 98 | state.mac = loaded["mac"] 99 | state.accessories_hash = loaded.get("accessories_hash") 100 | state.config_version = loaded["config_version"] 101 | if "client_properties" in loaded: 102 | state.client_properties = { 103 | uuid.UUID(client): props 104 | for client, props in loaded["client_properties"].items() 105 | } 106 | else: 107 | # If "client_properties" does not exist, everyone 108 | # before that was paired as an admin 109 | state.client_properties = { 110 | uuid.UUID(client): {CLIENT_PROP_PERMS: 1} 111 | for client in loaded["paired_clients"] 112 | } 113 | state.paired_clients = { 114 | uuid.UUID(client): bytes.fromhex(key) 115 | for client, key in loaded["paired_clients"].items() 116 | } 117 | state.private_key = ed25519.Ed25519PrivateKey.from_private_bytes( 118 | bytes.fromhex(loaded["private_key"]) 119 | ) 120 | state.public_key = ed25519.Ed25519PublicKey.from_public_bytes( 121 | bytes.fromhex(loaded["public_key"]) 122 | ) 123 | state.uuid_to_bytes = { 124 | uuid.UUID(client): bytes.fromhex(key) 125 | for client, key in loaded.get("client_uuid_to_bytes", {}).items() 126 | } 127 | -------------------------------------------------------------------------------- /pyhap/hap_crypto.py: -------------------------------------------------------------------------------- 1 | """This module partially implements crypto for HAP.""" 2 | from functools import partial 3 | import logging 4 | import struct 5 | from struct import Struct 6 | from typing import Iterable, List 7 | 8 | from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 9 | from cryptography.hazmat.backends import default_backend 10 | from cryptography.hazmat.primitives import hashes 11 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | CRYPTO_BACKEND = default_backend() 16 | 17 | PACK_NONCE = partial(Struct(" None: 58 | self._out_count = 0 59 | self._in_count = 0 60 | self._crypt_in_buffer = bytearray() # Encrypted buffer 61 | self.reset(shared_key) 62 | 63 | def reset(self, shared_key): 64 | """Setup the ciphers.""" 65 | self._out_cipher = ChaCha20Poly1305( 66 | hap_hkdf(shared_key, self.CIPHER_SALT, self.OUT_CIPHER_INFO) 67 | ) 68 | self._in_cipher = ChaCha20Poly1305( 69 | hap_hkdf(shared_key, self.CIPHER_SALT, self.IN_CIPHER_INFO) 70 | ) 71 | 72 | def receive_data(self, buffer: bytes) -> None: 73 | """Receive data into the encrypted buffer.""" 74 | self._crypt_in_buffer += buffer 75 | 76 | def decrypt(self) -> bytes: 77 | """Decrypt and return any complete blocks in the buffer as plaintext 78 | 79 | The received full cipher blocks are decrypted and returned and partial cipher 80 | blocks are buffered locally. 81 | """ 82 | result = b"" 83 | crypt_in_buffer = self._crypt_in_buffer 84 | length_length = self.LENGTH_LENGTH 85 | tag_length = HAP_CRYPTO.TAG_LENGTH 86 | 87 | while len(crypt_in_buffer) > self.MIN_BLOCK_LENGTH: 88 | block_length_bytes = crypt_in_buffer[:length_length] 89 | block_size = struct.unpack("H", block_length_bytes)[0] 90 | block_size_with_length = length_length + block_size + tag_length 91 | 92 | if len(crypt_in_buffer) < block_size_with_length: 93 | logger.debug("Incoming buffer does not have the full block") 94 | return result 95 | 96 | # Trim off the length 97 | del crypt_in_buffer[:length_length] 98 | 99 | data_size = block_size + tag_length 100 | nonce = PACK_NONCE(self._in_count) 101 | 102 | result += self._in_cipher.decrypt( 103 | nonce, 104 | bytes(crypt_in_buffer[:data_size]), 105 | bytes(block_length_bytes), 106 | ) 107 | 108 | self._in_count += 1 109 | 110 | # Now trim out the decrypted data 111 | del crypt_in_buffer[:data_size] 112 | 113 | return result 114 | 115 | def encrypt(self, data: bytes) -> Iterable[bytes]: 116 | """Encrypt and send the return bytes.""" 117 | result: List[bytes] = [] 118 | offset = 0 119 | total = len(data) 120 | while offset < total: 121 | length = min(total - offset, self.MAX_BLOCK_LENGTH) 122 | length_bytes = PACK_LENGTH(length) 123 | block = bytes(data[offset : offset + length]) 124 | nonce = PACK_NONCE(self._out_count) 125 | result.append(length_bytes) 126 | result.append(self._out_cipher.encrypt(nonce, block, length_bytes)) 127 | offset += length 128 | self._out_count += 1 129 | 130 | return result 131 | -------------------------------------------------------------------------------- /pyhap/hap_event.py: -------------------------------------------------------------------------------- 1 | """This module implements the HAP events.""" 2 | 3 | from typing import Any, Dict 4 | 5 | from .const import HAP_REPR_CHARS 6 | from .util import to_hap_json 7 | 8 | EVENT_MSG_STUB = ( 9 | b"EVENT/1.0 200 OK\r\n" 10 | b"Content-Type: application/hap+json\r\n" 11 | b"Content-Length: " 12 | ) 13 | 14 | 15 | def create_hap_event(data: Dict[str, Any]) -> bytes: 16 | """Creates a HAP HTTP EVENT response for the given data. 17 | 18 | @param data: Payload of the request. 19 | @type data: bytes 20 | """ 21 | bytesdata = to_hap_json({HAP_REPR_CHARS: data}) 22 | return b"".join( 23 | (EVENT_MSG_STUB, str(len(bytesdata)).encode("utf-8"), b"\r\n" * 2, bytesdata) 24 | ) 25 | -------------------------------------------------------------------------------- /pyhap/hap_protocol.py: -------------------------------------------------------------------------------- 1 | """This module implements the communication of HAP. 2 | 3 | The HAPServerProtocol is a protocol implementation that manages the "TLS" of the connection. 4 | """ 5 | import asyncio 6 | import logging 7 | import time 8 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple 9 | 10 | from cryptography.exceptions import InvalidTag 11 | import h11 12 | 13 | from pyhap.accessory import get_topic 14 | from pyhap.const import HAP_REPR_AID, HAP_REPR_IID 15 | 16 | from .hap_crypto import HAPCrypto 17 | from .hap_event import create_hap_event 18 | from .hap_handler import HAPResponse, HAPServerHandler 19 | from .util import async_create_background_task 20 | 21 | if TYPE_CHECKING: 22 | from .accessory_driver import AccessoryDriver 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | HIGH_WRITE_BUFFER_SIZE = 2**19 27 | # We timeout idle connections after 90 hours as we must 28 | # clean up unused sockets periodically. 90 hours was choosen 29 | # as its the longest time we expect a user to be away from 30 | # their phone or device before they have to resync when they 31 | # reopen homekit. 32 | IDLE_CONNECTION_TIMEOUT_SECONDS = 90 * 60 * 60 33 | 34 | EVENT_COALESCE_TIME_WINDOW = 0.5 35 | 36 | H11_END_OF_MESSAGE = h11.EndOfMessage() 37 | H11_CONNECTION_CLOSED = h11.ConnectionClosed() 38 | 39 | 40 | class HAPServerProtocol(asyncio.Protocol): 41 | """A asyncio.Protocol implementing the HAP protocol.""" 42 | 43 | def __init__( 44 | self, 45 | loop: asyncio.AbstractEventLoop, 46 | connections: Dict[str, "HAPServerProtocol"], 47 | accessory_driver: "AccessoryDriver", 48 | ) -> None: 49 | self.loop = loop 50 | self.conn = h11.Connection(h11.SERVER) 51 | self.connections = connections 52 | self.accessory_driver = accessory_driver 53 | self.handler: Optional[HAPServerHandler] = None 54 | self.peername: Optional[str] = None 55 | self.transport: Optional[asyncio.Transport] = None 56 | 57 | self.request: Optional[h11.Request] = None 58 | self.request_body: List[bytes] = [] 59 | self.response: Optional[HAPResponse] = None 60 | 61 | self.last_activity: Optional[float] = None 62 | self.hap_crypto: Optional[HAPCrypto] = None 63 | self._event_timer: Optional[asyncio.TimerHandle] = None 64 | self._event_queue: Dict[Tuple[int, int], Dict[str, Any]] = {} 65 | 66 | def connection_lost(self, exc: Exception) -> None: 67 | """Handle connection lost.""" 68 | logger.debug( 69 | "%s (%s): Connection lost to %s: %s", 70 | self.peername, 71 | self.handler.client_uuid, 72 | self.accessory_driver.accessory.display_name, 73 | exc, 74 | ) 75 | self.accessory_driver.connection_lost(self.peername) 76 | self.close() 77 | 78 | def connection_made(self, transport: asyncio.Transport) -> None: 79 | """Handle incoming connection.""" 80 | self.last_activity = time.time() 81 | peername = transport.get_extra_info("peername") 82 | logger.info( 83 | "%s: Connection made to %s", 84 | peername, 85 | self.accessory_driver.accessory.display_name, 86 | ) 87 | # Ensure we do not write a partial encrypted response 88 | # as it can cause the controller to send a RST and drop 89 | # the connection with large responses. 90 | transport.set_write_buffer_limits(high=HIGH_WRITE_BUFFER_SIZE) 91 | self.transport = transport 92 | self.peername = peername 93 | self.connections[peername] = self 94 | self.handler = HAPServerHandler(self.accessory_driver, peername) 95 | 96 | def write(self, data: bytes) -> None: 97 | """Write data to the client.""" 98 | self.last_activity = time.time() 99 | if self.hap_crypto: 100 | result = self.hap_crypto.encrypt(data) 101 | logger.debug( 102 | "%s (%s): Send encrypted: %s", 103 | self.peername, 104 | self.handler.client_uuid, 105 | data, 106 | ) 107 | self.transport.writelines(result) 108 | else: 109 | logger.debug( 110 | "%s (%s): Send unencrypted: %s", 111 | self.peername, 112 | self.handler.client_uuid, 113 | data, 114 | ) 115 | self.transport.write(data) 116 | 117 | def close(self) -> None: 118 | """Remove the connection and close the transport.""" 119 | if self.peername in self.connections: 120 | del self.connections[self.peername] 121 | self.transport.write_eof() 122 | self.transport.close() 123 | 124 | def queue_event(self, data: dict, immediate: bool) -> None: 125 | """Queue an event for sending.""" 126 | self._event_queue[(data[HAP_REPR_AID], data[HAP_REPR_IID])] = data 127 | if immediate: 128 | self.loop.call_soon(self._send_events) 129 | elif not self._event_timer: 130 | self._event_timer = self.loop.call_later( 131 | EVENT_COALESCE_TIME_WINDOW, self._send_events 132 | ) 133 | 134 | def send_response(self, response: HAPResponse) -> None: 135 | """Send a HAPResponse object.""" 136 | body_len = len(response.body) 137 | if body_len: 138 | # Force Content-Length as iOS can sometimes 139 | # stall if it gets chunked encoding 140 | response.headers.append(("Content-Length", str(body_len))) 141 | send = self.conn.send 142 | self.write( 143 | b"".join( 144 | ( 145 | send( 146 | h11.Response( 147 | status_code=response.status_code, 148 | reason=response.reason, 149 | headers=response.headers, 150 | ) 151 | ), 152 | send(h11.Data(data=response.body)), 153 | send(H11_END_OF_MESSAGE), 154 | ) 155 | ) 156 | ) 157 | 158 | def finish_and_close(self) -> None: 159 | """Cleanly finish and close the connection.""" 160 | self.conn.send(H11_CONNECTION_CLOSED) 161 | self.close() 162 | 163 | def check_idle(self, now: float) -> None: 164 | """Abort when do not get any data within the timeout.""" 165 | if self.last_activity + IDLE_CONNECTION_TIMEOUT_SECONDS >= now: 166 | return 167 | logger.info( 168 | "%s: Idle time out after %s to %s", 169 | self.peername, 170 | IDLE_CONNECTION_TIMEOUT_SECONDS, 171 | self.accessory_driver.accessory.display_name, 172 | ) 173 | self.close() 174 | 175 | def data_received(self, data: bytes) -> None: 176 | """Process new data from the socket.""" 177 | self.last_activity = time.time() 178 | if self.hap_crypto: 179 | self.hap_crypto.receive_data(data) 180 | try: 181 | unencrypted_data = self.hap_crypto.decrypt() 182 | except InvalidTag as ex: 183 | logger.debug( 184 | "%s (%s): Decrypt failed, closing connection: %s", 185 | self.peername, 186 | self.handler.client_uuid, 187 | ex, 188 | ) 189 | self.close() 190 | return 191 | if unencrypted_data == b"": 192 | logger.debug("No decryptable data") 193 | return 194 | logger.debug( 195 | "%s (%s): Recv decrypted: %s", 196 | self.peername, 197 | self.handler.client_uuid, 198 | unencrypted_data, 199 | ) 200 | self.conn.receive_data(unencrypted_data) 201 | else: 202 | self.conn.receive_data(data) 203 | logger.debug( 204 | "%s (%s): Recv unencrypted: %s", 205 | self.peername, 206 | self.handler.client_uuid, 207 | data, 208 | ) 209 | self._process_events() 210 | 211 | def _process_events(self) -> None: 212 | """Process pending events.""" 213 | try: 214 | while self._process_one_event(): 215 | if self.conn.our_state is h11.MUST_CLOSE: 216 | self.finish_and_close() 217 | return 218 | except h11.ProtocolError as protocol_ex: 219 | self._handle_invalid_conn_state(protocol_ex) 220 | 221 | def _send_events(self) -> None: 222 | """Send any pending events.""" 223 | if self._event_timer: 224 | self._event_timer.cancel() 225 | self._event_timer = None 226 | if not self._event_queue: 227 | return 228 | subscribed_events = self._event_queue_with_active_subscriptions() 229 | if subscribed_events: 230 | self.write(create_hap_event(subscribed_events)) 231 | self._event_queue.clear() 232 | 233 | def _event_queue_with_active_subscriptions(self) -> List[Dict[str, Any]]: 234 | """Remove any topics that have been unsubscribed after the event was generated.""" 235 | topics = self.accessory_driver.topics 236 | return [ 237 | event 238 | for event in self._event_queue.values() 239 | if self.peername 240 | in topics.get(get_topic(event[HAP_REPR_AID], event[HAP_REPR_IID]), []) 241 | ] 242 | 243 | def _process_one_event(self) -> bool: 244 | """Process one http event.""" 245 | event = self.conn.next_event() 246 | logger.debug( 247 | "%s (%s): h11 Event: %s", self.peername, self.handler.client_uuid, event 248 | ) 249 | if event is h11.NEED_DATA: 250 | return False 251 | 252 | if event is h11.PAUSED: 253 | self.conn.start_next_cycle() 254 | return True 255 | 256 | event_type = type(event) 257 | if event_type is h11.ConnectionClosed: 258 | return False 259 | 260 | if event_type is h11.Request: 261 | self.request = event 262 | self.request_body = [] 263 | return True 264 | 265 | if event_type is h11.Data: 266 | if TYPE_CHECKING: 267 | assert isinstance(event, h11.Data) # nosec 268 | self.request_body.append(event.data) 269 | return True 270 | 271 | if event_type is h11.EndOfMessage: 272 | response = self.handler.dispatch(self.request, b"".join(self.request_body)) 273 | self._process_response(response) 274 | self.request = None 275 | self.request_body = [] 276 | return True 277 | 278 | return self._handle_invalid_conn_state(f"Unexpected event: {event}") 279 | 280 | def _process_response(self, response: HAPResponse) -> None: 281 | """Process a response from the handler.""" 282 | if response.task: 283 | # If there is a task pending we will schedule 284 | # the response later 285 | self.response = response 286 | response.task.add_done_callback(self._handle_response_ready) 287 | else: 288 | self.send_response(response) 289 | 290 | # If we get a shared key, upgrade to encrypted 291 | if response.shared_key: 292 | self.hap_crypto = HAPCrypto(response.shared_key) 293 | # Only update mDNS after sending the response 294 | if response.pairing_changed: 295 | async_create_background_task( 296 | self.loop.run_in_executor(None, self.accessory_driver.finish_pair) 297 | ) 298 | 299 | def _handle_response_ready(self, task: asyncio.Task) -> None: 300 | """Handle delayed response.""" 301 | response = self.response 302 | self.response = None 303 | try: 304 | response.body = task.result() 305 | except Exception as ex: # pylint: disable=broad-except 306 | logger.debug( 307 | "%s (%s): exception during delayed response", 308 | self.peername, 309 | self.handler.client_uuid, 310 | exc_info=ex, 311 | ) 312 | response = self.handler.generic_failure_response() 313 | if self.transport.is_closing(): 314 | logger.debug( 315 | "%s (%s): delayed response not sent as the transport as closed.", 316 | self.peername, 317 | self.handler.client_uuid, 318 | ) 319 | return 320 | self.send_response(response) 321 | 322 | def _handle_invalid_conn_state(self, message: Exception) -> bool: 323 | """Log invalid state and close.""" 324 | logger.debug( 325 | "%s (%s): Invalid state: %s: close the client socket", 326 | self.peername, 327 | self.handler.client_uuid, 328 | message, 329 | ) 330 | self.close() 331 | return False 332 | -------------------------------------------------------------------------------- /pyhap/hap_server.py: -------------------------------------------------------------------------------- 1 | """This module implements the communication of HAP. 2 | 3 | The HAPServer is the point of contact to and from the world. 4 | """ 5 | 6 | import asyncio 7 | import logging 8 | import time 9 | from typing import TYPE_CHECKING, Dict, Optional, Tuple 10 | 11 | from .hap_protocol import HAPServerProtocol 12 | from .util import callback 13 | 14 | if TYPE_CHECKING: 15 | from .accessory_driver import AccessoryDriver 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | IDLE_CONNECTION_CHECK_INTERVAL_SECONDS = 300 20 | 21 | 22 | class HAPServer: 23 | """Point of contact for HAP clients. 24 | 25 | The HAPServer handles all incoming client requests (e.g. pair) and also handles 26 | communication from Accessories to clients (value changes). The outbound communication 27 | is something like HTTP push. 28 | 29 | @note: Client requests responses as well as outgoing event notifications happen through 30 | the same socket for the same client. This introduces a race condition - an Accessory 31 | decides to push a change in current temperature, while in the same time the HAP client 32 | decides to query the state of the Accessory. To overcome this the HAPSocket class 33 | implements exclusive access to the send methods. 34 | """ 35 | 36 | def __init__( 37 | self, addr_port: Tuple[str, int], accessory_handler: "AccessoryDriver" 38 | ) -> None: 39 | """Create a HAP Server.""" 40 | self._addr_port = addr_port 41 | self.connections: Dict[Tuple[str, int], HAPServerProtocol] = {} 42 | self.accessory_handler = accessory_handler 43 | self.server: Optional[asyncio.Server] = None 44 | self._connection_cleanup: Optional[asyncio.TimerHandle] = None 45 | self.loop: Optional[asyncio.AbstractEventLoop] = None 46 | 47 | async def async_start(self, loop: asyncio.AbstractEventLoop) -> None: 48 | """Start the http-hap server.""" 49 | self.loop = loop 50 | self.server = await loop.create_server( 51 | lambda: HAPServerProtocol(loop, self.connections, self.accessory_handler), 52 | self._addr_port[0], 53 | self._addr_port[1], 54 | ) 55 | self.async_cleanup_connections() 56 | 57 | @callback 58 | def async_cleanup_connections(self) -> None: 59 | """Cleanup stale connections.""" 60 | now = time.time() 61 | for hap_proto in list(self.connections.values()): 62 | hap_proto.check_idle(now) 63 | self._connection_cleanup = self.loop.call_later( 64 | IDLE_CONNECTION_CHECK_INTERVAL_SECONDS, self.async_cleanup_connections 65 | ) 66 | 67 | @callback 68 | def async_stop(self) -> None: 69 | """Stop the server. 70 | 71 | This method must be run in the event loop. 72 | """ 73 | self._connection_cleanup.cancel() 74 | for hap_proto in list(self.connections.values()): 75 | hap_proto.close() 76 | self.server.close() 77 | self.connections.clear() 78 | 79 | def push_event( 80 | self, data: bytes, client_addr: Tuple[str, int], immediate: bool = False 81 | ) -> bool: 82 | """Queue an event to the current connection with the provided data. 83 | 84 | :param data: The characteristic changes 85 | :type data: dict 86 | 87 | :param client_addr: A client (address, port) tuple to which to send the data. 88 | :type client_addr: tuple 89 | 90 | :return: True if sending was successful, False otherwise. 91 | :rtype: bool 92 | """ 93 | hap_server_protocol = self.connections.get(client_addr) 94 | if hap_server_protocol is None: 95 | logger.debug("No socket for %s", client_addr) 96 | return False 97 | hap_server_protocol.queue_event(data, immediate) 98 | return True 99 | -------------------------------------------------------------------------------- /pyhap/hsrp.py: -------------------------------------------------------------------------------- 1 | # Server Side SRP implementation 2 | 3 | import os 4 | 5 | from .util import long_to_bytes 6 | 7 | 8 | def bytes_to_long(s): 9 | # Bytes should be interpreted from left to right, hence the byteorder 10 | return int.from_bytes(s, byteorder="big") 11 | 12 | 13 | # b Secret ephemeral values (long) 14 | # A Public ephemeral values (long) 15 | # Ab Public ephemeral values (bytes) 16 | # B Public ephemeral values (long) 17 | # Bb Public ephemeral values (bytes) 18 | # g A generator modulo N (long) 19 | # gb A generator modulo N (bytes) 20 | # I Username (bytes) 21 | # k Multiplier parameter (long) 22 | # N Large safe prime (long) 23 | # Nb Large safe prime (bytes) 24 | # p Cleartext Password (bytes) 25 | # s Salt (bytes) 26 | # u Random scrambling parameter (bytes) 27 | # v Password verifier (long) 28 | 29 | 30 | class Server: 31 | def __init__(self, ctx, u, p, s=None, v=None, b=None): 32 | self.hashfunc = ctx["hashfunc"] 33 | self.N = ctx["N"] 34 | self.Nb = long_to_bytes(self.N) 35 | self.g = ctx["g"] 36 | self.gb = long_to_bytes(self.g) 37 | self.N_len = ctx["N_len"] 38 | self.s = s or os.urandom(ctx["salt_len"]) 39 | self.I = u # noqa: E741 40 | self.p = p 41 | self.v = v or self._get_verifier() 42 | self.k = self._get_k() 43 | self.b = b or bytes_to_long(os.urandom(ctx["secret_len"])) 44 | self.B = self._derive_B() 45 | self.Bb = long_to_bytes(self.B) 46 | 47 | self.Ab = None 48 | self.A = None 49 | self.S = None 50 | self.Sb = None 51 | self.K = None 52 | self.Kb = None 53 | self.M = None 54 | self.u = None 55 | self.HAMK = None 56 | 57 | def _digest(self, data): 58 | return self.hashfunc(data).digest() 59 | 60 | def _hexdigest_int16(self, data): 61 | return int(self.hashfunc(data).hexdigest(), 16) 62 | 63 | def _derive_B(self): 64 | return (self.k * self.v + pow(self.g, self.b, self.N)) % self.N 65 | 66 | def _get_private_key(self): 67 | return self._hexdigest_int16(self.s + self._digest(self.I + b":" + self.p)) 68 | 69 | def _get_verifier(self): 70 | return pow(self.g, self._get_private_key(), self.N) 71 | 72 | def _get_k(self): 73 | return self._hexdigest_int16(self.Nb + self._padN(self.gb)) 74 | 75 | def _get_K(self): 76 | return self._hexdigest_int16(self.Sb) 77 | 78 | def _padN(self, bytestr): 79 | return bytestr.rjust(self.N_len // 8, b"\x00") 80 | 81 | def _derive_premaster_secret(self): 82 | self.u = self._hexdigest_int16(self._padN(self.Ab) + self._padN(self.Bb)) 83 | Avu = self.A * pow(self.v, self.u, self.N) 84 | return pow(Avu, self.b, self.N) 85 | 86 | def _get_M(self): 87 | hN = self._digest(self.Nb) 88 | hG = self._digest(self.gb) 89 | hGroup = bytes(hN[i] ^ hG[i] for i in range(0, len(hN))) 90 | hU = self._digest(self.I) 91 | return self._digest(hGroup + hU + self.s + self.Ab + self.Bb + self.Kb) 92 | 93 | def set_A(self, bytes_A): 94 | self.A = bytes_to_long(bytes_A) 95 | self.Ab = bytes_A 96 | self.S = self._derive_premaster_secret() 97 | self.Sb = long_to_bytes(self.S) 98 | self.K = self._get_K() 99 | self.Kb = long_to_bytes(self.K) 100 | self.M = self._get_M() 101 | self.HAMK = self._get_HAMK() 102 | 103 | def _get_HAMK(self): 104 | return self._digest(self.Ab + self.M + self.Kb) 105 | 106 | def get_challenge(self): 107 | return (self.s, self.B) 108 | 109 | def verify(self, M): 110 | return self.HAMK if self.M == M else None 111 | 112 | def get_session_key(self): 113 | return self.K 114 | -------------------------------------------------------------------------------- /pyhap/iid_manager.py: -------------------------------------------------------------------------------- 1 | """Module for the IIDManager class.""" 2 | import logging 3 | from typing import TYPE_CHECKING, Dict, Optional, Union 4 | 5 | if TYPE_CHECKING: 6 | from .characteristic import Characteristic 7 | from .service import Service 8 | 9 | ServiceOrCharType = Union[Service, Characteristic] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class IIDManager: 15 | """Maintains a mapping between Service/Characteristic objects and IIDs.""" 16 | 17 | def __init__(self) -> None: 18 | """Initialize an empty instance.""" 19 | self.counter = 0 20 | self.iids: Dict["ServiceOrCharType", int] = {} 21 | self.objs: Dict[int, "ServiceOrCharType"] = {} 22 | 23 | def assign(self, obj: "ServiceOrCharType") -> None: 24 | """Assign an IID to given object. Print warning if already assigned. 25 | 26 | :param obj: The object that will be assigned an IID. 27 | :type obj: Service or Characteristic 28 | """ 29 | if obj in self.iids: 30 | logger.warning( 31 | "The given Service or Characteristic with UUID %s already " 32 | "has an assigned IID %s, ignoring.", 33 | obj.type_id, 34 | self.iids[obj], 35 | ) 36 | return 37 | 38 | iid = self.get_iid_for_obj(obj) 39 | self.iids[obj] = iid 40 | self.objs[iid] = obj 41 | 42 | def get_iid_for_obj(self, obj: "ServiceOrCharType") -> int: 43 | """Get the IID for the given object. 44 | 45 | Override this method to provide custom IID assignment. 46 | """ 47 | self.counter += 1 48 | return self.counter 49 | 50 | def get_obj(self, iid: int) -> "ServiceOrCharType": 51 | """Get the object that is assigned the given IID.""" 52 | return self.objs.get(iid) 53 | 54 | def get_iid(self, obj: "ServiceOrCharType") -> int: 55 | """Get the IID assigned to the given object.""" 56 | return self.iids.get(obj) 57 | 58 | def remove_obj(self, obj: "ServiceOrCharType") -> Optional[int]: 59 | """Remove an object from the IID list.""" 60 | iid = self.iids.pop(obj, None) 61 | if iid is None: 62 | logger.error("Object %s not found.", obj) 63 | return None 64 | del self.objs[iid] 65 | return iid 66 | 67 | def remove_iid(self, iid: int) -> Optional["ServiceOrCharType"]: 68 | """Remove an object with an IID from the IID list.""" 69 | obj = self.objs.pop(iid, None) 70 | if obj is None: 71 | logger.error("IID %s not found.", iid) 72 | return None 73 | del self.iids[obj] 74 | return obj 75 | -------------------------------------------------------------------------------- /pyhap/loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various classes that construct representations of 3 | HAP services and characteristics from a json 4 | representation. 5 | 6 | The idea is, give a name of a service and you get an 7 | instance of it (as long as it is described in some 8 | json file). 9 | """ 10 | import logging 11 | 12 | import orjson 13 | 14 | from pyhap import CHARACTERISTICS_FILE, SERVICES_FILE 15 | from pyhap.characteristic import Characteristic 16 | from pyhap.service import Service 17 | 18 | _loader = None 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class Loader: 23 | """Looks up type descriptions based on a name. 24 | 25 | .. seealso:: pyhap/resources/services.json 26 | .. seealso:: pyhap/resources/characteristics.json 27 | """ 28 | 29 | def __init__(self, path_char=CHARACTERISTICS_FILE, path_service=SERVICES_FILE): 30 | """Initialize a new Loader instance.""" 31 | self.char_types = self._read_file(path_char) 32 | self.serv_types = self._read_file(path_service) 33 | 34 | @staticmethod 35 | def _read_file(path): 36 | """Read file and return a dict.""" 37 | with open(path, "r", encoding="utf8") as file: 38 | return orjson.loads(file.read()) # pylint: disable=no-member 39 | 40 | def get_char(self, name): 41 | """Return new Characteristic object.""" 42 | char_dict = self.char_types[name].copy() 43 | if ( 44 | "Format" not in char_dict 45 | or "Permissions" not in char_dict 46 | or "UUID" not in char_dict 47 | ): 48 | raise KeyError(f"Could not load char {name}!") 49 | return Characteristic.from_dict(name, char_dict, from_loader=True) 50 | 51 | def get_service(self, name): 52 | """Return new service object.""" 53 | service_dict = self.serv_types[name].copy() 54 | if "RequiredCharacteristics" not in service_dict or "UUID" not in service_dict: 55 | raise KeyError(f"Could not load service {name}!") 56 | return Service.from_dict(name, service_dict, self) 57 | 58 | @classmethod 59 | def from_dict(cls, char_dict=None, serv_dict=None): 60 | """Create a new instance directly from json dicts.""" 61 | loader = cls.__new__(Loader) 62 | loader.char_types = char_dict or {} 63 | loader.serv_types = serv_dict or {} 64 | return loader 65 | 66 | 67 | def get_loader(): 68 | """Get a service and char loader. 69 | 70 | If already initialized it returns the existing one. 71 | """ 72 | # pylint: disable=global-statement 73 | global _loader 74 | if _loader is None: 75 | _loader = Loader() 76 | return _loader 77 | -------------------------------------------------------------------------------- /pyhap/params.py: -------------------------------------------------------------------------------- 1 | # hsrp parameters 2 | ng_order = (3072,) 3 | 4 | _ng_const = ( 5 | # 3072 6 | ( 7 | """\ 8 | FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E08\ 9 | 8A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B\ 10 | 302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9\ 11 | A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE6\ 12 | 49286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8\ 13 | FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D\ 14 | 670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C\ 15 | 180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\ 16 | 3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D\ 17 | 04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D\ 18 | B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D226\ 19 | 1AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200C\ 20 | BBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFC\ 21 | E0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF""", 22 | "5", 23 | ), 24 | ) 25 | 26 | 27 | def get_srp_context(ng_group_len, hashfunc, salt_len=16, secret_len=32): 28 | group = _ng_const[ng_order.index(ng_group_len)] 29 | 30 | ctx = { 31 | "hashfunc": hashfunc, 32 | "N": int(group[0], 16), 33 | "g": int(group[1], 16), 34 | "N_len": ng_group_len, 35 | "salt_len": salt_len, 36 | "secret_len": secret_len, 37 | } 38 | return ctx 39 | -------------------------------------------------------------------------------- /pyhap/resources/snapshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ikalchev/HAP-python/01af9961de403f63cc6bb8714f19988df9b66325/pyhap/resources/snapshot.jpg -------------------------------------------------------------------------------- /pyhap/service.py: -------------------------------------------------------------------------------- 1 | """This module implements the HAP Service.""" 2 | 3 | from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional 4 | from uuid import UUID 5 | 6 | from pyhap.const import ( 7 | HAP_REPR_CHARS, 8 | HAP_REPR_IID, 9 | HAP_REPR_LINKED, 10 | HAP_REPR_PRIMARY, 11 | HAP_REPR_TYPE, 12 | ) 13 | 14 | from .characteristic import Characteristic 15 | from .util import hap_type_to_uuid, uuid_to_hap_type 16 | 17 | if TYPE_CHECKING: 18 | from .accessory import Accessory 19 | from .loader import Loader 20 | 21 | 22 | class Service: 23 | """A representation of a HAP service. 24 | 25 | A Service contains multiple characteristics. For example, a 26 | TemperatureSensor service has the characteristic CurrentTemperature. 27 | """ 28 | 29 | __slots__ = ( 30 | "broker", 31 | "characteristics", 32 | "display_name", 33 | "type_id", 34 | "linked_services", 35 | "is_primary_service", 36 | "setter_callback", 37 | "unique_id", 38 | "_uuid_str", 39 | ) 40 | 41 | def __init__( 42 | self, 43 | type_id: UUID, 44 | display_name: Optional[str] = None, 45 | unique_id: Optional[str] = None, 46 | ) -> None: 47 | """Initialize a new Service object.""" 48 | self.broker: Optional["Accessory"] = None 49 | self.characteristics: List[Characteristic] = [] 50 | self.linked_services: List[Service] = [] 51 | self.display_name = display_name 52 | self.type_id = type_id 53 | self.is_primary_service = None 54 | self.setter_callback: Optional[Callable[[Any], None]] = None 55 | self.unique_id = unique_id 56 | self._uuid_str = uuid_to_hap_type(type_id) 57 | 58 | def __repr__(self): 59 | """Return the representation of the service.""" 60 | chars_dict = {c.display_name: c.value for c in self.characteristics} 61 | return f"" 62 | 63 | def add_linked_service(self, service: "Service") -> None: 64 | """Add the given service as "linked" to this Service.""" 65 | iid_manager = self.broker.iid_manager 66 | if not any( 67 | iid_manager.get_iid(service) == iid_manager.get_iid(original_service) 68 | for original_service in self.linked_services 69 | ): 70 | self.linked_services.append(service) 71 | 72 | def add_characteristic(self, *chars: Characteristic) -> None: 73 | """Add the given characteristics as "mandatory" for this Service.""" 74 | for char in chars: 75 | if not any( 76 | char.type_id == original_char.type_id 77 | for original_char in self.characteristics 78 | ): 79 | char.service = self 80 | self.characteristics.append(char) 81 | 82 | def get_characteristic(self, name: str) -> Characteristic: 83 | """Return a Characteristic object by the given name from this Service. 84 | 85 | :param name: The name of the characteristic to search for. 86 | :type name: str 87 | 88 | :raise ValueError if characteristic is not found. 89 | 90 | :return: A characteristic with the given name. 91 | :rtype: Characteristic 92 | """ 93 | for char in self.characteristics: 94 | if char.display_name == name: 95 | return char 96 | raise ValueError("Characteristic not found") 97 | 98 | def configure_char( 99 | self, 100 | char_name: str, 101 | properties=None, 102 | valid_values=None, 103 | value=None, 104 | setter_callback=None, 105 | getter_callback=None, 106 | ) -> Characteristic: 107 | """Helper method to return fully configured characteristic.""" 108 | char = self.get_characteristic(char_name) 109 | if properties or valid_values: 110 | char.override_properties(properties, valid_values) 111 | if value: 112 | char.set_value(value, should_notify=False) 113 | if setter_callback: 114 | char.setter_callback = setter_callback 115 | if getter_callback: 116 | char.getter_callback = getter_callback 117 | return char 118 | 119 | # pylint: disable=invalid-name 120 | def to_HAP(self, include_value: bool = True) -> Dict[str, Any]: 121 | """Create a HAP representation of this Service. 122 | 123 | :return: A HAP representation. 124 | :rtype: dict. 125 | """ 126 | hap = { 127 | HAP_REPR_IID: self.broker.iid_manager.get_iid(self), 128 | HAP_REPR_TYPE: self._uuid_str, 129 | HAP_REPR_CHARS: [c.to_HAP(include_value) for c in self.characteristics], 130 | } 131 | 132 | if self.is_primary_service is not None: 133 | hap[HAP_REPR_PRIMARY] = self.is_primary_service 134 | 135 | if self.linked_services: 136 | linked: List[int] = [] 137 | for linked_service in self.linked_services: 138 | linked.append(linked_service.broker.iid_manager.get_iid(linked_service)) 139 | hap[HAP_REPR_LINKED] = linked 140 | 141 | return hap 142 | 143 | @classmethod 144 | def from_dict( 145 | cls, name: str, json_dict: Dict[str, Any], loader: "Loader" 146 | ) -> "Service": 147 | """Initialize a service object from a dict. 148 | 149 | :param json_dict: Dictionary containing at least the keys `UUID` and 150 | `RequiredCharacteristics` 151 | :type json_dict: dict 152 | """ 153 | type_id = hap_type_to_uuid(json_dict.pop("UUID")) 154 | service = cls(type_id, name) 155 | for char_name in json_dict["RequiredCharacteristics"]: 156 | service.add_characteristic(loader.get_char(char_name)) 157 | return service 158 | -------------------------------------------------------------------------------- /pyhap/state.py: -------------------------------------------------------------------------------- 1 | """Module for `State` class.""" 2 | from typing import Dict, List, Optional, Union 3 | from uuid import UUID 4 | 5 | from cryptography.hazmat.primitives.asymmetric import ed25519 6 | 7 | from pyhap import util 8 | from pyhap.const import ( 9 | CLIENT_PROP_PERMS, 10 | DEFAULT_CONFIG_VERSION, 11 | DEFAULT_PORT, 12 | MAX_CONFIG_VERSION, 13 | ) 14 | 15 | ADMIN_BIT = 0x01 16 | 17 | 18 | class State: 19 | """Class to store all (semi-)static information. 20 | 21 | That includes all needed for setup of driver and pairing. 22 | """ 23 | 24 | addreses: List[str] 25 | 26 | def __init__( 27 | self, 28 | *, 29 | address: Optional[Union[str, List[str]]] = None, 30 | mac=None, 31 | pincode=None, 32 | port=None 33 | ): 34 | """Initialize a new object. Create key pair. 35 | 36 | Must be called with keyword arguments. 37 | """ 38 | if address: 39 | if isinstance(address, str): 40 | self.addresses = [address] 41 | else: 42 | self.addresses = address 43 | else: 44 | self.addresses = [util.get_local_address()] 45 | self.mac: str = mac or util.generate_mac() 46 | self.pincode = pincode or util.generate_pincode() 47 | self.port = port or DEFAULT_PORT 48 | self.setup_id = util.generate_setup_id() 49 | 50 | self.config_version = DEFAULT_CONFIG_VERSION 51 | self.paired_clients: Dict[UUID, bytes] = {} 52 | self.client_properties = {} 53 | 54 | self.private_key = ed25519.Ed25519PrivateKey.generate() 55 | self.public_key = self.private_key.public_key() 56 | self.uuid_to_bytes: Dict[UUID, bytes] = {} 57 | self.accessories_hash = None 58 | 59 | @property 60 | def address(self) -> str: 61 | """Return the first address for backwards compat.""" 62 | return self.addresses[0] 63 | 64 | # ### Pairing ### 65 | @property 66 | def paired(self) -> bool: 67 | """Return if main accessory is currently paired.""" 68 | return len(self.paired_clients) > 0 69 | 70 | def is_admin(self, client_uuid: UUID) -> bool: 71 | """Check if a paired client is an admin.""" 72 | if client_uuid not in self.client_properties: 73 | return False 74 | return bool(self.client_properties[client_uuid][CLIENT_PROP_PERMS] & ADMIN_BIT) 75 | 76 | def add_paired_client( 77 | self, client_username_bytes: bytes, client_public: bytes, perms: bytes 78 | ) -> None: 79 | """Add a given client to dictionary of paired clients. 80 | 81 | :param client_username_bytes: The client's user id bytes. 82 | :type client_username_bytes: bytes 83 | 84 | :param client_public: The client's public key 85 | (not the session public key). 86 | :type client_public: bytes 87 | """ 88 | client_username_str = client_username_bytes.decode("utf-8") 89 | client_uuid = UUID(client_username_str) 90 | self.uuid_to_bytes[client_uuid] = client_username_bytes 91 | self.paired_clients[client_uuid] = client_public 92 | self.client_properties[client_uuid] = {CLIENT_PROP_PERMS: ord(perms)} 93 | 94 | def remove_paired_client(self, client_uuid: UUID) -> None: 95 | """Remove a given client from dictionary of paired clients. 96 | 97 | :param client_uuid: The client's UUID. 98 | :type client_uuid: uuid.UUID 99 | """ 100 | self.paired_clients.pop(client_uuid) 101 | self.client_properties.pop(client_uuid) 102 | self.uuid_to_bytes.pop(client_uuid, None) 103 | 104 | # All pairings must be removed when the last admin is removed 105 | if not any(self.is_admin(client_uuid) for client_uuid in self.paired_clients): 106 | self.paired_clients.clear() 107 | self.client_properties.clear() 108 | 109 | def set_accessories_hash(self, accessories_hash): 110 | """Set the accessories hash and increment the config version if needed.""" 111 | if self.accessories_hash == accessories_hash: 112 | return False 113 | self.accessories_hash = accessories_hash 114 | self.increment_config_version() 115 | return True 116 | 117 | def increment_config_version(self): 118 | """Increment the config version.""" 119 | self.config_version += 1 120 | if self.config_version > MAX_CONFIG_VERSION: 121 | self.config_version = 1 122 | -------------------------------------------------------------------------------- /pyhap/tlv.py: -------------------------------------------------------------------------------- 1 | """Encodes and decodes Tag-Length-Value (tlv8) data.""" 2 | import struct 3 | from typing import Any, Dict 4 | 5 | from pyhap import util 6 | 7 | 8 | def encode(*args, to_base64=False): 9 | """Encode the given byte args in TLV format. 10 | 11 | :param args: Even-number, variable length positional arguments repeating a tag 12 | followed by a value. 13 | :type args: ``bytes`` 14 | 15 | :param toBase64: Whether to encode the resuting TLV byte sequence to a base64 str. 16 | :type toBase64: ``bool`` 17 | 18 | :return: The args in TLV format 19 | :rtype: ``bytes`` if ``toBase64`` is False and ``str`` otherwise. 20 | """ 21 | arg_len = len(args) 22 | if arg_len % 2 != 0: 23 | raise ValueError(f"Even number of args expected ({arg_len} given)") 24 | 25 | pieces = [] 26 | for x in range(0, len(args), 2): 27 | tag = args[x] 28 | data = args[x + 1] 29 | total_length = len(data) 30 | if len(data) <= 255: 31 | encoded = tag + struct.pack("B", total_length) + data 32 | else: 33 | encoded = b"" 34 | for y in range(0, total_length // 255): 35 | encoded = encoded + tag + b"\xFF" + data[y * 255 : (y + 1) * 255] 36 | remaining = total_length % 255 37 | encoded = encoded + tag + struct.pack("B", remaining) + data[-remaining:] 38 | 39 | pieces.append(encoded) 40 | 41 | result = b"".join(pieces) 42 | 43 | return util.to_base64_str(result) if to_base64 else result 44 | 45 | 46 | def decode(data: bytes, from_base64: bool = False) -> Dict[bytes, Any]: 47 | """Decode the given TLV-encoded ``data`` to a ``dict``. 48 | 49 | :param from_base64: Whether the given ``data`` should be base64 decoded first. 50 | :type from_base64: ``bool`` 51 | 52 | :return: A ``dict`` containing the tags as keys and the values as values. 53 | :rtype: ``dict`` 54 | """ 55 | if from_base64: 56 | data = util.base64_to_bytes(data) 57 | 58 | objects = {} 59 | current = 0 60 | while current < len(data): 61 | # The following hack is because bytes[x] is an int 62 | # and we want to keep the tag as a byte. 63 | tag = data[current : current + 1] 64 | length = data[current + 1] 65 | value = data[current + 2 : current + 2 + length] 66 | if tag in objects: 67 | objects[tag] = objects[tag] + value 68 | else: 69 | objects[tag] = value 70 | 71 | current = current + 2 + length 72 | 73 | return objects 74 | -------------------------------------------------------------------------------- /pyhap/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import functools 4 | import random 5 | import socket 6 | from typing import Awaitable, Set 7 | from uuid import UUID 8 | 9 | import async_timeout 10 | import orjson 11 | 12 | from .const import BASE_UUID 13 | 14 | ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" 15 | HEX_DIGITS = "0123456789ABCDEF" 16 | _BACKGROUND_TASKS: Set[asyncio.Task] = set() 17 | 18 | 19 | rand = random.SystemRandom() 20 | 21 | 22 | def callback(func): 23 | """Decorator for non blocking functions.""" 24 | setattr(func, "_pyhap_callback", True) 25 | return func 26 | 27 | 28 | def is_callback(func): 29 | """Check if function is callback.""" 30 | return "_pyhap_callback" in getattr(func, "__dict__", {}) 31 | 32 | 33 | def iscoro(func): 34 | """Check if the function is a coroutine or if the function is a ``functools.partial``, 35 | check the wrapped function for the same. 36 | """ 37 | if isinstance(func, functools.partial): 38 | func = func.func 39 | return asyncio.iscoroutinefunction(func) 40 | 41 | 42 | def get_local_address() -> str: 43 | """ 44 | Grabs the local IP address using a socket. 45 | 46 | :return: Local IP Address in IPv4 format. 47 | :rtype: str 48 | """ 49 | # TODO: try not to talk 8888 for this 50 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 51 | try: 52 | s.connect(("8.8.8.8", 80)) 53 | addr = s.getsockname()[0] 54 | finally: 55 | s.close() 56 | return str(addr) 57 | 58 | 59 | def long_to_bytes(n): 60 | """ 61 | Convert a ``long int`` to ``bytes`` 62 | 63 | :param n: Long Integer 64 | :type n: int 65 | 66 | :return: ``long int`` in ``bytes`` format. 67 | :rtype: bytes 68 | """ 69 | byteList = [] 70 | x = 0 71 | off = 0 72 | while x != n: 73 | b = (n >> off) & 0xFF 74 | byteList.append(b) 75 | x = x | (b << off) 76 | off += 8 77 | byteList.reverse() 78 | return bytes(byteList) 79 | 80 | 81 | def generate_mac(): 82 | """ 83 | Generates a fake mac address used in broadcast. 84 | 85 | :return: MAC address in format XX:XX:XX:XX:XX:XX 86 | :rtype: str 87 | """ 88 | return "{}{}:{}{}:{}{}:{}{}:{}{}:{}{}".format( # pylint: disable=consider-using-f-string 89 | *(rand.choice(HEX_DIGITS) for _ in range(12)) 90 | ) 91 | 92 | 93 | def generate_setup_id(): 94 | """ 95 | Generates a random Setup ID for an ``Accessory`` or ``Bridge``. 96 | 97 | Used in QR codes and the setup hash. 98 | 99 | :return: 4 digit alphanumeric code. 100 | :rtype: str 101 | """ 102 | return "".join([rand.choice(ALPHANUM) for i in range(4)]) 103 | 104 | 105 | def generate_pincode(): 106 | """ 107 | Generates a random pincode. 108 | 109 | :return: pincode in format ``xxx-xx-xxx`` 110 | :rtype: bytearray 111 | """ 112 | return "{}{}{}-{}{}-{}{}{}".format( # pylint: disable=consider-using-f-string 113 | *(rand.randint(0, 9) for i in range(8)) 114 | ).encode("ascii") 115 | 116 | 117 | def to_base64_str(bytes_input) -> str: 118 | return base64.b64encode(bytes_input).decode("utf-8") 119 | 120 | 121 | def base64_to_bytes(str_input) -> bytes: 122 | return base64.b64decode(str_input.encode("utf-8")) 123 | 124 | 125 | def byte_bool(boolv): 126 | return b"\x01" if boolv else b"\x00" 127 | 128 | 129 | async def event_wait(event, timeout): 130 | """Wait for the given event to be set or for the timeout to expire. 131 | 132 | :param event: The event to wait for. 133 | :type event: asyncio.Event 134 | 135 | :param timeout: The timeout for which to wait, in seconds. 136 | :type timeout: float 137 | 138 | :return: ``event.is_set()`` 139 | :rtype: bool 140 | """ 141 | try: 142 | async with async_timeout.timeout(timeout): 143 | await event.wait() 144 | except asyncio.TimeoutError: 145 | pass 146 | return event.is_set() 147 | 148 | 149 | @functools.lru_cache(maxsize=2048) 150 | def uuid_to_hap_type(uuid: UUID) -> str: 151 | """Convert a UUID to a HAP type.""" 152 | long_type = str(uuid).upper() 153 | if not long_type.endswith(BASE_UUID): 154 | return long_type 155 | return long_type.split("-", 1)[0].lstrip("0") 156 | 157 | 158 | @functools.lru_cache(maxsize=2048) 159 | def hap_type_to_uuid(hap_type): 160 | """Convert a HAP type to a UUID.""" 161 | if "-" in hap_type: 162 | return UUID(hap_type) 163 | return UUID("0" * (8 - len(hap_type)) + hap_type + BASE_UUID) 164 | 165 | 166 | def to_hap_json(dump_obj): 167 | """Convert an object to HAP json.""" 168 | return orjson.dumps(dump_obj) # pylint: disable=no-member 169 | 170 | 171 | def to_sorted_hap_json(dump_obj): 172 | """Convert an object to sorted HAP json.""" 173 | return orjson.dumps( # pylint: disable=no-member 174 | dump_obj, option=orjson.OPT_SORT_KEYS # pylint: disable=no-member 175 | ) 176 | 177 | 178 | def from_hap_json(json_str): 179 | """Convert json to an object.""" 180 | return orjson.loads(json_str) # pylint: disable=no-member 181 | 182 | 183 | def async_create_background_task(func: Awaitable) -> asyncio.Task: 184 | """Create a background task and add it to the set of background tasks.""" 185 | task = asyncio.ensure_future(func) 186 | _BACKGROUND_TASKS.add(task) 187 | task.add_done_callback(_BACKGROUND_TASKS.discard) 188 | return task 189 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable= 3 | too-few-public-methods, 4 | too-many-arguments, 5 | too-many-branches, 6 | too-many-function-args, 7 | too-many-instance-attributes, 8 | too-many-lines, 9 | too-many-locals, 10 | too-many-public-methods, 11 | too-many-return-statements, 12 | too-many-statements, 13 | unused-argument, 14 | consider-using-with 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py35", "py36", "py37", "py38"] 3 | exclude = 'generated' 4 | 5 | [tool.isort] 6 | # https://github.com/PyCQA/isort/wiki/isort-Settings 7 | profile = "black" 8 | # will group `import x` and `from x import` of the same module. 9 | force_sort_within_sections = true 10 | known_first_party = [ 11 | "pyhap", 12 | ] 13 | combine_as_imports = true 14 | 15 | [tool.pylint.MASTER] 16 | ignore = [ 17 | "tests", 18 | ] 19 | # Use a conservative default here; 2 should speed up most setups and not hurt 20 | # any too bad. Override on command line as appropriate. 21 | # Disabled for now: https://github.com/PyCQA/pylint/issues/3584 22 | #jobs = 2 23 | load-plugins = [ 24 | "pylint_strict_informational", 25 | ] 26 | persistent = false 27 | extension-pkg-whitelist = [ 28 | "ciso8601", 29 | "cv2", 30 | ] 31 | 32 | [tool.pylint.BASIC] 33 | good-names = [ 34 | "_", 35 | "ev", 36 | "ex", 37 | "fp", 38 | "i", 39 | "id", 40 | "j", 41 | "k", 42 | "Run", 43 | "T", 44 | ] 45 | 46 | [tool.pylint."MESSAGES CONTROL"] 47 | # Reasons disabled: 48 | # format - handled by black 49 | # locally-disabled - it spams too much 50 | # duplicate-code - unavoidable 51 | # cyclic-import - doesn't test if both import on load 52 | # abstract-class-little-used - prevents from setting right foundation 53 | # unused-argument - generic callbacks and setup methods create a lot of warnings 54 | # too-many-* - are not enforced for the sake of readability 55 | # too-few-* - same as too-many-* 56 | # abstract-method - with intro of async there are always methods missing 57 | # inconsistent-return-statements - doesn't handle raise 58 | # too-many-ancestors - it's too strict. 59 | # wrong-import-order - isort guards this 60 | disable = [ 61 | "format", 62 | "abstract-class-little-used", 63 | "abstract-method", 64 | "cyclic-import", 65 | "duplicate-code", 66 | "inconsistent-return-statements", 67 | "locally-disabled", 68 | "not-context-manager", 69 | "too-few-public-methods", 70 | "too-many-ancestors", 71 | "too-many-arguments", 72 | "too-many-branches", 73 | "too-many-instance-attributes", 74 | "too-many-lines", 75 | "too-many-locals", 76 | "too-many-public-methods", 77 | "too-many-return-statements", 78 | "too-many-statements", 79 | "too-many-boolean-expressions", 80 | "too-many-positional-arguments", 81 | "unused-argument", 82 | "wrong-import-order", 83 | "unused-argument", 84 | ] 85 | enable = [ 86 | #"useless-suppression", # temporarily every now and then to clean them up 87 | "use-symbolic-message-instead", 88 | ] 89 | 90 | [tool.pylint.REPORTS] 91 | score = false 92 | 93 | [tool.pylint.TYPECHECK] 94 | ignored-classes = [ 95 | "_CountingAttr", # for attrs 96 | ] 97 | 98 | [tool.pylint.FORMAT] 99 | expected-line-ending-format = "LF" 100 | 101 | [tool.pylint.EXCEPTIONS] 102 | overgeneral-exceptions = [ 103 | "BaseException", 104 | "Exception", 105 | "HomeAssistantError", 106 | ] 107 | 108 | [tool.pytest.ini_options] 109 | testpaths = [ 110 | "tests", 111 | ] 112 | norecursedirs = [ 113 | ".git", 114 | "testing_config", 115 | ] 116 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | h11 2 | chacha20poly1305-reuseable 3 | cryptography 4 | orjson 5 | zeroconf 6 | -------------------------------------------------------------------------------- /requirements_all.txt: -------------------------------------------------------------------------------- 1 | base36 2 | cryptography 3 | orjson 4 | pyqrcode 5 | h11 6 | zeroconf 7 | -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.7 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | base36 2 | flake8 3 | flake8-docstrings 4 | pydocstyle 5 | pylint 6 | pytest 7 | pytest-asyncio 8 | pytest-cov 9 | pytest-timeout>=1.2.1 10 | pyqrcode 11 | tox 12 | -------------------------------------------------------------------------------- /scripts/gen_hap_types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Create a json representation from the HomeKit Accessory Simulator Types.""" 3 | import plistlib 4 | import json 5 | 6 | # This path could be different. 7 | HOMEKIT_TYPES_PLIST = "/Applications/Xcode.app/Contents/Applications/HomeKit Accessory Simulator.app/Contents/Frameworks/HAPAccessoryKit.framework/Versions/A/Resources/default.metadata.plist" 8 | CHAR_OUT_FILE = "./pyhap/resources/characteristics.json" 9 | SERVICE_OUT_FILE = "./pyhap/resources/services.json" 10 | 11 | PERMS_MAP = { 12 | "read": "pr", 13 | "write": "pw", 14 | "cnotify": "ev", 15 | # TODO: find the 'symbol' for this one - "uncnotify": None, 16 | } 17 | 18 | CONSTRAINTS_MAP = { 19 | "MaximumValue": "maxValue", 20 | "MinimumValue": "minValue", 21 | "StepValue": "minStep", 22 | } 23 | 24 | 25 | def create_uuid2name_map(char_info): 26 | """Return a mapping UUIDs to Names.""" 27 | uuid2name = {} 28 | for char in char_info: 29 | uuid2name[char["UUID"]] = char["Name"] 30 | return uuid2name 31 | 32 | 33 | def fix_valid_values(char_info): 34 | """Valid values are given in a value: key format. Reverse them.""" 35 | for char in char_info: 36 | if "ValidValues" not in char: 37 | continue 38 | valid_values = {} 39 | for value, state in char["ValidValues"].items(): 40 | if "int" in char["Format"]: 41 | value = int(value) 42 | elif "float" == char["Format"]: 43 | value = float(value) 44 | else: 45 | raise ValueError 46 | state = state.replace(" ", "") # To camel case. 47 | valid_values[state] = value 48 | char["ValidValues"] = valid_values 49 | 50 | 51 | def tidy_char(char_info): 52 | """Various things we would like to change about a Characteristic representation.""" 53 | for char in char_info: 54 | if "Unit" in char: 55 | char["unit"] = char.pop("Unit") 56 | if "Properties" in char: 57 | permissions = [] 58 | for perm in char.pop("Properties"): 59 | if perm == "uncnotify": 60 | continue 61 | permissions.append(PERMS_MAP[perm]) 62 | char["Permissions"] = permissions 63 | if char["Format"] == "int32": 64 | char["Format"] = "int" 65 | if "Constraints" in char: 66 | constraints = char.pop("Constraints") 67 | for key, value in constraints.items(): 68 | char[CONSTRAINTS_MAP.get(key, key)] = value 69 | 70 | 71 | def replace_char_uuid(service_info, uuid2name): 72 | """Replace characteristics' UUID with their name for convenience.""" 73 | for service in service_info: 74 | if "RequiredCharacteristics" in service: 75 | req_chars = [] 76 | for char in service["RequiredCharacteristics"]: 77 | req_chars.append(uuid2name[char]) 78 | service["RequiredCharacteristics"] = req_chars 79 | if "OptionalCharacteristics" in service: 80 | opt_chars = [] 81 | for char in service.get("OptionalCharacteristics", []): 82 | opt_chars.append(uuid2name[char]) 83 | service["OptionalCharacteristics"] = opt_chars 84 | 85 | 86 | def camel_name(infos): 87 | """Transform the name to camel case, no spaces.""" 88 | for info in infos: 89 | info["Name"] = info["Name"].replace(" ", "") 90 | 91 | 92 | def list2dict(infos): 93 | """We want a mapping name: char/service for convenience, not a list.""" 94 | info_dict = {} 95 | for info in infos: 96 | info_dict[info["Name"]] = info 97 | del info["Name"] 98 | return info_dict 99 | 100 | 101 | def main(): 102 | """Reads the HomeKit Simulator types and creates a HAP-python json representation.""" 103 | with open(HOMEKIT_TYPES_PLIST, "rb") as types_plist_fp: 104 | type_info = plistlib.load(types_plist_fp) 105 | char_info = type_info["Characteristics"] 106 | service_info = type_info["Services"] 107 | 108 | camel_name(char_info) 109 | camel_name(service_info) 110 | 111 | uuid2name = create_uuid2name_map(char_info) 112 | 113 | tidy_char(char_info) 114 | fix_valid_values(char_info) 115 | with open(CHAR_OUT_FILE, "w") as char_fp: 116 | json.dump(list2dict(char_info), char_fp, indent=3, sort_keys=True) 117 | 118 | replace_char_uuid(service_info, uuid2name) 119 | with open(SERVICE_OUT_FILE, "w") as services_fp: 120 | json.dump(list2dict(service_info), services_fp, indent=3, sort_keys=True) 121 | 122 | 123 | if __name__ == "__main__": 124 | main() 125 | -------------------------------------------------------------------------------- /scripts/pickle_to_state.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Convert from pickled state to an AccessoryEncoder state. 4 | 5 | Usage: 6 | scripts/pickle_to_state.py accessory.pickle accessory.state 7 | 8 | The above will read the state from the pickle file and persist it into accessory.state. 9 | You can then pass accessory.state to the AccessoryDriver. 10 | """ 11 | import sys 12 | import pickle 13 | 14 | from pyhap.encoder import AccessoryEncoder 15 | 16 | def convert(fromfile, tofile): 17 | print("Unpickling...") 18 | with open(fromfile, "rb") as fp: 19 | acc = pickle.load(fp) 20 | print("Persiting new state...") 21 | with open(tofile, "w") as fp: 22 | AccessoryEncoder().persist(fp, acc) 23 | print("Done!") 24 | 25 | if __name__ == "__main__": 26 | convert(sys.argv[1], sys.argv[2]) 27 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run to build distribution packages and upload them to pypi 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | # Remove previous build 10 | if [ -n "$(ls | grep 'build')" ]; then 11 | rm -r build/ 12 | fi 13 | 14 | echo "=====================================" 15 | echo "= Generation source distribution =" 16 | echo "=====================================" 17 | python3 setup.py sdist 18 | 19 | echo "====================================" 20 | echo "= Generation build distribution =" 21 | echo "====================================" 22 | python3 setup.py bdist_wheel 23 | 24 | echo "=====================" 25 | echo "= Upload to pypi =" 26 | echo "=====================" 27 | python3 -m twine upload dist/* --skip-existing 28 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Setup development environment 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | echo "==============================" 10 | echo "= Installing dependencies =" 11 | echo "==============================" 12 | pip install -e .[qrcode] 13 | 14 | echo "===================================" 15 | echo "= Installing test dependencies =" 16 | echo "===================================" 17 | python3 -m pip install -r requirements_test.txt 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | 4 | [pycodestyle] 5 | max-line-length = 90 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | import pyhap.const as pyhap_const 5 | 6 | NAME = "HAP-python" 7 | DESCRIPTION = "HomeKit Accessory Protocol implementation in python" 8 | URL = "https://github.com/ikalchev/{}".format(NAME) 9 | AUTHOR = "Ivan Kalchev" 10 | 11 | 12 | PROJECT_URLS = { 13 | "Bug Reports": "{}/issues".format(URL), 14 | "Documentation": "http://hap-python.readthedocs.io/en/latest/", 15 | "Source": "{}/tree/master".format(URL), 16 | } 17 | 18 | 19 | MIN_PY_VERSION = ".".join(map(str, pyhap_const.REQUIRED_PYTHON_VER)) 20 | 21 | with open("README.md", "r", encoding="utf-8") as f: 22 | README = f.read() 23 | 24 | 25 | REQUIRES = [ 26 | "async_timeout", 27 | "cryptography", 28 | "chacha20poly1305-reuseable", 29 | "orjson>=3.7.2", 30 | "zeroconf>=0.36.2", 31 | "h11", 32 | ] 33 | 34 | 35 | setup( 36 | name=NAME, 37 | version=pyhap_const.__version__, 38 | description=DESCRIPTION, 39 | long_description=README, 40 | long_description_content_type="text/markdown", 41 | url=URL, 42 | packages=["pyhap"], 43 | include_package_data=True, 44 | project_urls=PROJECT_URLS, 45 | python_requires=">={}".format(MIN_PY_VERSION), 46 | install_requires=REQUIRES, 47 | license="Apache-2.0", 48 | license_file="LICENSE", 49 | classifiers=[ 50 | "Development Status :: 5 - Production/Stable", 51 | "Intended Audience :: Developers", 52 | "Intended Audience :: End Users/Desktop", 53 | "License :: OSI Approved :: Apache Software License", 54 | "Natural Language :: English", 55 | "Operating System :: OS Independent", 56 | "Programming Language :: Python :: 3.5", 57 | "Programming Language :: Python :: 3.6", 58 | "Programming Language :: Python :: 3.7", 59 | "Programming Language :: Python :: 3.8", 60 | "Programming Language :: Python :: 3.9", 61 | "Programming Language :: Python :: 3.10", 62 | "Topic :: Home Automation", 63 | "Topic :: Software Development :: Libraries :: Python Modules", 64 | ], 65 | extras_require={ 66 | "QRCode": ["base36", "pyqrcode"], 67 | }, 68 | ) 69 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock 3 | 4 | # Absolutize paths to coverage config and output file because tests that 5 | # spawn subprocesses also changes current working directory. 6 | _sourceroot = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | if "COV_CORE_CONFIG" in os.environ: 8 | os.environ["COVERAGE_FILE"] = os.path.join(_sourceroot, ".coverage") 9 | os.environ["COV_CORE_CONFIG"] = os.path.join( 10 | _sourceroot, os.environ["COV_CORE_CONFIG"] 11 | ) 12 | 13 | 14 | # Remove this when we drop python 3.5/3.6 support 15 | class AsyncMock(MagicMock): 16 | async def __call__( 17 | self, *args, **kwargs 18 | ): # pylint: disable=useless-super-delegation,invalid-overridden-method 19 | return super().__call__(*args, **kwargs) 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test fictures and mocks.""" 2 | 3 | import asyncio 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from pyhap.accessory_driver import AccessoryDriver 9 | from pyhap.loader import Loader 10 | 11 | from . import AsyncMock 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def mock_driver(): 16 | yield MockDriver() 17 | 18 | 19 | @pytest.fixture(name="async_zeroconf") 20 | def async_zc(): 21 | with patch("pyhap.accessory_driver.AsyncZeroconf") as mock_async_zeroconf: 22 | aiozc = mock_async_zeroconf.return_value 23 | aiozc.async_register_service = AsyncMock() 24 | aiozc.async_update_service = AsyncMock() 25 | aiozc.async_unregister_service = AsyncMock() 26 | aiozc.async_close = AsyncMock() 27 | yield aiozc 28 | 29 | 30 | @pytest.fixture 31 | def driver(async_zeroconf): 32 | try: 33 | loop = asyncio.get_event_loop() 34 | except RuntimeError: 35 | loop = asyncio.new_event_loop() 36 | asyncio.set_event_loop(loop) 37 | with patch( 38 | "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock 39 | ), patch( 40 | "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock 41 | ), patch( 42 | "pyhap.accessory_driver.AccessoryDriver.persist" 43 | ): 44 | yield AccessoryDriver(loop=loop) 45 | 46 | 47 | @pytest.fixture(autouse=True) 48 | def mock_local_address(): 49 | with patch("pyhap.util.get_local_address", return_value="127.0.0.1"): 50 | yield 51 | 52 | 53 | class MockDriver: 54 | def __init__(self): 55 | self.loader = Loader() 56 | 57 | def publish(self, data, client_addr=None, immediate=False): 58 | pass 59 | 60 | def add_job(self, target, *args): 61 | asyncio.new_event_loop().run_until_complete(target(*args)) 62 | -------------------------------------------------------------------------------- /tests/test_camera.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.camera.""" 2 | from unittest.mock import Mock, patch 3 | from uuid import UUID 4 | 5 | from pyhap import camera 6 | 7 | _OPTIONS = { 8 | "stream_count": 4, 9 | "video": { 10 | "codec": { 11 | "profiles": [ 12 | camera.VIDEO_CODEC_PARAM_PROFILE_ID_TYPES["BASELINE"], 13 | ], 14 | "levels": [ 15 | camera.VIDEO_CODEC_PARAM_LEVEL_TYPES["TYPE3_1"], 16 | ], 17 | }, 18 | "resolutions": [ 19 | [320, 240, 15], 20 | [1024, 768, 30], 21 | [640, 480, 30], 22 | [640, 360, 30], 23 | [480, 360, 30], 24 | [480, 270, 30], 25 | [320, 240, 30], 26 | [320, 180, 30], 27 | ], 28 | }, 29 | "audio": { 30 | "codecs": [ 31 | { 32 | "type": "OPUS", 33 | "samplerate": 24, 34 | }, 35 | {"type": "AAC-eld", "samplerate": 16}, 36 | ], 37 | }, 38 | "srtp": True, 39 | "address": "192.168.1.226", 40 | } 41 | 42 | 43 | def test_init(mock_driver): 44 | """Test that the camera init properly computes TLV values""" 45 | acc = camera.Camera(_OPTIONS, mock_driver, "Camera") 46 | 47 | management = acc.get_service("CameraRTPStreamManagement") 48 | assert management.unique_id is not None 49 | 50 | assert ( 51 | management.get_characteristic("SupportedRTPConfiguration").get_value() == "AgEA" 52 | ) 53 | assert ( 54 | management.get_characteristic("SupportedVideoStreamConfiguration").get_value() 55 | == "AX4BAQACCQMBAAEBAAIBAAMMAQJAAQIC8AADAg8AAwwBAgAEAgIAAwMCHgADDAECgAICAuA" 56 | "BAwIeAAMMAQKAAgICaAEDAh4AAwwBAuABAgJoAQMCHgADDAEC4AECAg4BAwIeAAMMAQJAAQ" 57 | "IC8AADAh4AAwwBAkABAgK0AAMCHgA=" 58 | ) 59 | assert ( 60 | management.get_characteristic("SupportedAudioStreamConfiguration").get_value() 61 | == "AQ4BAQMCCQEBAQIBAAMBAgEOAQECAgkBAQECAQADAQECAQA=" 62 | ) 63 | 64 | 65 | def test_setup_endpoints(mock_driver): 66 | """Test that the SetupEndpoint response is computed correctly""" 67 | set_endpoint_req = ( 68 | "ARCszGzBBWNFFY2pdLRQkAaRAxoBAQACDTE5Mi4xNjguMS4xMTQDAjPFBAKs1gQ" 69 | "lAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QEBAAUlAhAKRPSRVa" 70 | "qGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQEBAA==" 71 | ) 72 | 73 | set_endpoint_res = ( 74 | "ARCszGzBBWNFFY2pdLRQkAaRAgEAAxoBAQACDTE5Mi4xNjguMS4yMjYDAjPFBAK" 75 | "s1gQlAQEAAhDYlmCkyTBZQfxqFS3OnxVOAw4bQZm5NuoQjyanlqWA0QUlAQEAAh" 76 | "AKRPSRVaqGeNmESTIojxNiAw78WkjTLtGv0waWnLo9gQYBAQcBAQ==" 77 | ) 78 | 79 | acc = camera.Camera(_OPTIONS, mock_driver, "Camera") 80 | setup_endpoints = acc.get_service("CameraRTPStreamManagement").get_characteristic( 81 | "SetupEndpoints" 82 | ) 83 | setup_endpoints.client_update_value(set_endpoint_req) 84 | 85 | assert setup_endpoints.get_value()[:171] == set_endpoint_res[:171] 86 | 87 | 88 | def test_set_selected_stream_start_stop(mock_driver): 89 | """Test starting a stream request.""" 90 | # mocks for asyncio.Process 91 | async def communicate(): 92 | return (None, "stderr") 93 | 94 | async def wait(): 95 | pass 96 | 97 | process_mock = Mock() 98 | 99 | # Mock for asyncio.create_subprocess_exec 100 | async def subprocess_exec(*args, **kwargs): # pylint: disable=unused-argument 101 | process_mock.id = 42 102 | process_mock.communicate = communicate 103 | process_mock.wait = wait 104 | return process_mock 105 | 106 | selected_config_req = ( 107 | "ARUCAQEBEKzMbMEFY0UVjal0tFCQBpECNAEBAAIJAQEAAgEAAwEAAwsBAoAC" 108 | "AgJoAQMBHgQXAQFjAgQr66FSAwKEAAQEAAAAPwUCYgUDLAEBAgIMAQEBAgEA" 109 | "AwEBBAEeAxYBAW4CBMUInmQDAhgABAQAAKBABgENBAEA" 110 | ) 111 | 112 | session_id = UUID("accc6cc1-0563-4515-8da9-74b450900691") 113 | 114 | session_info = { 115 | "id": session_id, 116 | "stream_idx": 0, 117 | "address": "192.168.1.114", 118 | "v_port": 50483, 119 | "v_srtp_key": "2JZgpMkwWUH8ahUtzp8VThtBmbk26hCPJqeWpYDR", 120 | "a_port": 54956, 121 | "a_srtp_key": "CkT0kVWqhnjZhEkyKI8TYvxaSNMu0a/TBpacuj2B", 122 | "process": None, 123 | } 124 | 125 | acc = camera.Camera(_OPTIONS, mock_driver, "Camera") 126 | 127 | acc.sessions[session_id] = session_info 128 | 129 | patcher = patch("asyncio.create_subprocess_exec", new=subprocess_exec) 130 | patcher.start() 131 | 132 | acc.set_selected_stream_configuration(selected_config_req) 133 | 134 | assert acc.streaming_status == camera.STREAMING_STATUS["STREAMING"] 135 | 136 | selected_config_stop_req = "ARUCAQABEKzMbMEFY0UVjal0tFCQBpE=" 137 | acc.set_selected_stream_configuration(selected_config_stop_req) 138 | 139 | patcher.stop() 140 | 141 | assert session_id not in acc.sessions 142 | assert process_mock.terminate.called 143 | assert acc.streaming_status == camera.STREAMING_STATUS["AVAILABLE"] 144 | -------------------------------------------------------------------------------- /tests/test_encoder.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.encoder.""" 2 | import json 3 | import tempfile 4 | import uuid 5 | 6 | from cryptography.hazmat.primitives import serialization 7 | from cryptography.hazmat.primitives.asymmetric import ed25519 8 | 9 | from pyhap import encoder 10 | from pyhap.const import HAP_PERMISSIONS 11 | from pyhap.state import State 12 | from pyhap.util import generate_mac 13 | 14 | 15 | def test_persist_and_load(): 16 | """Stores an Accessory and then loads the stored state into another 17 | Accessory. Tests if the two accessories have the same property values. 18 | """ 19 | mac = generate_mac() 20 | _pk = ed25519.Ed25519PrivateKey.generate() 21 | sample_client_pk = _pk.public_key() 22 | state = State(mac=mac) 23 | admin_client_uuid = uuid.uuid1() 24 | admin_client_bytes = str(admin_client_uuid).upper().encode("utf-8") 25 | state.add_paired_client( 26 | admin_client_bytes, 27 | sample_client_pk.public_bytes( 28 | encoding=serialization.Encoding.Raw, 29 | format=serialization.PublicFormat.Raw, 30 | ), 31 | HAP_PERMISSIONS.ADMIN, 32 | ) 33 | assert state.is_admin(admin_client_uuid) 34 | user_client_uuid = uuid.uuid1() 35 | user_client_bytes = str(user_client_uuid).upper().encode("utf-8") 36 | state.add_paired_client( 37 | user_client_bytes, 38 | sample_client_pk.public_bytes( 39 | encoding=serialization.Encoding.Raw, 40 | format=serialization.PublicFormat.Raw, 41 | ), 42 | HAP_PERMISSIONS.USER, 43 | ) 44 | assert not state.is_admin(user_client_uuid) 45 | config_loaded = State() 46 | config_loaded.config_version += 2 # change the default state. 47 | enc = encoder.AccessoryEncoder() 48 | with tempfile.TemporaryFile(mode="r+") as fp: 49 | enc.persist(fp, state) 50 | fp.seek(0) 51 | enc.load_into(fp, config_loaded) 52 | 53 | assert state.mac == config_loaded.mac 54 | assert state.private_key.private_bytes( 55 | encoding=serialization.Encoding.Raw, 56 | format=serialization.PrivateFormat.Raw, 57 | encryption_algorithm=serialization.NoEncryption(), 58 | ) == config_loaded.private_key.private_bytes( 59 | encoding=serialization.Encoding.Raw, 60 | format=serialization.PrivateFormat.Raw, 61 | encryption_algorithm=serialization.NoEncryption(), 62 | ) 63 | assert state.public_key.public_bytes( 64 | encoding=serialization.Encoding.Raw, 65 | format=serialization.PublicFormat.Raw, 66 | ) == config_loaded.public_key.public_bytes( 67 | encoding=serialization.Encoding.Raw, 68 | format=serialization.PublicFormat.Raw, 69 | ) 70 | assert state.config_version == config_loaded.config_version 71 | assert state.paired_clients == config_loaded.paired_clients 72 | assert state.client_properties == config_loaded.client_properties 73 | 74 | 75 | def test_migration_to_include_client_properties(): 76 | """Verify we build client properties if its missing since it was not present in older versions.""" 77 | mac = generate_mac() 78 | _pk = ed25519.Ed25519PrivateKey.generate() 79 | sample_client_pk = _pk.public_key() 80 | state = State(mac=mac) 81 | admin_client_uuid = uuid.uuid1() 82 | admin_client_bytes = str(admin_client_uuid).upper().encode("utf-8") 83 | state.add_paired_client( 84 | admin_client_bytes, 85 | sample_client_pk.public_bytes( 86 | encoding=serialization.Encoding.Raw, 87 | format=serialization.PublicFormat.Raw, 88 | ), 89 | HAP_PERMISSIONS.ADMIN, 90 | ) 91 | assert state.is_admin(admin_client_uuid) 92 | user_client_uuid = uuid.uuid1() 93 | user_client_bytes = str(user_client_uuid).upper().encode("utf-8") 94 | state.add_paired_client( 95 | user_client_bytes, 96 | sample_client_pk.public_bytes( 97 | encoding=serialization.Encoding.Raw, 98 | format=serialization.PublicFormat.Raw, 99 | ), 100 | HAP_PERMISSIONS.USER, 101 | ) 102 | assert not state.is_admin(user_client_uuid) 103 | 104 | config_loaded = State() 105 | config_loaded.config_version += 2 # change the default state. 106 | enc = encoder.AccessoryEncoder() 107 | with tempfile.TemporaryFile(mode="r+") as fp: 108 | enc.persist(fp, state) 109 | fp.seek(0) 110 | loaded = json.load(fp) 111 | fp.seek(0) 112 | del loaded["client_properties"] 113 | json.dump(loaded, fp) 114 | fp.truncate() 115 | fp.seek(0) 116 | enc.load_into(fp, config_loaded) 117 | 118 | # When client_permissions are missing, all clients 119 | # are imported as admins for backwards compatibility 120 | assert config_loaded.is_admin(admin_client_uuid) 121 | assert config_loaded.is_admin(user_client_uuid) 122 | -------------------------------------------------------------------------------- /tests/test_hap_crypto.py: -------------------------------------------------------------------------------- 1 | """Tests for the HAPCrypto.""" 2 | 3 | 4 | from pyhap import hap_crypto 5 | 6 | 7 | def test_round_trip(): 8 | """Test we can roundtrip data by using the same cipher info.""" 9 | plaintext = b"bobdata1232" * 1000 10 | key = b"mykeydsfdsfdsfsdfdsfsdf" 11 | 12 | crypto = hap_crypto.HAPCrypto(key) 13 | # Switch the cipher info to the same to allow 14 | # round trip 15 | crypto.OUT_CIPHER_INFO = crypto.IN_CIPHER_INFO 16 | crypto.reset(key) 17 | 18 | encrypted = bytearray(b"".join(crypto.encrypt(plaintext))) 19 | 20 | # Receive no data 21 | assert crypto.decrypt() == b"" 22 | 23 | # Receive not a whole block 24 | crypto.receive_data(encrypted[:50]) 25 | assert crypto.decrypt() == b"" 26 | 27 | del encrypted[:50] 28 | # Receive the rest of the block 29 | crypto.receive_data(encrypted) 30 | 31 | decrypted = crypto.decrypt() 32 | 33 | assert decrypted == plaintext 34 | -------------------------------------------------------------------------------- /tests/test_hap_server.py: -------------------------------------------------------------------------------- 1 | """Tests for the HAPServer.""" 2 | 3 | import asyncio 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from pyhap import hap_server 9 | from pyhap.accessory import Accessory 10 | from pyhap.accessory_driver import AccessoryDriver 11 | from pyhap.hap_protocol import HAPServerProtocol 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_we_can_start_stop(driver): 16 | """Test we can start and stop.""" 17 | loop = asyncio.get_event_loop() 18 | addr_info = ("0.0.0.0", None) 19 | client_1_addr_info = ("1.2.3.4", 44433) 20 | client_2_addr_info = ("4.5.6.7", 33444) 21 | 22 | server = hap_server.HAPServer(addr_info, driver) 23 | await server.async_start(loop) 24 | server.connections[client_1_addr_info] = MagicMock() 25 | server.connections[client_2_addr_info] = MagicMock() 26 | server.async_stop() 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_we_can_connect(): 31 | """Test we can start, connect, and stop.""" 32 | loop = asyncio.get_event_loop() 33 | with patch("pyhap.accessory_driver.AsyncZeroconf"), patch( 34 | "pyhap.accessory_driver.AccessoryDriver.persist" 35 | ): 36 | driver = AccessoryDriver(loop=loop) 37 | 38 | driver.add_accessory(Accessory(driver, "TestAcc")) 39 | 40 | addr_info = ("0.0.0.0", None) 41 | server = hap_server.HAPServer(addr_info, driver) 42 | await server.async_start(loop) 43 | sock = server.server.sockets[0] 44 | assert not server.connections 45 | _, port = sock.getsockname() 46 | _, writer = await asyncio.open_connection("127.0.0.1", port) 47 | # flush out any call_soon 48 | for _ in range(3): 49 | await asyncio.sleep(0) 50 | assert server.connections 51 | server.async_stop() 52 | writer.close() 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_idle_connection_cleanup(): 57 | """Test we cleanup idle connections.""" 58 | loop = asyncio.get_event_loop() 59 | addr_info = ("0.0.0.0", None) 60 | client_1_addr_info = ("1.2.3.4", 44433) 61 | 62 | with patch.object(hap_server, "IDLE_CONNECTION_CHECK_INTERVAL_SECONDS", 0), patch( 63 | "pyhap.accessory_driver.AsyncZeroconf" 64 | ), patch("pyhap.accessory_driver.AccessoryDriver.persist"), patch( 65 | "pyhap.accessory_driver.AccessoryDriver.load" 66 | ): 67 | driver = AccessoryDriver(loop=loop) 68 | server = hap_server.HAPServer(addr_info, driver) 69 | await server.async_start(loop) 70 | check_idle = MagicMock() 71 | server.connections[client_1_addr_info] = MagicMock(check_idle=check_idle) 72 | for _ in range(3): 73 | await asyncio.sleep(0) 74 | assert check_idle.called 75 | check_idle.reset_mock() 76 | for _ in range(3): 77 | await asyncio.sleep(0) 78 | assert check_idle.called 79 | server.async_stop() 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_push_event(driver): 84 | """Test we can create and send an event.""" 85 | addr_info = ("1.2.3.4", 1234) 86 | server = hap_server.HAPServer(("127.0.01", 5555), driver) 87 | server.loop = asyncio.get_event_loop() 88 | hap_events = [] 89 | 90 | def _save_event(hap_event): 91 | hap_events.append(hap_event) 92 | 93 | hap_server_protocol = HAPServerProtocol( 94 | server.loop, server.connections, server.accessory_handler 95 | ) 96 | hap_server_protocol.write = _save_event 97 | hap_server_protocol.peername = addr_info 98 | server.accessory_handler.topics["1.33"] = {addr_info} 99 | server.accessory_handler.topics["2.33"] = {addr_info} 100 | server.accessory_handler.topics["3.33"] = {addr_info} 101 | 102 | assert server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info) is False 103 | await asyncio.sleep(0) 104 | server.connections[addr_info] = hap_server_protocol 105 | 106 | assert ( 107 | server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info, True) 108 | is True 109 | ) 110 | assert ( 111 | server.push_event({"aid": 2, "iid": 33, "value": False}, addr_info, True) 112 | is True 113 | ) 114 | assert ( 115 | server.push_event({"aid": 3, "iid": 33, "value": False}, addr_info, True) 116 | is True 117 | ) 118 | 119 | await asyncio.sleep(0) 120 | assert hap_events == [ 121 | b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 120\r\n\r\n" 122 | b'{"characteristics":[{"aid":1,"iid":33,"value":false},' 123 | b'{"aid":2,"iid":33,"value":false},{"aid":3,"iid":33,"value":false}]}' 124 | ] 125 | 126 | hap_events = [] 127 | assert ( 128 | server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info, False) 129 | is True 130 | ) 131 | assert ( 132 | server.push_event({"aid": 2, "iid": 33, "value": False}, addr_info, False) 133 | is True 134 | ) 135 | assert ( 136 | server.push_event({"aid": 3, "iid": 33, "value": False}, addr_info, False) 137 | is True 138 | ) 139 | 140 | await asyncio.sleep(0) 141 | assert not hap_events 142 | 143 | # Ensure that a the event is not sent if its unsubscribed during 144 | # the coalesce delay 145 | server.accessory_handler.topics["1.33"].remove(addr_info) 146 | 147 | await asyncio.sleep(0.55) 148 | assert hap_events == [ 149 | b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 87\r\n\r\n" 150 | b'{"characteristics":[{"aid":2,"iid":33,"value":false},{"aid":3,"iid":33,"value":false}]}' 151 | ] 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_push_event_overwrites_old_pending_events(driver): 156 | """Test push event overwrites old events in the event queue. 157 | 158 | iOS 15 had a breaking change where events are no longer processed 159 | in order. We want to make sure when we send an event message we 160 | only send the latest state and overwrite all the previous states 161 | for the same AID/IID that are in the queue when the state changes 162 | before the event is sent. 163 | """ 164 | addr_info = ("1.2.3.4", 1234) 165 | server = hap_server.HAPServer(("127.0.01", 5555), driver) 166 | server.loop = asyncio.get_event_loop() 167 | hap_events = [] 168 | 169 | def _save_event(hap_event): 170 | hap_events.append(hap_event) 171 | 172 | hap_server_protocol = HAPServerProtocol( 173 | server.loop, server.connections, server.accessory_handler 174 | ) 175 | hap_server_protocol.write = _save_event 176 | hap_server_protocol.peername = addr_info 177 | server.accessory_handler.topics["1.33"] = {addr_info} 178 | server.accessory_handler.topics["2.33"] = {addr_info} 179 | server.connections[addr_info] = hap_server_protocol 180 | 181 | assert ( 182 | server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info, True) 183 | is True 184 | ) 185 | assert ( 186 | server.push_event({"aid": 1, "iid": 33, "value": True}, addr_info, True) is True 187 | ) 188 | assert ( 189 | server.push_event({"aid": 2, "iid": 33, "value": False}, addr_info, True) 190 | is True 191 | ) 192 | 193 | await asyncio.sleep(0) 194 | assert hap_events == [ 195 | b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 86\r\n\r\n" 196 | b'{"characteristics":[{"aid":1,"iid":33,"value":true},' 197 | b'{"aid":2,"iid":33,"value":false}]}' 198 | ] 199 | -------------------------------------------------------------------------------- /tests/test_hsrp.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.hsrp.""" 2 | 3 | # pylint: disable=line-too-long, pointless-string-statement 4 | 5 | import hashlib 6 | 7 | from pyhap.hsrp import Server 8 | from pyhap.params import get_srp_context 9 | from pyhap.util import long_to_bytes 10 | 11 | DUMMY_A = b"Ve\xce\xd4\x90LExKD\x9d7\x16\\@\xb6\xb8\x9f\x01\x1a]\x86\xa4\x1c" 12 | " \x13\xaa\xc0\x17=\x1f\xafPx\xea/\x01Q\xc8hw\x06\x03\xc8O\x89|\x8d4\xa8\x85" 13 | "\xd2\xfb:\x0e\xb6PT2V\xb2\xa9\xca\x0bL\x97\r\xee\x88\xbc\xef\x8d\xa6|\xeb \xdc" 14 | "\x80.\x92\xe0\xe5s\xf5\xf2;\x89LN\\^\x8c\xd1\x00\x99U]]/^\xe9\x1b\xe2\xf3\x1a|" 15 | "\xc6\x85Q\x95T`b\x8e\x04\xc2\x99\xdd\xdfp\x98\x85\x13\xe5\xaf\xdf\xe0Tm\xa3t\xfe" 16 | "\xc1_V\x04\xab\xb1\x96\xa8\x9cw\xa40\x95\x8d\x9f|\xf7.\x90\xd2{L\xcc*\xcb\xdde" 17 | "\x81\x14\x14\xc97\xe7\xa0177\x1b\xe0\xb0\x19\x0f\xf1\x1e;\xc4\xc9\x07\x05zN\xb3" 18 | "!y\xf2\x9e\xa4N\xbeswxx\x13\x82\x18\xccU\xb4\xec\x7f{\x8eo\x86\x0b\xa6\xff\x9b" 19 | "\xbcY(0\x16\xba$\x9d\xb9\x8d}\xe5f\x0c)\\\x8b\\\xef\xfd\x0coEg\x13\x13\xa2q\xb9" 20 | "\xe5\x8a\xfd\x97\x97\xcb\xb1\x15\xd5\xc2\xd7\x07\x91A\xdf\xd7" 21 | 22 | 23 | def test_srp_basic(): 24 | ctx = get_srp_context(3072, hashlib.sha512, 16) 25 | b = 191304991611724068381190663629083136274 26 | s = long_to_bytes(227710976386754876301088769828140156049) 27 | verifier = Server(ctx, b"Pair-Setup", b"123-45-543", s=s, b=b) 28 | verifier.set_A(DUMMY_A) 29 | 30 | assert ( 31 | verifier.k 32 | == 8891118944006259431156568541843809053371474718154946070525699599564743247786811275097952247025117806925219847643897478119979876683245412022290811230509536 33 | ) 34 | assert ( 35 | verifier.get_session_key() 36 | == 7776966363435436003301596680621751479448170893927097125414524508260409807602643597201957531811064094375727460485526402929080964822225092649470633176208468 37 | ) 38 | assert ( 39 | verifier.M 40 | == b"\xafnZ\xef\x8e\x84\xbe\xaa\xe2M}5'\x0c\xb8\xb9\x07\x13\xa3t\xbbfOL\x059\xa3T\xaf\x021\x05\xf7*\xdb]\xa3]\x92\xbc\xa7\x0ed\xc1C\x88W\x0b\xe7n\xe6|\x1e\xb4\xf9pUc\xa2\x8d\x05\xd7\xabI" 41 | ) 42 | assert ( 43 | verifier.S 44 | == 74327940101639752536537640881643581886247890122995727869092918508085397047960192114187184206420245499227933354038262980545757154896143196917567791395562849790585173129051928488506985432588320936161016609993624725221069849383124728580710793131421162926844621384309691065416908669855286020750380619018007734494245389837285359061649585082978114606737696983003789452193299203880220013003551748645087934186574940836315605161763958706985646740794424371115818479937015467439653789667600114913036877616558029128521276071759153575011083182650027094873442901697309464533625147028860476977419766721379872518101123122550406587162809198793634217353529574423908555799363233330194347012490634061830786590780000201696990820985363093141614397601285773980430681705777477946555312165250133963931282621724675380164859592461132141730419315498467050491890312826221069184134326282895963295397215898192608240385050625017941322853973472354023693355 45 | ) 46 | assert verifier.get_challenge() == ( 47 | s, 48 | 2149981971605054722971448928513305504744266471818820776094113337432031877014471028912971746321748621185649001880451734094103311676264091997241948096711710461140721738956497494552388614895831596671069609694220554015991913746528757304239759620571367574036184864989138266792823575841594621160010011666017298902208272126405229578664943728094068949021795802799552486045670159066273942547651088762352104942364707580142387716636468281068738042936130578774565386637668610429058884417819388838110075674266297699354845325023954873162742733169560666501210723876454859556564325607870517213063038111644227553599978606540729093082921723443122696487068510228710655880466038292327450357013882323502992655150615829432843408599038481983277372215619348128412279375677793332715557041679298014663382481619951610899087031959653365603032111634191603851554865349816117884573658915813848292512124719015181912892538210471183790840676306564839828444134, 49 | ) 50 | assert verifier.b == b 51 | assert ( 52 | verifier.v 53 | == 1800954445588585461785592179273284825501707649217210015435034845050179016324355419526711292364866248346582448660643272322280999760562622718989053886869428917425675795172391329924178337579968214001782222575897907780437717763112406095878356902641567396545009429496128133564692965499069074320017151157469160990771527712530637370897276672652870613312504255873634362188551282649472569433062597795005057270622772410668342950279555516133010272639201733492626622809480021268951287298118968011031850511105359580984350020671780470982743318615303989055956125558514263378948829479434245711743458681522240763520911255733079164391662778946744155477806679057949726211652108387739564473209550264487697151825509058193841809273482575660658239177704074882302955007248950743262054925817705066654613816236610736311934089570249355454459951577900707115340781119430461780455828980205046091360390327787803271426555681638302650021637121212829077894589 54 | ) 55 | assert ( 56 | verifier.N 57 | == 5809605995369958062791915965639201402176612226902900533702900882779736177890990861472094774477339581147373410185646378328043729800750470098210924487866935059164371588168047540943981644516632755067501626434556398193186628990071248660819361205119793693985433297036118232914410171876807536457391277857011849897410207519105333355801121109356897459426271845471397952675959440793493071628394122780510124618488232602464649876850458861245784240929258426287699705312584509625419513463605155428017165714465363094021609290561084025893662561222573202082865797821865270991145082200656978177192827024538990239969175546190770645685893438011714430426409338676314743571154537142031573004276428701433036381801705308659830751190352946025482059931306571004727362479688415574702596946457770284148435989129632853918392117997472632693078113129886487399347796982772784615865232621289656944284216824611318709764535152507354116344703769998514148343807 58 | ) 59 | assert verifier.g == 5 60 | 61 | assert ( 62 | verifier.verify(verifier.M) 63 | == b"\xe1\x00\xcf\xe2\x98\xaf\x1e\x02tb\x0b\xfclKF\xee\x1b\x80\xf6\x90\xb7\x8a\x9f\x133y#>\x8d/\xc1\x88\x93\x8eh\tN\x9b\xda\xc2-\x1a(\xe3\xca\x0bf\xf3\xc4\xca\xc4\xec\xfa/\xec\xb7\x16\x81\xdd%\xc9i\xf9\x90" 64 | ) 65 | assert verifier.verify(b"wrong") is None 66 | -------------------------------------------------------------------------------- /tests/test_iid_manager.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.iid_manager module.""" 2 | from unittest.mock import Mock 3 | 4 | from pyhap.iid_manager import IIDManager 5 | 6 | 7 | def get_iid_manager(): 8 | """Return an IIDManager and a mock object for testing.""" 9 | obj_a = Mock() 10 | iid_manager = IIDManager() 11 | iid_manager.assign(obj_a) 12 | return iid_manager, obj_a 13 | 14 | 15 | def test_assign(): 16 | """Test if iids are assigned correctly.""" 17 | iid_manager, obj_a = get_iid_manager() 18 | obj_b = Mock() 19 | iid_manager.assign(obj_b) 20 | assert iid_manager.iids == {obj_a: 1, obj_b: 2} 21 | iid_manager.assign(obj_a) 22 | assert iid_manager.iids == {obj_a: 1, obj_b: 2} 23 | 24 | 25 | def test_assign_order_with_remove(): 26 | """Test if iids are assigned correctly, with removed objects.""" 27 | iid_manager, obj_a = get_iid_manager() 28 | assert iid_manager.remove_obj(obj_a) == 1 29 | iid_manager.assign(obj_a) 30 | assert iid_manager.iids == {obj_a: 2} 31 | 32 | 33 | def test_get_obj(): 34 | """Test if the right object is returned for a given iid.""" 35 | iid_manager, obj_a = get_iid_manager() 36 | assert iid_manager.get_obj(1) == obj_a 37 | assert iid_manager.get_obj(0) is None 38 | 39 | 40 | def test_get_iid(): 41 | """Test if the right iid is returned for a given object.""" 42 | iid_manager, obj_a = get_iid_manager() 43 | assert iid_manager.get_iid(obj_a) == 1 44 | assert iid_manager.get_iid(Mock()) is None 45 | 46 | 47 | def test_remove_obj(): 48 | """Test if entry with object is successfully removed.""" 49 | iid_manager, obj_a = get_iid_manager() 50 | assert iid_manager.remove_obj(Mock()) is None 51 | assert iid_manager.remove_obj(obj_a) == 1 52 | 53 | 54 | def test_remove_iid(): 55 | """Test if entry with iid is successfully removed.""" 56 | iid_manager, obj_a = get_iid_manager() 57 | assert iid_manager.remove_iid(0) is None 58 | assert iid_manager.remove_iid(1) == obj_a 59 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.loader.""" 2 | import pytest 3 | 4 | from pyhap import CHARACTERISTICS_FILE, SERVICES_FILE 5 | from pyhap.characteristic import Characteristic 6 | from pyhap.loader import Loader, get_loader 7 | from pyhap.service import Service 8 | 9 | 10 | def test_loader_char(): 11 | """Test if method returns a Characteristic object.""" 12 | loader = Loader() 13 | 14 | with pytest.raises(KeyError): 15 | loader.get_char("Not a char") 16 | 17 | char_name = loader.get_char("Name") 18 | assert char_name is not None 19 | assert isinstance(char_name, Characteristic) 20 | 21 | 22 | def test_loader_get_char_error(): 23 | """Test if errors are thrown for invalid dictionary entries.""" 24 | loader = Loader.from_dict(char_dict={"Char": None}) 25 | assert loader.char_types == {"Char": None} 26 | assert loader.serv_types == {} 27 | json_dicts = ( 28 | {"Format": "int", "Permissions": "read"}, 29 | {"Format": "int", "UUID": "123456"}, 30 | {"Permissions": "read", "UUID": "123456"}, 31 | ) 32 | 33 | for case in json_dicts: 34 | loader.char_types["Char"] = case 35 | with pytest.raises(KeyError): 36 | loader.get_char("Char") 37 | 38 | 39 | def test_loader_service(): 40 | """Test if method returns a Service object.""" 41 | loader = Loader() 42 | 43 | with pytest.raises(KeyError): 44 | loader.get_service("Not a service") 45 | 46 | serv_acc_info = loader.get_service("AccessoryInformation") 47 | assert serv_acc_info is not None 48 | assert isinstance(serv_acc_info, Service) 49 | 50 | 51 | def test_loader_service_error(): 52 | """Test if errors are thrown for invalid dictionary entries.""" 53 | loader = Loader.from_dict(serv_dict={"Service": None}) 54 | assert loader.char_types == {} 55 | assert loader.serv_types == {"Service": None} 56 | json_dicts = ({"RequiredCharacteristics": ["Char 1", "Char 2"]}, {"UUID": "123456"}) 57 | 58 | for case in json_dicts: 59 | loader.serv_types["Service"] = case 60 | with pytest.raises(KeyError): 61 | loader.get_service("Service") 62 | 63 | 64 | def test_get_loader(): 65 | """Test if method returns the preloaded loader object.""" 66 | loader = get_loader() 67 | assert isinstance(loader, Loader) 68 | assert loader.char_types is not ({} or None) 69 | assert loader.serv_types is not ({} or None) 70 | 71 | loader2 = Loader(path_char=CHARACTERISTICS_FILE, path_service=SERVICES_FILE) 72 | assert loader.char_types == loader2.char_types 73 | assert loader.serv_types == loader2.serv_types 74 | 75 | assert get_loader() == loader 76 | -------------------------------------------------------------------------------- /tests/test_service.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.service.""" 2 | from unittest.mock import Mock, call, patch 3 | from uuid import uuid1 4 | 5 | import pytest 6 | 7 | from pyhap.characteristic import ( 8 | HAP_FORMAT_INT, 9 | HAP_PERMISSION_READ, 10 | PROP_FORMAT, 11 | PROP_PERMISSIONS, 12 | Characteristic, 13 | ) 14 | from pyhap.service import Service 15 | 16 | CHAR_PROPS = { 17 | PROP_FORMAT: HAP_FORMAT_INT, 18 | PROP_PERMISSIONS: HAP_PERMISSION_READ, 19 | } 20 | 21 | 22 | def get_chars(): 23 | """Return example char objects.""" 24 | c1 = Characteristic("Char 1", uuid1(), CHAR_PROPS) 25 | c2 = Characteristic("Char 2", uuid1(), CHAR_PROPS) 26 | return [c1, c2] 27 | 28 | 29 | def test_repr(): 30 | """Test service representation.""" 31 | service = Service(uuid1(), "TestService", unique_id="my_service_unique_id") 32 | service.characteristics = [get_chars()[0]] 33 | assert ( 34 | repr(service) 35 | == "" 36 | ) 37 | 38 | 39 | def test_service_with_unique_id(): 40 | """Test service with unique_id.""" 41 | service = Service(uuid1(), "TestService", unique_id="service_unique_id") 42 | assert service.unique_id == "service_unique_id" 43 | 44 | 45 | def test_add_characteristic(): 46 | """Test adding characteristics to a service.""" 47 | service = Service(uuid1(), "Test Service") 48 | chars = get_chars() 49 | service.add_characteristic(*chars) 50 | for char_service, char_original in zip(service.characteristics, chars): 51 | assert char_service == char_original 52 | 53 | service.add_characteristic(chars[0]) 54 | assert len(service.characteristics) == 2 55 | 56 | 57 | def test_get_characteristic(): 58 | """Test getting a characteristic from a service.""" 59 | service = Service(uuid1(), "Test Service") 60 | chars = get_chars() 61 | service.characteristics = chars 62 | assert service.get_characteristic("Char 1") == chars[0] 63 | with pytest.raises(ValueError): 64 | service.get_characteristic("Not found") 65 | 66 | 67 | def test_configure_char(): 68 | """Test preconfiguring a characteristic from a service.""" 69 | pyhap_char = "pyhap.characteristic.Characteristic" 70 | 71 | service = Service(uuid1(), "Test Service") 72 | chars = get_chars() 73 | service.characteristics = chars 74 | 75 | with pytest.raises(ValueError): 76 | service.configure_char("Char not found") 77 | assert service.configure_char("Char 1") == chars[0] 78 | 79 | with patch(pyhap_char + ".override_properties") as mock_override_prop, patch( 80 | pyhap_char + ".set_value" 81 | ) as mock_set_value: 82 | service.configure_char("Char 1") 83 | mock_override_prop.assert_not_called() 84 | mock_set_value.assert_not_called() 85 | assert service.get_characteristic("Char 1").setter_callback is None 86 | 87 | with patch(pyhap_char + ".override_properties") as mock_override_prop: 88 | new_properties = {"Format": "string"} 89 | new_valid_values = {0: "on", 1: "off"} 90 | service.configure_char("Char 1", properties=new_properties) 91 | mock_override_prop.assert_called_with(new_properties, None) 92 | service.configure_char("Char 1", valid_values=new_valid_values) 93 | mock_override_prop.assert_called_with(None, new_valid_values) 94 | service.configure_char( 95 | "Char 1", properties=new_properties, valid_values=new_valid_values 96 | ) 97 | mock_override_prop.assert_called_with(new_properties, new_valid_values) 98 | 99 | with patch(pyhap_char + ".set_value") as mock_set_value: 100 | new_value = 1 101 | service.configure_char("Char 1", value=new_value) 102 | mock_set_value.assert_called_with(1, should_notify=False) 103 | 104 | new_setter_callback = "Test callback" 105 | service.configure_char("Char 1", setter_callback=new_setter_callback) 106 | assert service.get_characteristic("Char 1").setter_callback == new_setter_callback 107 | 108 | 109 | def test_is_primary_service(): 110 | """Test setting is_primary_service on a service.""" 111 | service = Service(uuid1(), "Test Service") 112 | 113 | assert service.is_primary_service is None 114 | 115 | service.is_primary_service = True 116 | assert service.is_primary_service is True 117 | 118 | service.is_primary_service = False 119 | assert service.is_primary_service is False 120 | 121 | 122 | def test_add_linked_service(): 123 | """Test adding linked service to a service.""" 124 | service = Service(uuid1(), "Test Service") 125 | assert len(service.linked_services) == 0 126 | 127 | linked_service = Service(uuid1(), "Test Linked Service") 128 | service.broker = Mock() 129 | service.add_linked_service(linked_service) 130 | 131 | assert len(service.linked_services) == 1 132 | assert service.linked_services[0] == linked_service 133 | 134 | 135 | def test_to_HAP(): 136 | """Test created HAP representation of a service.""" 137 | uuid = uuid1() 138 | pyhap_char_to_HAP = "pyhap.characteristic.Characteristic.to_HAP" 139 | 140 | service = Service(uuid, "Test Service") 141 | service.characteristics = get_chars() 142 | with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( 143 | service, "broker" 144 | ) as mock_broker: 145 | mock_iid = mock_broker.iid_manager.get_iid 146 | mock_iid.return_value = 2 147 | mock_char_HAP.side_effect = ("Char 1", "Char 2") 148 | hap_repr = service.to_HAP() 149 | mock_iid.assert_called_with(service) 150 | 151 | assert hap_repr == { 152 | "iid": 2, 153 | "type": str(uuid).upper(), 154 | "characteristics": ["Char 1", "Char 2"], 155 | } 156 | 157 | 158 | def test_linked_service_to_HAP(): 159 | """Test created HAP representation of a service.""" 160 | uuid = uuid1() 161 | pyhap_char_to_HAP = "pyhap.characteristic.Characteristic.to_HAP" 162 | 163 | service = Service(uuid, "Test Service") 164 | linked_service = Service(uuid1(), "Test Linked Service") 165 | service.broker = Mock() 166 | service.add_linked_service(linked_service) 167 | service.characteristics = get_chars() 168 | with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( 169 | service, "broker" 170 | ) as mock_broker, patch.object(linked_service, "broker") as mock_linked_broker: 171 | mock_iid = mock_broker.iid_manager.get_iid 172 | mock_iid.return_value = 2 173 | mock_linked_iid = mock_linked_broker.iid_manager.get_iid 174 | mock_linked_iid.return_value = 3 175 | mock_char_HAP.side_effect = ("Char 1", "Char 2") 176 | hap_repr = service.to_HAP() 177 | mock_iid.assert_called_with(service) 178 | assert hap_repr == { 179 | "iid": 2, 180 | "type": str(uuid).upper(), 181 | "characteristics": ["Char 1", "Char 2"], 182 | "linked": [mock_linked_iid()], 183 | } 184 | # Verify we can readd it without dupes 185 | service.add_linked_service(linked_service) 186 | assert hap_repr == { 187 | "iid": 2, 188 | "type": str(uuid).upper(), 189 | "characteristics": ["Char 1", "Char 2"], 190 | "linked": [mock_linked_iid()], 191 | } 192 | 193 | 194 | def test_is_primary_service_to_HAP(): 195 | """Test created HAP representation of primary service.""" 196 | uuid = uuid1() 197 | pyhap_char_to_HAP = "pyhap.characteristic.Characteristic.to_HAP" 198 | 199 | service = Service(uuid, "Test Service") 200 | service.characteristics = get_chars() 201 | service.is_primary_service = True 202 | with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object( 203 | service, "broker" 204 | ) as mock_broker: 205 | mock_iid = mock_broker.iid_manager.get_iid 206 | mock_iid.return_value = 2 207 | mock_char_HAP.side_effect = ("Char 1", "Char 2") 208 | hap_repr = service.to_HAP() 209 | mock_iid.assert_called_with(service) 210 | 211 | assert hap_repr == { 212 | "iid": 2, 213 | "type": str(uuid).upper(), 214 | "characteristics": ["Char 1", "Char 2"], 215 | "primary": True, 216 | } 217 | 218 | 219 | def test_from_dict(): 220 | """Test creating a service from a dictionary.""" 221 | uuid = uuid1() 222 | chars = get_chars() 223 | mock_char_loader = Mock() 224 | mock_char_loader.get_char.side_effect = chars 225 | 226 | json_dict = { 227 | "UUID": str(uuid), 228 | "RequiredCharacteristics": { 229 | "Char 1", 230 | "Char 2", 231 | }, 232 | } 233 | 234 | service = Service.from_dict("Test Service", json_dict, mock_char_loader) 235 | assert service.display_name == "Test Service" 236 | assert service.type_id == uuid 237 | assert service.characteristics == chars 238 | 239 | mock_char_loader.get_char.assert_has_calls( 240 | [call("Char 1"), call("Char 2")], any_order=True 241 | ) 242 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | """Test for pyhap.state.""" 2 | from unittest.mock import patch 3 | from uuid import UUID 4 | 5 | from cryptography.hazmat.primitives.asymmetric import ed25519 6 | import pytest 7 | 8 | from pyhap.const import CLIENT_PROP_PERMS, HAP_PERMISSIONS 9 | from pyhap.state import State 10 | 11 | CLIENT_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1") 12 | CLIENT_UUID_BYTES = str(CLIENT_UUID).upper().encode("utf-8") 13 | CLIENT2_UUID = UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c2") 14 | CLIENT2_UUID_BYTES = str(CLIENT2_UUID).upper().encode("utf-8") 15 | 16 | 17 | def test_setup(): 18 | """Test if State class is setup correctly.""" 19 | with pytest.raises(TypeError): 20 | State("invalid_argument") 21 | 22 | addr = "172.0.0.1" 23 | mac = "00:00:00:00:00:00" 24 | pin = b"123-45-678" 25 | port = 11111 26 | 27 | private_key = ed25519.Ed25519PrivateKey.generate() 28 | 29 | with patch("pyhap.util.get_local_address") as mock_local_addr, patch( 30 | "pyhap.util.generate_mac" 31 | ) as mock_gen_mac, patch("pyhap.util.generate_pincode") as mock_gen_pincode, patch( 32 | "pyhap.util.generate_setup_id" 33 | ) as mock_gen_setup_id, patch( 34 | "cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey.generate", 35 | return_value=private_key, 36 | ) as mock_create_keypair: 37 | state = State(address=addr, mac=mac, pincode=pin, port=port) 38 | assert not mock_local_addr.called 39 | assert not mock_gen_mac.called 40 | assert not mock_gen_pincode.called 41 | assert mock_gen_setup_id.called 42 | assert mock_create_keypair.called 43 | 44 | assert state.address == addr 45 | assert state.addresses == [addr] 46 | assert state.mac == mac 47 | assert state.pincode == pin 48 | assert state.port == port 49 | 50 | state = State() 51 | assert mock_local_addr.called 52 | assert mock_gen_mac.called 53 | assert mock_gen_pincode.called 54 | assert state.port == 51827 55 | assert state.config_version == 1 56 | 57 | 58 | def test_pairing_remove_last_admin(): 59 | """Test if pairing methods work.""" 60 | with patch("pyhap.util.get_local_address"), patch("pyhap.util.generate_mac"), patch( 61 | "pyhap.util.generate_pincode" 62 | ), patch("pyhap.util.generate_setup_id"): 63 | state = State() 64 | 65 | assert not state.paired 66 | assert not state.paired_clients 67 | 68 | state.add_paired_client(CLIENT_UUID_BYTES, "public", HAP_PERMISSIONS.ADMIN) 69 | assert state.paired 70 | assert state.paired_clients == {CLIENT_UUID: "public"} 71 | assert state.client_properties == {CLIENT_UUID: {CLIENT_PROP_PERMS: 1}} 72 | 73 | state.add_paired_client(CLIENT2_UUID_BYTES, "public", HAP_PERMISSIONS.USER) 74 | assert state.paired 75 | assert state.paired_clients == {CLIENT_UUID: "public", CLIENT2_UUID: "public"} 76 | assert state.client_properties == { 77 | CLIENT_UUID: {CLIENT_PROP_PERMS: 1}, 78 | CLIENT2_UUID: {CLIENT_PROP_PERMS: 0}, 79 | } 80 | assert state.uuid_to_bytes == { 81 | CLIENT_UUID: CLIENT_UUID_BYTES, 82 | CLIENT2_UUID: CLIENT2_UUID_BYTES, 83 | } 84 | 85 | # Removing the last admin should remove all non-admins 86 | state.remove_paired_client(CLIENT_UUID) 87 | assert not state.paired 88 | assert not state.paired_clients 89 | 90 | 91 | def test_pairing_two_admins(): 92 | """Test if pairing methods work.""" 93 | with patch("pyhap.util.get_local_address"), patch("pyhap.util.generate_mac"), patch( 94 | "pyhap.util.generate_pincode" 95 | ), patch("pyhap.util.generate_setup_id"): 96 | state = State() 97 | 98 | assert not state.paired 99 | assert not state.paired_clients 100 | 101 | state.add_paired_client(CLIENT_UUID_BYTES, "public", HAP_PERMISSIONS.ADMIN) 102 | assert state.paired 103 | assert state.paired_clients == {CLIENT_UUID: "public"} 104 | assert state.client_properties == {CLIENT_UUID: {CLIENT_PROP_PERMS: 1}} 105 | 106 | state.add_paired_client(CLIENT2_UUID_BYTES, "public", HAP_PERMISSIONS.ADMIN) 107 | assert state.paired 108 | assert state.paired_clients == {CLIENT_UUID: "public", CLIENT2_UUID: "public"} 109 | assert state.client_properties == { 110 | CLIENT_UUID: {CLIENT_PROP_PERMS: 1}, 111 | CLIENT2_UUID: {CLIENT_PROP_PERMS: 1}, 112 | } 113 | 114 | # Removing the admin should leave the other admin 115 | state.remove_paired_client(CLIENT2_UUID) 116 | assert state.paired 117 | assert state.paired_clients == {CLIENT_UUID: "public"} 118 | assert state.client_properties == {CLIENT_UUID: {CLIENT_PROP_PERMS: 1}} 119 | assert not state.is_admin(CLIENT2_UUID) 120 | -------------------------------------------------------------------------------- /tests/test_tlv.py: -------------------------------------------------------------------------------- 1 | """Tests for pyhap.tlv.""" 2 | 3 | import pytest 4 | 5 | from pyhap import tlv 6 | 7 | 8 | def test_tlv_round_trip(): 9 | """Test tlv can round trip TLV8 data.""" 10 | message = tlv.encode( 11 | b"\x01", 12 | b"A", 13 | b"\x01", 14 | b"B", 15 | b"\x02", 16 | b"C", 17 | ) 18 | 19 | decoded = tlv.decode(message) 20 | assert decoded == { 21 | b"\x01": b"AB", 22 | b"\x02": b"C", 23 | } 24 | 25 | 26 | def test_tlv_invalid_pairs(): 27 | """Test we encode fails with an odd amount of args.""" 28 | with pytest.raises(ValueError): 29 | tlv.encode(b"\x01", b"A", b"\02") 30 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | """Test for pyhap.util.""" 2 | import functools 3 | from uuid import UUID 4 | 5 | from pyhap import util 6 | 7 | 8 | @util.callback 9 | def async_is_callback(): 10 | """Test callback.""" 11 | 12 | 13 | def async_not_callback(): 14 | """Test callback.""" 15 | 16 | 17 | async def async_function(): 18 | """Test for iscoro.""" 19 | 20 | 21 | def test_callback(): 22 | """Test is_callback.""" 23 | assert util.is_callback(async_is_callback) is True 24 | assert util.is_callback(async_not_callback) is False 25 | 26 | 27 | def test_iscoro(): 28 | """Test iscoro.""" 29 | assert util.iscoro(async_function) is True 30 | assert util.iscoro(functools.partial(async_function)) is True 31 | assert util.iscoro(async_is_callback) is False 32 | assert util.iscoro(async_not_callback) is False 33 | 34 | 35 | def test_generate_setup_id(): 36 | """Test generate_setup_id.""" 37 | assert len(util.generate_setup_id()) == 4 38 | 39 | 40 | def test_hap_type_to_uuid(): 41 | """Test we can convert short types to UUIDs.""" 42 | assert util.hap_type_to_uuid("32") == UUID("00000032-0000-1000-8000-0026bb765291") 43 | assert util.hap_type_to_uuid("00000032") == UUID( 44 | "00000032-0000-1000-8000-0026bb765291" 45 | ) 46 | assert util.hap_type_to_uuid("00000032-0000-1000-8000-0026bb765291") == UUID( 47 | "00000032-0000-1000-8000-0026bb765291" 48 | ) 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, py38, py39, py310, py311, py312, docs, lint, pylint, bandit 3 | skip_missing_interpreters = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.5: py36 8 | 3.6: py36 9 | 3.7: py37 10 | 3.8: py38, mypy 11 | 3.9: py39, mypy 12 | 3.10: py310, mypy 13 | 3.11: py311, mypy 14 | 3.12: py312, mypy 15 | 16 | [testenv] 17 | deps = 18 | -r{toxinidir}/requirements_all.txt 19 | -r{toxinidir}/requirements_test.txt 20 | commands = 21 | pytest --timeout=2 --cov --cov-report= {posargs} 22 | 23 | [testenv:codecov] 24 | deps = 25 | -r{toxinidir}/requirements_all.txt 26 | -r{toxinidir}/requirements_test.txt 27 | commands = 28 | pytest --timeout=2 --cov --cov-report=xml {posargs} 29 | 30 | [testenv:temperature] 31 | basepython = python3.6 32 | deps = 33 | -r{toxinidir}/requirements_all.txt 34 | commands = 35 | python main.py 36 | 37 | [testenv:docs] 38 | changedir = docs 39 | deps = 40 | -r{toxinidir}/requirements_docs.txt 41 | commands = 42 | make clean 43 | sphinx-build -W -b html source {envtmpdir}/html 44 | allowlist_externals= 45 | /usr/bin/make 46 | make 47 | 48 | 49 | [testenv:lint] 50 | basepython = {env:PYTHON3_PATH:python3} 51 | deps = 52 | -r{toxinidir}/requirements_all.txt 53 | -r{toxinidir}/requirements_test.txt 54 | commands = 55 | flake8 pyhap tests --ignore=D10,D205,D4,E501,E126,E128,W504,W503,E203 56 | 57 | [testenv:pylint] 58 | basepython = {env:PYTHON3_PATH:python3} 59 | ignore_errors = True 60 | deps = 61 | -r{toxinidir}/requirements_all.txt 62 | -r{toxinidir}/requirements_test.txt 63 | commands = 64 | pylint pyhap --disable=missing-docstring,empty-docstring,invalid-name,fixme,too-many-positional-arguments --max-line-length=120 65 | pylint tests --disable=duplicate-code,missing-docstring,empty-docstring,invalid-name,fixme,too-many-positional-arguments --max-line-length=120 66 | 67 | 68 | [testenv:bandit] 69 | basepython = {env:PYTHON3_PATH:python3} 70 | ignore_errors = True 71 | deps = 72 | -r{toxinidir}/requirements_all.txt 73 | -r{toxinidir}/requirements_test.txt 74 | bandit 75 | commands = 76 | bandit -r pyhap 77 | 78 | 79 | 80 | [testenv:doc-errors] 81 | basepython = {env:PYTHON3_PATH:python3} 82 | ignore_errors = True 83 | deps = 84 | -r{toxinidir}/requirements_all.txt 85 | -r{toxinidir}/requirements_test.txt 86 | commands = 87 | flake8 pyhap tests --select=D10,D205,D4,E501 88 | pylint pyhap --disable=all --enable=missing-docstring,empty-docstring 89 | # pydocstyle pyhap tests 90 | --------------------------------------------------------------------------------