├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── publish-to-pypi.yml │ ├── release-drafter.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── openzwavemqtt ├── __init__.py ├── base.py ├── const.py ├── exceptions.py ├── manager.py ├── models │ ├── __init__.py │ ├── command_class.py │ ├── instance.py │ ├── instance_statistics.py │ ├── instance_status.py │ ├── node.py │ ├── node_association.py │ ├── node_child_base.py │ ├── node_instance.py │ ├── node_statistics.py │ └── value.py ├── options.py └── util │ ├── __init__.py │ ├── lock.py │ ├── mqtt_client.py │ └── node.py ├── pylintrc ├── requirements.txt ├── requirements_dev.txt ├── requirements_lint.txt ├── requirements_script.txt ├── requirements_test.txt ├── script ├── dump_mqtt.py ├── instance_from_file.py └── ozw_emulator.py ├── setup.cfg ├── setup.py ├── test ├── __init__.py ├── conftest.py ├── models │ ├── __init__.py │ ├── test_instance.py │ ├── test_instance_statistics.py │ ├── test_node_child_base.py │ ├── test_node_statistics.py │ └── test_value.py ├── test_base.py ├── test_manager.py └── util │ ├── __init__.py │ └── test_node.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | - package-ecosystem: "pip" 14 | directory: "/" # Location of package manifests 15 | schedule: 16 | interval: "weekly" 17 | open-pull-requests-limit: 10 18 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | change-template: "- #$NUMBER - $TITLE (@$AUTHOR)" 2 | categories: 3 | - title: "⚠ Breaking Changes" 4 | labels: 5 | - "breaking change" 6 | template: | 7 | ## What’s Changed 8 | 9 | $CHANGES 10 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish releases to PyPI 2 | 3 | on: 4 | release: 5 | types: [published, prereleased] 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Builds and publishes releases to PyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3.0.0 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: 3.8 17 | - name: Install wheel 18 | run: >- 19 | pip install wheel 20 | - name: Build 21 | run: >- 22 | python3 setup.py sdist bdist_wheel 23 | - name: Publish release to PyPI 24 | uses: pypa/gh-action-pypi-publish@v1.5.0 25 | with: 26 | user: __token__ 27 | password: ${{ secrets.PYPI_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts your next Release notes as Pull Requests are merged into "master" 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.8, 3.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v3.0.0 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install tox tox-gh-actions 29 | - name: Test with tox 30 | run: tox 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Hide sublime text stuff 2 | *.sublime-project 3 | *.sublime-workspace 4 | 5 | # Hide some OS X stuff 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # IntelliJ IDEA 15 | .idea 16 | *.iml 17 | 18 | # pytest 19 | .pytest_cache 20 | .cache 21 | 22 | # GITHUB Proposed Python stuff: 23 | *.py[cod] 24 | 25 | # C extensions 26 | *.so 27 | 28 | # Packages 29 | *.egg 30 | *.egg-info 31 | dist 32 | build 33 | eggs 34 | .eggs 35 | parts 36 | bin 37 | var 38 | sdist 39 | develop-eggs 40 | .installed.cfg 41 | lib 42 | lib64 43 | pip-wheel-metadata 44 | 45 | # Logs 46 | *.log 47 | pip-log.txt 48 | 49 | # Unit test / coverage reports 50 | .coverage 51 | .tox 52 | coverage.xml 53 | nosetests.xml 54 | htmlcov/ 55 | test-reports/ 56 | test-results.xml 57 | test-output.xml 58 | 59 | # Translations 60 | *.mo 61 | 62 | # Mr Developer 63 | .mr.developer.cfg 64 | .project 65 | .pydevproject 66 | 67 | .python-version 68 | 69 | # emacs auto backups 70 | *~ 71 | *# 72 | *.orig 73 | 74 | # venv stuff 75 | pyvenv.cfg 76 | pip-selfcheck.json 77 | venv 78 | .venv 79 | Pipfile* 80 | share/* 81 | Scripts/ 82 | 83 | # vimmy stuff 84 | *.swp 85 | *.swo 86 | tags 87 | ctags.tmp 88 | 89 | # vagrant stuff 90 | virtualization/vagrant/setup_done 91 | virtualization/vagrant/.vagrant 92 | virtualization/vagrant/config 93 | 94 | # Visual Studio Code 95 | .vscode/* 96 | !.vscode/cSpell.json 97 | !.vscode/extensions.json 98 | !.vscode/tasks.json 99 | 100 | # Typing 101 | .mypy_cache 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-openzwave-mqtt 2 | 3 | **This repository has been archived as the upstream qt-openzwave project is no longer maintained.** 4 | 5 | Python library for the OpenZWave MQTT implementation. 6 | 7 | Consumes MQTT output from [https://github.com/OpenZWave/qt-openzwave](https://github.com/OpenZWave/qt-openzwave) 8 | 9 | For Home Assistant integration, see the custom component [homeassistant-zwave_mqtt](https://github.com/cgarwood/homeassistant-zwave_mqtt). 10 | 11 | ## Install 12 | 13 | - Python 3.8+ is required. We support the two latest Python 3 versions released at any one time. 14 | 15 | ```sh 16 | pip install python-openzwave-mqtt 17 | ``` 18 | 19 | ## Structure 20 | 21 | Each object maps to one or two parts in the topic. A topic can contain the following parts: 22 | 23 | - ``: the prefix of each topic. This is ignored in the processing. Usually `openzwave/`. 24 | - `/`: The collection type and the ID of the item in the collection. Example: `value/3` 25 | - ``: Some objects will have a direct collection that is not typed in the topic. Example is the OZW instance in `/1` 26 | - ``: If there is only a single instance of a type under a parent. For example `node/2/statistics`. 27 | 28 | ### Example 29 | 30 | A message is sent to topic `openzwave/1/node/2/statistics`. This maps to: 31 | 32 | | Type | ID | 33 | | ------------------- | --- | 34 | | Prefix | - | 35 | | `OZWInstance` | `1` | 36 | | `OZWNode` | `2` | 37 | | `OZWNodeStatistics` | - | 38 | 39 | ## Message ordering 40 | 41 | We work with signals to signal listeners when things change. However, when we connect to MQTT we will receive a lot of retained messages at once. To prevent signals being sent out of order, we will hold all messages for children until the parent has received its information. 42 | 43 | This has been disabled for `OZWManager` and `OZWInstance`. 44 | 45 | If we receive messages on the following topics: 46 | 47 | 1. `openzwave/1/node/2/statistics` 48 | 2. `openzwave/1/node/2` 49 | 50 | We will process the messages in the reverse order: 51 | 52 | 1. `openzwave/1/node/2` 53 | 2. `openzwave/1/node/2/statistics` 54 | 55 | ## Modelling Rules 56 | 57 | This library should not aim to do fancy things. We should, as much as possible, represent the data from MQTT as-is. We don't want to change names besides making them Pythonic (CamelCase -> snake_case). 58 | 59 | ## Automatic added helpers 60 | 61 | Models will have automatic helpers added based on their child models. For example, the `Node` model has the following child collections: 62 | 63 | ```python 64 | def create_collections(self): 65 | """Create collections that Node supports.""" 66 | return { 67 | # A collection of children 68 | "instance": ItemCollection(OZWNodeInstance), 69 | # A single child 70 | "statistics": OZWNodeStatistics, 71 | } 72 | ``` 73 | 74 | This means that `Node` has the following automatic functions created: 75 | 76 | - `get_instance(item_id)` to get an instance by ID. 77 | - `instances()` to get an iterator over all available instances. 78 | - `get_statistics()` get the direct child. 79 | 80 | ## Gathering Data 81 | 82 | This library is instantiated using messages received from MQTT. To make development easier, we have created two helper scripts. One that will dump all MQTT messages and one that will read messages from a text file and instantiate an `OZWManager` with all the data. This can be used to develop, test or reproduce bugs. 83 | 84 | ```sh 85 | python3 -m script.dump_mqtt > dump.csv 86 | python3 -m script.instance_from_file dump.csv 87 | ``` 88 | 89 | ## Development 90 | 91 | - Install all requirements: 92 | 93 | ```sh 94 | pip install -r requirements_dev.txt 95 | ``` 96 | 97 | - We use [black](https://pypi.org/project/black/) to format the code: 98 | 99 | ```sh 100 | black ./ 101 | ``` 102 | 103 | - Use [tox](https://pypi.org/project/tox/) to run tests and linting: 104 | 105 | ```sh 106 | tox 107 | ``` 108 | -------------------------------------------------------------------------------- /openzwavemqtt/__init__.py: -------------------------------------------------------------------------------- 1 | """Python listener implementation of the OpenZWave MQTT Server.""" 2 | 3 | from .manager import OZWManager # noqa 4 | from .options import OZWOptions # noqa 5 | -------------------------------------------------------------------------------- /openzwavemqtt/base.py: -------------------------------------------------------------------------------- 1 | """Base for all models.""" 2 | from abc import ABC 3 | from typing import Any, Callable, Deque, Dict, Iterator, Optional, Type, Union 4 | 5 | from .const import EMPTY_PAYLOAD, EVENT_PLACEHOLDER, LOGGER 6 | from .options import OZWOptions 7 | 8 | 9 | class ItemCollection: 10 | """Initialize an item collection.""" 11 | 12 | def __init__(self, item_class: Type["ZWaveBase"]): 13 | """Initialize item collection instance.""" 14 | self.parent: Optional["ZWaveBase"] = None 15 | self.topic_part: Optional[str] = None 16 | self.item_class = item_class 17 | self.collection: Dict[int, "ZWaveBase"] = {} 18 | 19 | assert item_class.EVENT_ADDED != EVENT_PLACEHOLDER 20 | assert item_class.EVENT_REMOVED != EVENT_PLACEHOLDER 21 | 22 | def adopt(self, parent: "ZWaveBase", topic_part: Optional[str]) -> None: 23 | """Adopt the item collection.""" 24 | assert self.parent is None 25 | self.parent = parent 26 | self.topic_part = topic_part 27 | 28 | def get(self, item_id: int) -> Optional["ZWaveBase"]: 29 | """Return item in collection.""" 30 | return self.collection.get(item_id) 31 | 32 | def process_message(self, topic: Deque[str], message: dict) -> None: 33 | """Process a new message.""" 34 | item_id = int(topic.popleft()) 35 | item = self.collection.get(item_id) 36 | added = False 37 | 38 | if item is None and message is EMPTY_PAYLOAD: 39 | return 40 | 41 | if item is None: 42 | topic_part = str(item_id) 43 | 44 | if self.topic_part is not None: 45 | topic_part = f"{self.topic_part}/{topic_part}" 46 | 47 | assert self.parent is not None 48 | item = self.collection[item_id] = self.item_class( 49 | self.parent.options, self.parent, topic_part, item_id 50 | ) 51 | added = True 52 | 53 | if len(topic) == 0 and message is EMPTY_PAYLOAD: 54 | self.remove_and_notify(item_id) 55 | return 56 | 57 | item.process_message(topic, message) 58 | 59 | # Only notify after we process the message. 60 | if added: 61 | assert self.parent is not None 62 | self.parent.options.notify(self.item_class.EVENT_ADDED, item) 63 | 64 | def remove_and_notify(self, item_id: int) -> None: 65 | """Remove item from collection and fire remove events for all child objects.""" 66 | item = self.collection[item_id] 67 | 68 | for collection in item.collections.values(): 69 | if not isinstance(collection, ItemCollection): 70 | continue 71 | 72 | for item in list(collection): 73 | assert item.id is not None 74 | collection.remove_and_notify(item.id) 75 | 76 | assert self.parent is not None 77 | self.parent.options.notify( 78 | self.item_class.EVENT_REMOVED, self.collection.pop(item_id) 79 | ) 80 | 81 | def __iter__(self) -> Iterator: 82 | """Return iterator over all items in this collection.""" 83 | return iter(self.collection.values()) 84 | 85 | 86 | class ZWaveBase(ABC): 87 | """A base class for all models.""" 88 | 89 | # Name the direct collection that lives underneath this object 90 | # but is not named in the topic. A message to /openzwave/1 will 91 | # be interpreted as if sent to /openzwave//1 92 | DIRECT_COLLECTION: Optional[str] = None 93 | 94 | # Default value of this object. If untouched, all messages for child objects 95 | # will be held until information for this object has been received. 96 | DEFAULT_VALUE: Optional[dict] = EMPTY_PAYLOAD 97 | 98 | # Use in case there is a special plural name of this class. 99 | PLURAL_NAME: Optional[str] = None 100 | 101 | EVENT_ADDED = EVENT_PLACEHOLDER 102 | EVENT_CHANGED = EVENT_PLACEHOLDER 103 | EVENT_REMOVED = EVENT_PLACEHOLDER 104 | 105 | def __init__( 106 | self, 107 | options: OZWOptions, 108 | parent: Optional["ZWaveBase"], 109 | topic_part: str, 110 | item_id: Optional[int], 111 | ): 112 | """Initialize the model.""" 113 | # Runtime options 114 | self.options = options 115 | 116 | # Parent object 117 | self.parent = parent 118 | 119 | # Part of the topic that instantiated this object. 120 | self.topic_part = topic_part 121 | 122 | # Identifier of this object 123 | self.id = item_id 124 | 125 | # Models that live under this model 126 | self.collections: Dict[ 127 | str, Union[ItemCollection, "ZWaveBase", "DiscardMessages", "EventMessages"] 128 | ] = {} 129 | 130 | # The data this object holds 131 | self.data = self.DEFAULT_VALUE 132 | 133 | # Messages for children that are held until data is received 134 | self.pending_messages: Optional[list] = None 135 | 136 | assert self.EVENT_CHANGED != EVENT_PLACEHOLDER 137 | 138 | # Process collections 139 | for item_type, collection in self.create_collections().items(): 140 | if isinstance(collection, type): # OZWBase 141 | self.collections[item_type] = collection( 142 | self.options, self, item_type, None 143 | ) 144 | setattr( 145 | self, f"get_{item_type}", create_getter(self.collections[item_type]) 146 | ) 147 | continue 148 | 149 | if not isinstance(collection, ItemCollection): 150 | self.collections[item_type] = collection 151 | continue 152 | 153 | setattr(self, f"get_{item_type}", collection.get) 154 | 155 | plural_name = collection.item_class.PLURAL_NAME or f"{item_type}s" 156 | if hasattr(self, plural_name): 157 | raise RuntimeError( 158 | f"Cannot add {plural_name} function to {type(self).__name__}. Already exists." 159 | ) 160 | setattr(self, plural_name, collection.collection.values) 161 | 162 | if item_type == self.DIRECT_COLLECTION: 163 | coll_topic_part: Optional[str] = None 164 | else: 165 | coll_topic_part = item_type 166 | 167 | collection.adopt(self, coll_topic_part) 168 | self.collections[item_type] = collection 169 | 170 | @property 171 | def topic(self) -> str: 172 | """Return topic of this object.""" 173 | if self.parent is None: 174 | # Cut off the trailing slash 175 | return self.options.topic_prefix[:-1] 176 | 177 | return f"{self.parent.topic}/{self.topic_part}" 178 | 179 | def create_collections( 180 | self, 181 | ) -> Dict[ 182 | str, 183 | Union[ItemCollection, Type["ZWaveBase"], "DiscardMessages", "EventMessages"], 184 | ]: 185 | """Create collections that this type supports. 186 | 187 | Each collection is either ItemCollection or a class derived from ZWaveBase. 188 | """ 189 | # pylint: disable=no-self-use 190 | return {} 191 | 192 | def process_message(self, topic: Deque[str], message: dict) -> None: 193 | """Process a new message.""" 194 | if len(topic) == 0: 195 | is_init_msg = self.data is EMPTY_PAYLOAD 196 | self.data = message 197 | 198 | if not is_init_msg: 199 | self.options.notify(self.EVENT_CHANGED, self) 200 | return 201 | 202 | # Process all messages for the children. 203 | if self.pending_messages is not None: 204 | for pend_topic, pend_message in self.pending_messages: 205 | self.process_message(pend_topic, pend_message) 206 | self.pending_messages = None 207 | 208 | return 209 | 210 | # If this object has not been initialized, queue up messages. 211 | if self.data is EMPTY_PAYLOAD: 212 | if self.pending_messages is None: 213 | self.pending_messages = [] 214 | self.pending_messages.append((topic, message)) 215 | return 216 | 217 | if topic[0] in self.collections: 218 | collection_type = topic.popleft() 219 | 220 | elif self.DIRECT_COLLECTION and topic[0].isnumeric(): 221 | collection_type = self.DIRECT_COLLECTION 222 | 223 | else: 224 | self._warn_cannot_handle(topic, message) 225 | return 226 | 227 | self.collections[collection_type].process_message(topic, message) 228 | 229 | def _warn_cannot_handle(self, topic: Deque[str], message: dict) -> None: 230 | LOGGER.warning( 231 | "%s cannot process message %s: %s", 232 | type(self).__name__, 233 | f"{self.topic}/{'/'.join(topic)}", 234 | message, 235 | ) 236 | 237 | def __repr__(self) -> str: 238 | """Return a representation of this object.""" 239 | iden = f" {self.id}" if self.id else "" 240 | return f"<{type(self).__name__}{iden}>" 241 | 242 | 243 | class DiscardMessages: 244 | """Class that discards all messages sent to it.""" 245 | 246 | def process_message(self, topic: Deque[str], message: dict) -> None: 247 | """Process incoming message.""" 248 | 249 | 250 | class EventMessages: 251 | """Class that converts messages to events.""" 252 | 253 | def __init__( 254 | self, 255 | options: OZWOptions, 256 | event: str, 257 | type_extractor: Callable[[Deque[str], dict], str], 258 | ): 259 | """Initialize EventMessages.""" 260 | self.options = options 261 | self.event = event 262 | self.type_extractor = type_extractor 263 | 264 | def process_message(self, topic: Deque[str], message: dict) -> None: 265 | """Process incoming message.""" 266 | event_type = self.type_extractor(topic, message) 267 | self.options.notify(self.event, {"event": event_type, "data": message}) 268 | 269 | 270 | def create_getter(obj: Any) -> Callable: 271 | """Return a function that returns an object. 272 | 273 | Workaround for not being able to create lambdas that refer to variables in the 274 | current iteration of the loop. 275 | """ 276 | return lambda: obj 277 | -------------------------------------------------------------------------------- /openzwavemqtt/const.py: -------------------------------------------------------------------------------- 1 | """All constants and common types.""" 2 | import logging 3 | from enum import Enum, IntEnum 4 | 5 | LOGGER = logging.getLogger("openzwavemqtt") 6 | 7 | # Attribute names for utility functions 8 | ATTR_CODE_SLOT = "code_slot" 9 | ATTR_IN_USE = "in_use" 10 | ATTR_LABEL = "label" 11 | ATTR_MAX = "max" 12 | ATTR_MIN = "min" 13 | ATTR_NAME = "name" 14 | ATTR_OPTIONS = "options" 15 | ATTR_PARAMETER = "parameter" 16 | ATTR_POSITION = "position" 17 | ATTR_TYPE = "type" 18 | ATTR_USERCODE = "usercode" 19 | ATTR_VALUE = "value" 20 | ATTR_HELP = "help" 21 | 22 | # OZW Events 23 | EVENT_PLACEHOLDER = "missing" 24 | EVENT_COMMAND_CLASS_ADDED = "command_class_added" 25 | EVENT_COMMAND_CLASS_CHANGED = "command_class_changed" 26 | EVENT_COMMAND_CLASS_REMOVED = "command_class_removed" 27 | EVENT_INSTANCE_ADDED = "instance_added" 28 | EVENT_INSTANCE_CHANGED = "instance_changed" 29 | EVENT_INSTANCE_REMOVED = "instance_removed" 30 | EVENT_INSTANCE_EVENT = "instance_event" 31 | EVENT_INSTANCE_STATISTICS_CHANGED = "instance_statistics_changed" 32 | EVENT_INSTANCE_STATUS_CHANGED = "instance_status_changed" 33 | EVENT_NODE_ADDED = "node_added" 34 | EVENT_NODE_CHANGED = "node_changed" 35 | EVENT_NODE_REMOVED = "node_removed" 36 | EVENT_NODE_INSTANCE_ADDED = "node_instance_added" 37 | EVENT_NODE_INSTANCE_CHANGED = "node_instance_changed" 38 | EVENT_NODE_INSTANCE_REMOVED = "node_instance_removed" 39 | EVENT_NODE_ASSOCIATION_ADDED = "node_association_added" 40 | EVENT_NODE_ASSOCIATION_CHANGED = "node_association_changed" 41 | EVENT_NODE_ASSOCIATION_REMOVED = "node_association_removed" 42 | EVENT_NODE_STATISTICS_CHANGED = "node_statistics_changed" 43 | EVENT_VALUE_ADDED = "value_added" 44 | EVENT_VALUE_CHANGED = "value_changed" 45 | EVENT_VALUE_REMOVED = "value_removed" 46 | 47 | # Default/empty payload on MQTT messages 48 | EMPTY_PAYLOAD: dict = {} 49 | 50 | 51 | class OpenZWaveStatus(Enum): 52 | """Enum with all Status strings for the OZW Daemon.""" 53 | 54 | # https://github.com/OpenZWave/qt-openzwave/blob/master/docs/MQTT.md#mqtt-client-status 55 | 56 | OFFLINE = "Offline" 57 | STARTING = "starting" 58 | STARTED = "started" 59 | STOPPED = "stopped" 60 | READY = "Ready" 61 | DRIVER_READY = "driverReady" 62 | DRIVER_FAILED = "driverFailed" 63 | DRIVER_RESET = "driverReset" 64 | DRIVER_REMOVED = "driverRemoved" 65 | DRIVER_ALL_NODES_QUERIED_SOME_DEAD = "driverAllNodesQueriedSomeDead" 66 | DRIVER_ALL_NODES_QUERIED = "driverAllNodesQueried" 67 | DRIVER_AWAKE_NODES_QUERIED_SOME_DEAD = "driverAwakeNodesQueried" 68 | UNKNOWN = None 69 | 70 | 71 | # Tuple with all states indicating OZW is ready 72 | OZW_READY_STATES = ( 73 | OpenZWaveStatus.DRIVER_AWAKE_NODES_QUERIED_SOME_DEAD, 74 | OpenZWaveStatus.DRIVER_ALL_NODES_QUERIED_SOME_DEAD, 75 | OpenZWaveStatus.DRIVER_ALL_NODES_QUERIED, 76 | ) 77 | 78 | 79 | class ValueGenre(Enum): 80 | """Enum with all diferent Value genres.""" 81 | 82 | USER = "User" 83 | SYSTEM = "System" 84 | BASIC = "Basic" 85 | CONFIG = "Config" 86 | UNKNOWN = None 87 | 88 | 89 | class ValueType(Enum): 90 | """Enum with all diferent Value types.""" 91 | 92 | BOOL = "Bool" 93 | LIST = "List" 94 | STRING = "String" 95 | DECIMAL = "Decimal" 96 | BYTE = "Byte" 97 | SHORT = "Short" 98 | INT = "Int" 99 | BITSET = "BitSet" 100 | RAW = "Raw" 101 | BUTTON = "Button" 102 | SCHEDULE = "Schedule" 103 | UNKNOWN = None 104 | 105 | 106 | class ValueIndex(IntEnum): 107 | """Enum with all (known/used) Value indexes.""" 108 | 109 | # Alarm 110 | ALARM_TYPE = 0 111 | ALARM_LEVEL = 1 112 | ALARM_ACCESS_CONTROL = 9 113 | # BarrierOperator 114 | BARRIER_OPERATOR_LABEL = 1 115 | # DoorLock 116 | DOOR_LOCK_LOCK = 0 117 | CLEAR_USER_CODE = 256 118 | NUM_USER_CODES = 257 119 | # Meter 120 | METER_POWER = 2 121 | METER_RESET = 257 122 | # SensorMultilevel 123 | SENSOR_MULTILEVEL_AIR_TEMPERATURE = 1 124 | SENSOR_MULTILEVEL_GENERAL_PURPOSE = 2 125 | SENSOR_MULTILEVEL_ILLUMINANCE = 3 126 | SENSOR_MULTILEVEL_POWER = 4 127 | SENSOR_MULTILEVEL_HUMIDITY = 5 128 | SENSOR_MULTILEVEL_VELOCITY = 6 129 | SENSOR_MULTILEVEL_DIRECTION = 7 130 | SENSOR_MULTILEVEL_ATMOSPHERIC_PRESSURE = 8 131 | SENSOR_MULTILEVEL_BAROMETIC_PRESSURE = 9 132 | SENSOR_MULTILEVEL_SOLAR_RADIATION = 10 133 | SENSOR_MULTILEVEL_DEW_POINT = 11 134 | SENSOR_MULTILEVEL_RAIN_RATE = 12 135 | SENSOR_MULTILEVEL_TIDE_LEVEL = 13 136 | SENSOR_MULTILEVEL_WEIGHT = 14 137 | SENSOR_MULTILEVEL_VOLTAGE = 15 138 | SENSOR_MULTILEVEL_CURRENT = 16 139 | SENSOR_MULTILEVEL_CARBON_DIOXIDE = 17 140 | SENSOR_MULTILEVEL_AIR_FLOW = 18 141 | SENSOR_MULTILEVEL_TANK_CAPACITY = 19 142 | SENSOR_MULTILEVEL_DISTANCE = 20 143 | SENSOR_MULTILEVEL_ANGLE_POSITION = 21 144 | SENSOR_MULTILEVEL_ROTATION = 22 145 | SENSOR_MULTILEVEL_WATER_TEMPERATURE = 23 146 | SENSOR_MULTILEVEL_SOIL_TEMPERATURE = 24 147 | SENSOR_MULTILEVEL_SEISMIC_INTENSITY = 25 148 | SENSOR_MULTILEVEL_SEISMIC_MAGNITUDE = 26 149 | SENSOR_MULTILEVEL_ULTRAVIOLET = 27 150 | SENSOR_MULTILEVEL_ELECTRICAL_RESISTIVITY = 28 151 | SENSOR_MULTILEVEL_ELECTRICAL_CONDUCTIVITY = 29 152 | SENSOR_MULTILEVEL_LOUDNESS = 30 153 | SENSOR_MULTILEVEL_MOISTURE = 31 154 | SENSOR_MULTILEVEL_FREQUENCY = 32 155 | SENSOR_MULTILEVEL_TIME = 33 156 | SENSOR_MULTILEVEL_TARGET_TEMPERATURE = 34 157 | SENSOR_MULTILEVEL_PARTICULATE_MATTER_25 = 35 158 | SENSOR_MULTILEVEL_FORMALDEHYDE_CH20_LEVEL = 36 159 | SENSOR_MULTILEVEL_RADON_CONCENTRATION = 37 160 | SENSOR_MULTILEVEL_METHANE_DENSITY = 38 161 | SENSOR_MULTILEVEL_VOLATILE_ORGANIC_COMPOUND = 39 162 | SENSOR_MULTILEVEL_CARBON_MONOXIDE = 40 163 | SENSOR_MULTILEVEL_SOIL_HUMIDITY = 41 164 | SENSOR_MULTILEVEL_SOIL_REACTIVITY = 42 165 | SENSOR_MULTILEVEL_SOIL_SALINITY = 43 166 | SENSOR_MULTILEVEL_HEART_RATE = 44 167 | SENSOR_MULTILEVEL_BLOOD_PRESSURE = 45 168 | SENSOR_MULTILEVEL_MUSCLE_MASS = 46 169 | SENSOR_MULTILEVEL_FAT_MASS = 47 170 | SENSOR_MULTILEVEL_BONE_MASS = 48 171 | SENSOR_MULTILEVEL_TOTAL_BODY_WATER = 49 172 | SENSOR_MULTILEVEL_BASIC_METABOLIC_RATE = 50 173 | SENSOR_MULTILEVEL_BODY_MASS_INDEX = 51 174 | SENSOR_MULTILEVEL_X_AXIS_ACCELERATION = 52 175 | SENSOR_MULTILEVEL_Y_AXIS_ACCELERATION = 53 176 | SENSOR_MULTILEVEL_Z_AXIS_ACCELERATION = 54 177 | SENSOR_MULTILEVEL_SMOKE_DENSITY = 55 178 | SENSOR_MULTILEVEL_WATER_FLOW = 56 179 | SENSOR_MULTILEVEL_WATER_PRESSURE = 57 180 | SENSOR_MULTILEVEL_RF_SIGNAL_STRENGTH = 58 181 | SENSOR_MULTILEVEL_PARTICULATE_MATTER_10 = 59 182 | SENSOR_MULTILEVEL_RESPIRATORY_RATE = 60 183 | SENSOR_MULTILEVEL_RELATIVE_MODULATION = 61 184 | SENSOR_MULTILEVEL_BOILER_WATER_TEMPERATURE = 62 185 | SENSOR_MULTILEVEL_DOMESTIC_HOT_WATER_TEMPERATURE = 63 186 | SENSOR_MULTILEVEL_OUTSIDE_TEMPERATURE = 64 187 | SENSOR_MULTILEVEL_EXHAUST_TEMPERATURE = 65 188 | SENSOR_MULTILEVEL_WATER_CHLORINE = 66 189 | SENSOR_MULTILEVEL_WATER_ACIDITY = 67 190 | SENSOR_MULTILEVEL_WATER_OXIDATION_REDUCTION_POTENTIAL = 68 191 | SENSOR_MULTILEVEL_HEART_RATE_LF_HF_RATIO = 69 192 | SENSOR_MULTILEVEL_MOTION_DIRECTION = 70 193 | SENSOR_MULTILEVEL_APPLIED_FORCE = 71 194 | SENSOR_MULTILEVEL_RETURN_AIR_TEMPERATURE = 72 195 | SENSOR_MULTILEVEL_SUPPLY_AIR_TEMPERATURE = 73 196 | SENSOR_MULTILEVEL_CONDENSER_COIL_TEMPERATURE = 74 197 | SENSOR_MULTILEVEL_EVAPORATOR_COIL_TEMPERATURE = 75 198 | SENSOR_MULTILEVEL_LIQUID_LINE_TEMPERATURE = 76 199 | SENSOR_MULTILEVEL_DISCHARGE_LINE_TEMPERATURE = 77 200 | SENSOR_MULTILEVEL_SUCTION = 78 201 | SENSOR_MULTILEVEL_DISCHARGE = 79 202 | SENSOR_MULTILEVEL_DEFROST_TEMPERATURE = 80 203 | SENSOR_MULTILEVEL_OZONE = 81 204 | SENSOR_MULTILEVEL_SULFUR_DIOXIDE = 82 205 | SENSOR_MULTILEVEL_NITROGEN_DIOXIDE = 83 206 | SENSOR_MULTILEVEL_AMMONIA = 84 207 | SENSOR_MULTILEVEL_LEAD = 85 208 | SENSOR_MULTILEVEL_PARTICULATE_MATTER_1 = 86 209 | # Color 210 | SWITCH_COLOR_COLOR = 0 211 | SWITCH_COLOR_CHANNELS = 2 212 | SWITCH_COLOR_CT_WARM = 81 213 | SWITCH_COLOR_CT_COLD = 82 214 | # SwitchMultilevel 215 | SWITCH_MULTILEVEL_LEVEL = 0 216 | SWITCH_MULTILEVEL_BRIGHT = 1 217 | SWITCH_MULTILEVEL_DIM = 2 218 | SWITCH_MULTILEVEL_DURATION = 5 219 | # Notification 220 | NOTIFICATION_SMOKE_ALARM = 1 221 | NOTIFICATION_CARBON_MONOOXIDE = 2 222 | NOTIFICATION_CARBON_DIOXIDE = 3 223 | NOTIFICATION_HEAT = 4 224 | NOTIFICATION_WATER = 5 225 | NOTIFICATION_ACCESS_CONTROL = 6 226 | NOTIFICATION_HOME_SECURITY = 7 227 | NOTIFICATION_POWER_MANAGEMENT = 8 228 | NOTIFICATION_SYSTEM = 9 229 | NOTIFICATION_EMERGENCY = 10 230 | NOTIFICATION_CLOCK = 11 231 | NOTIFICATION_APPLIANCE = 12 232 | NOTIFICATION_HOME_HEALTH = 13 233 | NOTIFICATION_SIREN = 14 234 | NOTIFICATION_WATER_VALVE = 15 235 | NOTIFICATION_WEATHER = 16 236 | NOTIFICATION_IRRIGATION = 17 237 | NOTIFICATION_GAS = 18 238 | # Notification Event Parameter 239 | NOTIFICATION_EVENT_PARAMETER_PREVIOUS_EVENT_CLEARED = 256 240 | NOTIFICATION_EVENT_PARAMETER_USER_CODE = 260 241 | UNKNOWN = 999 242 | # Version 243 | VERSION_LIBRARY = 0 244 | VERSION_PROTOCOL = 1 245 | VERSION_APPLICATION = 2 246 | 247 | 248 | class CommandClass(IntEnum): 249 | """Enum with all known CommandClasses.""" 250 | 251 | ALARM = 113 252 | SENSOR_ALARM = 156 253 | SILENCE_ALARM = 157 254 | SWITCH_ALL = 39 255 | ANTITHEFT = 93 256 | ANTITHEFT_UNLOCK = 126 257 | APPLICATION_CAPABILITY = 87 258 | APPLICATION_STATUS = 34 259 | ASSOCIATION = 133 260 | ASSOCIATION_COMMAND_CONFIGURATION = 155 261 | ASSOCIATION_GRP_INFO = 89 262 | AUTHENTICATION = 161 263 | AUTHENTICATION_MEDIA_WRITE = 162 264 | BARRIER_OPERATOR = 102 265 | BASIC = 32 266 | BASIC_TARIFF_INFO = 54 267 | BASIC_WINDOW_COVERING = 80 268 | BATTERY = 128 269 | SENSOR_BINARY = 48 270 | SWITCH_BINARY = 37 271 | SWITCH_TOGGLE_BINARY = 40 272 | CLIMATE_CONTROL_SCHEDULE = 70 273 | CENTRAL_SCENE = 91 274 | CLOCK = 129 275 | SWITCH_COLOR = 51 276 | CONFIGURATION = 112 277 | CONTROLLER_REPLICATION = 33 278 | CRC_16_ENCAP = 86 279 | DCP_CONFIG = 58 280 | DCP_MONITOR = 59 281 | DEVICE_RESET_LOCALLY = 90 282 | DOOR_LOCK = 98 283 | DOOR_LOCK_LOGGING = 76 284 | ENERGY_PRODUCTION = 144 285 | ENTRY_CONTROL = 111 286 | FIRMWARE_UPDATE_MD = 122 287 | GENERIC_SCHEDULE = 163 288 | GEOGRAPHIC_LOCATION = 140 289 | GROUPING_NAME = 123 290 | HAIL = 130 291 | HRV_STATUS = 55 292 | HRV_CONTROL = 57 293 | HUMIDITY_CONTROL_MODE = 109 294 | HUMIDITY_CONTROL_OPERATING_STATE = 110 295 | HUMIDITY_CONTROL_SETPOINT = 100 296 | INCLUSION_CONTROLLER = 116 297 | INDICATOR = 135 298 | IP_ASSOCIATION = 92 299 | IP_CONFIGURATION = 154 300 | IR_REPEATER = 160 301 | IRRIGATION = 107 302 | LANGUAGE = 137 303 | LOCK = 118 304 | MAILBOX = 105 305 | MANUFACTURER_PROPRIETARY = 145 306 | MANUFACTURER_SPECIFIC = 114 307 | MARK = 239 308 | METER = 50 309 | METER_TBL_CONFIG = 60 310 | METER_TBL_MONITOR = 61 311 | METER_TBL_PUSH = 62 312 | MTP_WINDOW_COVERING = 81 313 | MULTI_CHANNEL = 96 314 | MULTI_CHANNEL_ASSOCIATION = 142 315 | MULTI_CMD = 143 316 | SENSOR_MULTILEVEL = 49 317 | SWITCH_MULTILEVEL = 38 318 | SWITCH_TOGGLE_MULTILEVEL = 41 319 | NETWORK_MANAGEMENT_BASIC = 77 320 | NETWORK_MANAGEMENT_INCLUSION = 52 321 | NETWORK_MANAGEMENT_INSTALLATION_MAINTENANCE = 103 322 | NETWORK_MANAGEMENT_PRIMARY = 84 323 | NETWORK_MANAGEMENT_PROXY = 82 324 | NO_OPERATION = 0 325 | NODE_NAMING = 119 326 | NODE_PROVISIONING = 120 327 | NOTIFICATION = 113 328 | POWERLEVEL = 115 329 | PREPAYMENT = 63 330 | PREPAYMENT_ENCAPSULATION = 65 331 | PROPRIETARY = 136 332 | PROTECTION = 117 333 | METER_PULSE = 53 334 | RATE_TBL_CONFIG = 72 335 | RATE_TBL_MONITOR = 73 336 | REMOTE_ASSOCIATION_ACTIVATE = 124 337 | REMOTE_ASSOCIATION = 125 338 | SCENE_ACTIVATION = 43 339 | SCENE_ACTUATOR_CONF = 44 340 | SCENE_CONTROLLER_CONF = 45 341 | SCHEDULE = 83 342 | SCHEDULE_ENTRY_LOCK = 78 343 | SCREEN_ATTRIBUTES = 147 344 | SCREEN_MD = 146 345 | SECURITY = 152 346 | SECURITY_2 = 159 347 | SECURITY_SCHEME0_MARK = 61696 348 | SENSOR_CONFIGURATION = 158 349 | SIMPLE_AV_CONTROL = 148 350 | SOUND_SWITCH = 121 351 | SUPERVISION = 108 352 | TARIFF_CONFIG = 74 353 | TARIFF_TBL_MONITOR = 75 354 | THERMOSTAT_FAN_MODE = 68 355 | THERMOSTAT_FAN_STATE = 69 356 | THERMOSTAT_MODE = 64 357 | THERMOSTAT_OPERATING_STATE = 66 358 | THERMOSTAT_SETBACK = 71 359 | THERMOSTAT_SETPOINT = 67 360 | TIME = 138 361 | TIME_PARAMETERS = 139 362 | TRANSPORT_SERVICE = 85 363 | USER_CODE = 99 364 | VERSION = 134 365 | WAKE_UP = 132 366 | WINDOW_COVERING = 106 367 | ZIP = 35 368 | ZIP_6LOWPAN = 79 369 | ZIP_GATEWAY = 95 370 | ZIP_NAMING = 104 371 | ZIP_ND = 88 372 | ZIP_PORTAL = 97 373 | ZWAVEPLUS_INFO = 94 374 | UNKNOWN = 0 375 | 376 | 377 | # DeviceTypes Generic/Specific 378 | 379 | SPECIFIC_TYPE_NOT_USED = 0 # Available in all Generic types 380 | GENERIC_TYPE_AV_CONTROL_POINT = 3 381 | SPECIFIC_TYPE_DOORBELL = 18 382 | SPECIFIC_TYPE_SATELLITE_RECEIVER = 4 383 | SPECIFIC_TYPE_SATELLITE_RECEIVER_V2 = 17 384 | 385 | GENERIC_TYPE_DISPLAY = 4 386 | SPECIFIC_TYPE_SIMPLE_DISPLAY = 1 387 | 388 | GENERIC_TYPE_ENTRY_CONTROL = 64 389 | SPECIFIC_TYPE_DOOR_LOCK = 1 390 | SPECIFIC_TYPE_ADVANCED_DOOR_LOCK = 2 391 | SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK = 3 392 | SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK_DEADBOLT = 4 393 | SPECIFIC_TYPE_SECURE_DOOR = 5 394 | SPECIFIC_TYPE_SECURE_GATE = 6 395 | SPECIFIC_TYPE_SECURE_BARRIER_ADDON = 7 396 | SPECIFIC_TYPE_SECURE_BARRIER_OPEN_ONLY = 8 397 | SPECIFIC_TYPE_SECURE_BARRIER_CLOSE_ONLY = 9 398 | SPECIFIC_TYPE_SECURE_LOCKBOX = 10 399 | SPECIFIC_TYPE_SECURE_KEYPAD = 11 400 | 401 | GENERIC_TYPE_GENERIC_CONTROLLER = 1 402 | SPECIFIC_TYPE_PORTABLE_CONTROLLER = 1 403 | SPECIFIC_TYPE_PORTABLE_SCENE_CONTROLLER = 2 404 | SPECIFIC_TYPE_PORTABLE_INSTALLER_TOOL = 3 405 | SPECIFIC_TYPE_REMOTE_CONTROL_AV = 4 406 | SPECIFIC_TYPE_REMOTE_CONTROL_SIMPLE = 6 407 | 408 | GENERIC_TYPE_METER = 49 409 | SPECIFIC_TYPE_SIMPLE_METER = 1 410 | SPECIFIC_TYPE_ADV_ENERGY_CONTROL = 2 411 | SPECIFIC_TYPE_WHOLE_HOME_METER_SIMPLE = 3 412 | 413 | GENERIC_TYPE_METER_PULSE = 48 414 | 415 | GENERIC_TYPE_NON_INTEROPERABLE = 255 416 | 417 | GENERIC_TYPE_REPEATER_SLAVE = 15 418 | SPECIFIC_TYPE_REPEATER_SLAVE = 1 419 | SPECIFIC_TYPE_VIRTUAL_NODE = 2 420 | 421 | GENERIC_TYPE_SECURITY_PANEL = 23 422 | SPECIFIC_TYPE_ZONED_SECURITY_PANEL = 1 423 | 424 | GENERIC_TYPE_SEMI_INTEROPERABLE = 80 425 | SPECIFIC_TYPE_ENERGY_PRODUCTION = 1 426 | 427 | GENERIC_TYPE_SENSOR_ALARM = 161 428 | SPECIFIC_TYPE_ADV_ZENSOR_NET_ALARM_SENSOR = 5 429 | SPECIFIC_TYPE_ADV_ZENSOR_NET_SMOKE_SENSOR = 10 430 | SPECIFIC_TYPE_BASIC_ROUTING_ALARM_SENSOR = 1 431 | SPECIFIC_TYPE_BASIC_ROUTING_SMOKE_SENSOR = 6 432 | SPECIFIC_TYPE_BASIC_ZENSOR_NET_ALARM_SENSOR = 3 433 | SPECIFIC_TYPE_BASIC_ZENSOR_NET_SMOKE_SENSOR = 8 434 | SPECIFIC_TYPE_ROUTING_ALARM_SENSOR = 2 435 | SPECIFIC_TYPE_ROUTING_SMOKE_SENSOR = 7 436 | SPECIFIC_TYPE_ZENSOR_NET_ALARM_SENSOR = 4 437 | SPECIFIC_TYPE_ZENSOR_NET_SMOKE_SENSOR = 9 438 | SPECIFIC_TYPE_ALARM_SENSOR = 11 439 | 440 | GENERIC_TYPE_SENSOR_BINARY = 32 441 | SPECIFIC_TYPE_ROUTING_SENSOR_BINARY = 1 442 | 443 | GENERIC_TYPE_SENSOR_MULTILEVEL = 33 444 | SPECIFIC_TYPE_ROUTING_SENSOR_MULTILEVEL = 1 445 | SPECIFIC_TYPE_CHIMNEY_FAN = 2 446 | 447 | GENERIC_TYPE_STATIC_CONTROLLER = 2 448 | SPECIFIC_TYPE_PC_CONTROLLER = 1 449 | SPECIFIC_TYPE_SCENE_CONTROLLER = 2 450 | SPECIFIC_TYPE_STATIC_INSTALLER_TOOL = 3 451 | SPECIFIC_TYPE_SET_TOP_BOX = 4 452 | SPECIFIC_TYPE_SUB_SYSTEM_CONTROLLER = 5 453 | SPECIFIC_TYPE_TV = 6 454 | SPECIFIC_TYPE_GATEWAY = 7 455 | 456 | GENERIC_TYPE_SWITCH_BINARY = 16 457 | SPECIFIC_TYPE_POWER_SWITCH_BINARY = 1 458 | SPECIFIC_TYPE_SCENE_SWITCH_BINARY = 3 459 | SPECIFIC_TYPE_POWER_STRIP = 4 460 | SPECIFIC_TYPE_SIREN = 5 461 | SPECIFIC_TYPE_VALVE_OPEN_CLOSE = 6 462 | SPECIFIC_TYPE_COLOR_TUNABLE_BINARY = 2 463 | SPECIFIC_TYPE_IRRIGATION_CONTROLLER = 7 464 | 465 | GENERIC_TYPE_SWITCH_MULTILEVEL = 17 466 | SPECIFIC_TYPE_CLASS_A_MOTOR_CONTROL = 5 467 | SPECIFIC_TYPE_CLASS_B_MOTOR_CONTROL = 6 468 | SPECIFIC_TYPE_CLASS_C_MOTOR_CONTROL = 7 469 | SPECIFIC_TYPE_MOTOR_MULTIPOSITION = 3 470 | SPECIFIC_TYPE_POWER_SWITCH_MULTILEVEL = 1 471 | SPECIFIC_TYPE_SCENE_SWITCH_MULTILEVEL = 4 472 | SPECIFIC_TYPE_FAN_SWITCH = 8 473 | SPECIFIC_TYPE_COLOR_TUNABLE_MULTILEVEL = 2 474 | 475 | GENERIC_TYPE_SWITCH_REMOTE = 18 476 | SPECIFIC_TYPE_REMOTE_BINARY = 1 477 | SPECIFIC_TYPE_REMOTE_MULTILEVEL = 2 478 | SPECIFIC_TYPE_REMOTE_TOGGLE_BINARY = 3 479 | SPECIFIC_TYPE_REMOTE_TOGGLE_MULTILEVEL = 4 480 | 481 | GENERIC_TYPE_SWITCH_TOGGLE = 19 482 | SPECIFIC_TYPE_SWITCH_TOGGLE_BINARY = 1 483 | SPECIFIC_TYPE_SWITCH_TOGGLE_MULTILEVEL = 2 484 | 485 | GENERIC_TYPE_THERMOSTAT = 8 486 | SPECIFIC_TYPE_SETBACK_SCHEDULE_THERMOSTAT = 3 487 | SPECIFIC_TYPE_SETBACK_THERMOSTAT = 5 488 | SPECIFIC_TYPE_SETPOINT_THERMOSTAT = 4 489 | SPECIFIC_TYPE_THERMOSTAT_GENERAL = 2 490 | SPECIFIC_TYPE_THERMOSTAT_GENERAL_V2 = 6 491 | SPECIFIC_TYPE_THERMOSTAT_HEATING = 1 492 | 493 | GENERIC_TYPE_VENTILATION = 22 494 | SPECIFIC_TYPE_RESIDENTIAL_HRV = 1 495 | 496 | GENERIC_TYPE_WINDOWS_COVERING = 9 497 | SPECIFIC_TYPE_SIMPLE_WINDOW_COVERING = 1 498 | 499 | GENERIC_TYPE_ZIP_NODE = 21 500 | SPECIFIC_TYPE_ZIP_ADV_NODE = 2 501 | SPECIFIC_TYPE_ZIP_TUN_NODE = 1 502 | 503 | GENERIC_TYPE_WALL_CONTROLLER = 24 504 | SPECIFIC_TYPE_BASIC_WALL_CONTROLLER = 1 505 | 506 | GENERIC_TYPE_NETWORK_EXTENDER = 5 507 | SPECIFIC_TYPE_SECURE_EXTENDER = 1 508 | 509 | GENERIC_TYPE_APPLIANCE = 6 510 | SPECIFIC_TYPE_GENERAL_APPLIANCE = 1 511 | SPECIFIC_TYPE_KITCHEN_APPLIANCE = 2 512 | SPECIFIC_TYPE_LAUNDRY_APPLIANCE = 3 513 | 514 | GENERIC_TYPE_SENSOR_NOTIFICATION = 7 515 | SPECIFIC_TYPE_NOTIFICATION_SENSOR = 1 516 | -------------------------------------------------------------------------------- /openzwavemqtt/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for OpenZWave MQTT.""" 2 | 3 | 4 | class BaseOZWError(Exception): 5 | """Base OpenZWave MQTT exception.""" 6 | 7 | 8 | class NotFoundError(BaseOZWError): 9 | """Exception that is raised when an entity can't be found.""" 10 | 11 | 12 | class NotSupportedError(BaseOZWError): 13 | """Exception that is raised when an action isn't supported.""" 14 | 15 | 16 | class WrongTypeError(NotSupportedError): 17 | """Exception that is raised when an input is the wrong type.""" 18 | 19 | 20 | class InvalidValueError(NotSupportedError): 21 | """Exception that is raised when an input value is invalid.""" 22 | -------------------------------------------------------------------------------- /openzwavemqtt/manager.py: -------------------------------------------------------------------------------- 1 | """Root Manager object.""" 2 | import json 3 | from collections import deque 4 | from typing import TYPE_CHECKING, Dict, Optional, Type, Union 5 | 6 | from .base import ItemCollection, ZWaveBase 7 | from .const import EMPTY_PAYLOAD 8 | from .models.instance import OZWInstance 9 | from .options import OZWOptions 10 | 11 | if TYPE_CHECKING: 12 | from .base import DiscardMessages, EventMessages # noqa: F401 13 | 14 | 15 | class OZWManager(ZWaveBase): 16 | """Manager that holds the OZW instances connected to MQTT.""" 17 | 18 | DIRECT_COLLECTION = "instance" 19 | DEFAULT_VALUE: Optional[dict] = None 20 | EVENT_CHANGED = "manager_placeholder_event" 21 | 22 | def __init__(self, options: OZWOptions): 23 | """Initialize class.""" 24 | super().__init__(options, None, options.topic_prefix, None) 25 | 26 | def create_collections( 27 | self, 28 | ) -> Dict[ 29 | str, 30 | Union[ItemCollection, Type["ZWaveBase"], "DiscardMessages", "EventMessages"], 31 | ]: 32 | """Create collections that the manager supports.""" 33 | return {"instance": ItemCollection(OZWInstance)} 34 | 35 | def receive_message(self, topic: str, message: str) -> None: 36 | """Receive an MQTT message.""" 37 | assert topic.startswith(self.options.topic_prefix) 38 | 39 | topic_parts_raw = topic[len(self.options.topic_prefix) :].split("/") 40 | instance_id = self.options.instance_id 41 | 42 | if instance_id is not None and topic_parts_raw[0] != str(instance_id): 43 | return 44 | 45 | if topic_parts_raw[-1] == "": 46 | topic_parts_raw.pop() 47 | topic_parts = deque(topic_parts_raw) 48 | 49 | if message == "": 50 | payload = EMPTY_PAYLOAD 51 | else: 52 | payload = json.loads(message) 53 | 54 | self.process_message(topic_parts, payload) 55 | -------------------------------------------------------------------------------- /openzwavemqtt/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide openzwavemqtt models.""" 2 | -------------------------------------------------------------------------------- /openzwavemqtt/models/command_class.py: -------------------------------------------------------------------------------- 1 | """Model for the CommandClass.""" 2 | from typing import Optional 3 | 4 | from ..base import ItemCollection 5 | from ..const import ( 6 | EVENT_COMMAND_CLASS_ADDED, 7 | EVENT_COMMAND_CLASS_CHANGED, 8 | EVENT_COMMAND_CLASS_REMOVED, 9 | LOGGER, 10 | CommandClass, 11 | ValueIndex, 12 | ) 13 | from .node_child_base import OZWNodeChildBase 14 | from .value import OZWValue 15 | 16 | 17 | class OZWCommandClass(OZWNodeChildBase): 18 | """Model for the OZW CommandClass.""" 19 | 20 | EVENT_ADDED = EVENT_COMMAND_CLASS_ADDED 21 | EVENT_CHANGED = EVENT_COMMAND_CLASS_CHANGED 22 | EVENT_REMOVED = EVENT_COMMAND_CLASS_REMOVED 23 | 24 | PLURAL_NAME = "commandclasses" 25 | 26 | @property 27 | def instance(self) -> int: 28 | """Return Instance.""" 29 | return self.data.get("Instance") 30 | 31 | @property 32 | def command_class_id(self) -> CommandClass: 33 | """Return CommandClassId as CommandClass Enum.""" 34 | try: 35 | return CommandClass(self.data.get("CommandClassId")) 36 | except ValueError: 37 | LOGGER.warning( 38 | "Unknown CommandClass found: %s", self.data.get("CommandClassId") 39 | ) 40 | return CommandClass.UNKNOWN 41 | 42 | @property 43 | def command_class(self) -> str: 44 | """Return string/label representation of this CommandClass.""" 45 | # TODO: This needs changing to a better name once fixed upstream in the daemon 46 | return self.data.get("CommandClass") 47 | 48 | @property 49 | def time_stamp(self) -> int: 50 | """Return TimeStamp.""" 51 | return self.data.get("TimeStamp") 52 | 53 | def create_collections(self): 54 | """Create collections that Node supports.""" 55 | return {"value": ItemCollection(OZWValue)} 56 | 57 | def get_value_by_index(self, value_index: ValueIndex) -> Optional[OZWValue]: 58 | """Return a specific OZWValue on this CommandClass (if exists).""" 59 | # pylint: disable=no-member 60 | for value in self.values(): 61 | if value.index == value_index: 62 | return value 63 | return None 64 | 65 | def has_value(self, value_index: ValueIndex) -> bool: 66 | """Determine if the CommandClass has the given ValueIndex.""" 67 | return self.get_value_by_index(value_index) is not None 68 | -------------------------------------------------------------------------------- /openzwavemqtt/models/instance.py: -------------------------------------------------------------------------------- 1 | """Model for the OZW instance level.""" 2 | from typing import Dict, Optional, Type, Union 3 | 4 | from .. import base 5 | from ..const import ( 6 | EVENT_INSTANCE_ADDED, 7 | EVENT_INSTANCE_CHANGED, 8 | EVENT_INSTANCE_EVENT, 9 | EVENT_INSTANCE_REMOVED, 10 | ) 11 | from .instance_statistics import OZWInstanceStatistics 12 | from .instance_status import OZWInstanceStatus 13 | from .node import OZWNode 14 | 15 | 16 | class OZWInstance(base.ZWaveBase): 17 | """Model for the OZW instance level.""" 18 | 19 | DEFAULT_VALUE: Optional[dict] = None 20 | 21 | EVENT_ADDED = EVENT_INSTANCE_ADDED 22 | EVENT_CHANGED = EVENT_INSTANCE_CHANGED 23 | EVENT_REMOVED = EVENT_INSTANCE_REMOVED 24 | 25 | def create_collections( 26 | self, 27 | ) -> Dict[ 28 | str, 29 | Union[ 30 | base.ItemCollection, 31 | Type[base.ZWaveBase], 32 | base.DiscardMessages, 33 | base.EventMessages, 34 | ], 35 | ]: 36 | """Create collections that Node supports.""" 37 | return { 38 | "node": base.ItemCollection(OZWNode), 39 | "status": OZWInstanceStatus, 40 | "statistics": OZWInstanceStatistics, 41 | "command": base.DiscardMessages(), 42 | "event": base.EventMessages( 43 | self.options, EVENT_INSTANCE_EVENT, lambda topic, data: topic[0] 44 | ), 45 | } 46 | 47 | def send_command(self, command: str, payload: Optional[dict] = None) -> None: 48 | """Send command to the OZW instance.""" 49 | if payload is None: 50 | payload = {} 51 | topic_prefix = self.options.topic_prefix 52 | full_topic = f"{topic_prefix}{self.id}/command/{command}/" 53 | self.options.send_message(full_topic, payload) 54 | 55 | # Shortcut methods to some common used (global) controller commands 56 | # https://github.com/OpenZWave/qt-openzwave/blob/master/docs/MQTT.md#mqtt-commands 57 | 58 | def add_node(self, secure: bool = False) -> None: 59 | """Enter inclusion mode on the controller.""" 60 | self.send_command("addnode", {"secure": secure}) 61 | 62 | def remove_node(self) -> None: 63 | """Enter exclusion mode on the controller.""" 64 | self.send_command("removenode") 65 | 66 | def refresh_node(self, node_id: int) -> None: 67 | """Force OZW to re-interview a device.""" 68 | self.send_command("refreshnodeinfo", {"node": node_id}) 69 | 70 | def remove_failed_node(self, node_id: int) -> None: 71 | """Remove a failed node from the controller.""" 72 | self.send_command("removefailednode", {"node": node_id}) 73 | 74 | def replace_failed_node(self, node_id: int) -> None: 75 | """Replace a failed node from the controller with a new device.""" 76 | self.send_command("replacefailednode", {"node": node_id}) 77 | 78 | def heal_node(self, node_id: int) -> None: 79 | """Ask a Node to recalculate its neighbors and routes to other devices.""" 80 | self.send_command("healnetworknode", {"node": node_id}) 81 | 82 | def cancel_controller_command(self) -> None: 83 | """Cancel in Controller Commands that are in progress.""" 84 | self.send_command("cancelcontrollercommand") 85 | 86 | def check_node_failed(self, node_id: int) -> None: 87 | """Force OZW to test communication with a node.""" 88 | self.send_command("hasnodefailed", {"node": node_id}) 89 | 90 | def refresh_value(self, value_id: int) -> None: 91 | """Refresh a specific value.""" 92 | self.send_command("refreshvalue", {"ValueIDKey": value_id}) 93 | 94 | def refresh_values(self, node_id: int) -> None: 95 | """Refresh dynamic and static values for a node.""" 96 | self.send_command("requestnodestate", {"node": node_id}) 97 | 98 | def refresh_dynamic_values(self, node_id: int) -> None: 99 | """Refresh dynamic values for a node.""" 100 | self.send_command("requestnodedynamic", {"node": node_id}) 101 | -------------------------------------------------------------------------------- /openzwavemqtt/models/instance_statistics.py: -------------------------------------------------------------------------------- 1 | """Model for the Instance statistics.""" 2 | from ..const import EVENT_INSTANCE_STATISTICS_CHANGED 3 | from .node_child_base import OZWNodeChildBase 4 | 5 | 6 | class OZWInstanceStatistics(OZWNodeChildBase): 7 | """Model for OZW Instance statistics.""" 8 | 9 | EVENT_CHANGED = EVENT_INSTANCE_STATISTICS_CHANGED 10 | 11 | @property 12 | def sof_cnt(self) -> int: 13 | """Return SOFCnt.""" 14 | return self.data.get("SOFCnt") 15 | 16 | @property 17 | def ack_waiting(self) -> int: 18 | """Return ACKWaiting.""" 19 | return self.data.get("ACKWaiting") 20 | 21 | @property 22 | def read_aborts(self) -> int: 23 | """Return readAborts.""" 24 | return self.data.get("readAborts") 25 | 26 | @property 27 | def bad_checksum(self) -> int: 28 | """Return badChecksum.""" 29 | return self.data.get("badChecksum") 30 | 31 | @property 32 | def read_cnt(self) -> int: 33 | """Return readCnt.""" 34 | return self.data.get("readCnt") 35 | 36 | @property 37 | def write_cnt(self) -> int: 38 | """Return writeCnt.""" 39 | return self.data.get("writeCnt") 40 | 41 | @property 42 | def can_cnt(self) -> int: 43 | """Return CANCnt.""" 44 | return self.data.get("CANCnt") 45 | 46 | @property 47 | def nak_cnt(self) -> int: 48 | """Return NAKCnt.""" 49 | return self.data.get("NAKCnt") 50 | 51 | @property 52 | def ack_cnt(self) -> int: 53 | """Return ACKCnt.""" 54 | return self.data.get("ACKCnt") 55 | 56 | @property 57 | def oof_cnt(self) -> int: 58 | """Return OOFCnt.""" 59 | return self.data.get("OOFCnt") 60 | 61 | @property 62 | def dropped(self) -> int: 63 | """Return dropped.""" 64 | return self.data.get("dropped") 65 | 66 | @property 67 | def retries(self) -> int: 68 | """Return retries.""" 69 | return self.data.get("retries") 70 | 71 | @property 72 | def callbacks(self) -> int: 73 | """Return callbacks.""" 74 | return self.data.get("callbacks") 75 | 76 | @property 77 | def badroutes(self) -> int: 78 | """Return badroutes.""" 79 | return self.data.get("badroutes") 80 | 81 | @property 82 | def noack(self) -> int: 83 | """Return noack.""" 84 | return self.data.get("noack") 85 | 86 | @property 87 | def netbusy(self) -> int: 88 | """Return netbusy.""" 89 | return self.data.get("netbusy") 90 | 91 | @property 92 | def notidle(self) -> int: 93 | """Return notidle.""" 94 | return self.data.get("notidle") 95 | 96 | @property 97 | def txverified(self) -> int: 98 | """Return txverified.""" 99 | return self.data.get("txverified") 100 | 101 | @property 102 | def nondelivery(self) -> int: 103 | """Return nondelivery.""" 104 | return self.data.get("nondelivery") 105 | 106 | @property 107 | def routedbusy(self) -> int: 108 | """Return routedbusy.""" 109 | return self.data.get("routedbusy") 110 | 111 | @property 112 | def broadcast_read_cnt(self) -> int: 113 | """Return broadcastReadCnt.""" 114 | return self.data.get("broadcastReadCnt") 115 | 116 | @property 117 | def broadcast_write_cnt(self) -> int: 118 | """Return broadcastWriteCnt.""" 119 | return self.data.get("broadcastWriteCnt") 120 | -------------------------------------------------------------------------------- /openzwavemqtt/models/instance_status.py: -------------------------------------------------------------------------------- 1 | """Model for OZW Instance Status.""" 2 | from .. import base 3 | from ..const import EVENT_INSTANCE_STATUS_CHANGED 4 | 5 | 6 | class OZWInstanceStatus(base.ZWaveBase): 7 | """Model for OZW Instance Status.""" 8 | 9 | EVENT_CHANGED = EVENT_INSTANCE_STATUS_CHANGED 10 | 11 | @property 12 | def status(self): 13 | """Return current status of this OZW Instance.""" 14 | return self.data.get("Status") 15 | 16 | @property 17 | def home_id(self): 18 | """Return the homeID of this OZW Instance.""" 19 | return self.data.get("homeID") 20 | 21 | @property 22 | def manufacturer_specific_db_ready(self) -> bool: 23 | """Return ManufacturerSpecificDBReady.""" 24 | return self.data.get("ManufacturerSpecificDBReady") 25 | 26 | @property 27 | def time_stamp(self) -> int: 28 | """Return TimeStamp.""" 29 | return self.data.get("TimeStamp") 30 | 31 | @property 32 | def openzwave_version(self) -> str: 33 | """Return OpenZWave_Version.""" 34 | return self.data.get("OpenZWave_Version") 35 | 36 | @property 37 | def ozw_deamon_version(self) -> str: 38 | """Return OZWDeamon_Version.""" 39 | return self.data.get("OZWDeamon_Version") 40 | 41 | @property 42 | def qt_openzwave_version(self) -> str: 43 | """Return QTOpenZWave_Version.""" 44 | return self.data.get("QTOpenZWave_Version") 45 | 46 | @property 47 | def qt_version(self) -> str: 48 | """Return QT_Version.""" 49 | return self.data.get("QT_Version") 50 | 51 | @property 52 | def get_controller_node_id(self) -> int: 53 | """Return getControllerNodeId.""" 54 | return self.data.get("getControllerNodeId") 55 | 56 | @property 57 | def get_suc_node_id(self) -> int: 58 | """Return getSUCNodeId.""" 59 | return self.data.get("getSUCNodeId") 60 | 61 | @property 62 | def is_primary_controller(self) -> bool: 63 | """Return isPrimaryController.""" 64 | return self.data.get("isPrimaryController") 65 | 66 | @property 67 | def is_bridge_controller(self) -> bool: 68 | """Return isBridgeController.""" 69 | return self.data.get("isBridgeController") 70 | 71 | @property 72 | def has_extended_tx_statistics(self) -> bool: 73 | """Return hasExtendedTXStatistics.""" 74 | return self.data.get("hasExtendedTXStatistics") 75 | 76 | @property 77 | def get_controller_library_version(self) -> str: 78 | """Return getControllerLibraryVersion.""" 79 | return self.data.get("getControllerLibraryVersion") 80 | 81 | @property 82 | def get_controller_library_type(self) -> str: 83 | """Return getControllerLibraryType.""" 84 | return self.data.get("getControllerLibraryType") 85 | 86 | @property 87 | def get_controller_path(self) -> str: 88 | """Return getControllerPath.""" 89 | return self.data.get("getControllerPath") 90 | -------------------------------------------------------------------------------- /openzwavemqtt/models/node.py: -------------------------------------------------------------------------------- 1 | """Model for a OZW Node.""" 2 | from typing import Iterable, List, Optional, Union 3 | 4 | from ..base import ItemCollection, ZWaveBase 5 | from ..const import ( 6 | EVENT_NODE_ADDED, 7 | EVENT_NODE_CHANGED, 8 | EVENT_NODE_REMOVED, 9 | CommandClass, 10 | ValueIndex, 11 | ) 12 | from .command_class import OZWCommandClass 13 | from .node_association import OZWNodeAssociation 14 | from .node_instance import OZWNodeInstance 15 | from .node_statistics import OZWNodeStatistics 16 | from .value import OZWValue 17 | 18 | 19 | class OZWNode(ZWaveBase): 20 | """Model for a Z-Wave Node.""" 21 | 22 | EVENT_ADDED = EVENT_NODE_ADDED 23 | EVENT_CHANGED = EVENT_NODE_CHANGED 24 | EVENT_REMOVED = EVENT_NODE_REMOVED 25 | 26 | @property 27 | def node_id(self) -> int: 28 | """Return NodeID.""" 29 | return self.data.get("NodeID") 30 | 31 | @property 32 | def node_query_stage(self) -> str: 33 | """Return NodeQueryStage.""" 34 | return self.data.get("NodeQueryStage") 35 | 36 | @property 37 | def is_listening(self) -> bool: 38 | """Return isListening.""" 39 | return self.data.get("isListening") 40 | 41 | @property 42 | def is_flirs(self) -> bool: 43 | """Return isFlirs.""" 44 | return self.data.get("isFlirs") 45 | 46 | @property 47 | def is_beaming(self) -> bool: 48 | """Return isBeaming.""" 49 | return self.data.get("isBeaming") 50 | 51 | @property 52 | def is_routing(self) -> bool: 53 | """Return isRouting.""" 54 | return self.data.get("isRouting") 55 | 56 | @property 57 | def is_securityv1(self) -> bool: 58 | """Return isSecurityv1.""" 59 | return self.data.get("isSecurityv1") 60 | 61 | @property 62 | def is_zwave_plus(self) -> bool: 63 | """Return isZWavePlus.""" 64 | return self.data.get("isZWavePlus") 65 | 66 | @property 67 | def is_nif_recieved(self) -> bool: 68 | """Return isNIFRecieved.""" 69 | return self.data.get("isNIFRecieved") 70 | 71 | @property 72 | def is_awake(self) -> bool: 73 | """Return isAwake.""" 74 | return self.data.get("isAwake") 75 | 76 | @property 77 | def is_failed(self) -> bool: 78 | """Return isFailed.""" 79 | return self.data.get("isFailed") 80 | 81 | @property 82 | def meta_data(self) -> dict: 83 | """Return MetaData.""" 84 | return self.data.get("MetaData") 85 | 86 | @property 87 | def event(self) -> str: 88 | """Return Event.""" 89 | return self.data.get("Event") 90 | 91 | @property 92 | def time_stamp(self) -> int: 93 | """Return TimeStamp.""" 94 | return self.data.get("TimeStamp") 95 | 96 | @property 97 | def node_manufacturer_name(self) -> str: 98 | """Return NodeManufacturerName.""" 99 | return self.data.get("NodeManufacturerName") 100 | 101 | @property 102 | def node_product_name(self) -> str: 103 | """Return NodeProductName.""" 104 | return self.data.get("NodeProductName") 105 | 106 | @property 107 | def node_basic_string(self) -> str: 108 | """Return NodeBasicString.""" 109 | return self.data.get("NodeBasicString") 110 | 111 | @property 112 | def node_basic(self) -> int: 113 | """Return NodeBasic.""" 114 | return self.data.get("NodeBasic") 115 | 116 | @property 117 | def node_generic_string(self) -> str: 118 | """Return NodeGenericString.""" 119 | return self.data.get("NodeGenericString") 120 | 121 | @property 122 | def node_generic(self) -> int: 123 | """Return NodeGeneric.""" 124 | return self.data.get("NodeGeneric") 125 | 126 | @property 127 | def node_specific_string(self) -> int: 128 | """Return NodeSpecificString.""" 129 | return self.data.get("NodeSpecificString") 130 | 131 | @property 132 | def node_specific(self) -> int: 133 | """Return NodeSpecific.""" 134 | return self.data.get("NodeSpecific") 135 | 136 | @property 137 | def node_manufacturer_id(self) -> str: 138 | """Return NodeManufacturerID.""" 139 | return self.data.get("NodeManufacturerID") 140 | 141 | @property 142 | def node_product_type(self) -> str: 143 | """Return NodeProductType.""" 144 | return self.data.get("NodeProductType") 145 | 146 | @property 147 | def node_product_id(self) -> str: 148 | """Return NodeProductID.""" 149 | return self.data.get("NodeProductID") 150 | 151 | @property 152 | def node_baud_rate(self) -> int: 153 | """Return NodeBaudRate.""" 154 | return self.data.get("NodeBaudRate") 155 | 156 | @property 157 | def node_version(self) -> int: 158 | """Return NodeVersion.""" 159 | return self.data.get("NodeVersion") 160 | 161 | @property 162 | def node_groups(self) -> int: 163 | """Return NodeGroups.""" 164 | return self.data.get("NodeGroups") 165 | 166 | @property 167 | def node_name(self) -> str: 168 | """Return NodeName.""" 169 | return self.data.get("NodeName") 170 | 171 | @property 172 | def node_location(self) -> str: 173 | """Return NodeLocation.""" 174 | return self.data.get("NodeLocation") 175 | 176 | @property 177 | def node_device_type_string(self) -> str: 178 | """Return NodeDeviceTypeString.""" 179 | return self.data.get("NodeDeviceTypeString") 180 | 181 | @property 182 | def node_device_type(self) -> int: 183 | """Return NodeDeviceType.""" 184 | return self.data.get("NodeDeviceType") 185 | 186 | @property 187 | def node_role(self) -> int: 188 | """Return NodeRole.""" 189 | return self.data.get("NodeRole") 190 | 191 | @property 192 | def node_role_string(self) -> str: 193 | """Return NodeRoleString.""" 194 | return self.data.get("NodeRoleString") 195 | 196 | @property 197 | def node_plus_type(self) -> int: 198 | """Return NodePlusType.""" 199 | return self.data.get("NodePlusType") 200 | 201 | @property 202 | def node_plus_type_string(self) -> str: 203 | """Return NodePlusTypeString.""" 204 | return self.data.get("NodePlusTypeString") 205 | 206 | @property 207 | def neighbors(self) -> List[int]: 208 | """Return Neighbors.""" 209 | return self.data.get("Neighbors") 210 | 211 | def values(self) -> Iterable["OZWValue"]: 212 | """Iterate over all OZWValue child items.""" 213 | # pylint: disable=no-member 214 | return ( 215 | value 216 | for instance in self.instances() 217 | for cc in instance.commandclasses() 218 | for value in cc.values() 219 | ) 220 | 221 | def create_collections(self): 222 | """Create collections that Node supports.""" 223 | return { 224 | "instance": ItemCollection(OZWNodeInstance), 225 | "association": ItemCollection(OZWNodeAssociation), 226 | "statistics": OZWNodeStatistics, 227 | } 228 | 229 | def get_command_class( 230 | self, command_class_id: CommandClass, instance_id: Optional[int] = None 231 | ) -> Optional[OZWCommandClass]: 232 | """Return a specific CommandClass on this node (if exists).""" 233 | # pylint: disable=no-member 234 | for instance in self.instances(): 235 | if instance_id is not None and instance.instance != instance_id: 236 | continue 237 | return instance.get_command_class(command_class_id) 238 | return None 239 | 240 | def has_command_class( 241 | self, command_class_id: CommandClass, instance_id: Optional[int] = None 242 | ) -> bool: 243 | """Determine if the node has the given CommandClass.""" 244 | return self.get_command_class(command_class_id, instance_id) is not None 245 | 246 | def get_value( 247 | self, 248 | command_class_id: CommandClass, 249 | value_index: Union[ValueIndex, int], 250 | instance_id: Optional[int] = None, 251 | ) -> Optional[OZWValue]: 252 | """Return a specific OZWValue on this node (if exists).""" 253 | command_class = self.get_command_class(command_class_id, instance_id) 254 | return command_class.get_value_by_index(value_index) if command_class else None 255 | 256 | def has_value( 257 | self, 258 | command_class_id: CommandClass, 259 | value_index: Union[ValueIndex, int], 260 | instance_id: Optional[int] = None, 261 | ) -> bool: 262 | """Determine if the node has the given Value.""" 263 | return self.get_value(command_class_id, value_index, instance_id) is not None 264 | -------------------------------------------------------------------------------- /openzwavemqtt/models/node_association.py: -------------------------------------------------------------------------------- 1 | """Model for the Node Associations level.""" 2 | from ..const import ( 3 | EVENT_NODE_ASSOCIATION_ADDED, 4 | EVENT_NODE_ASSOCIATION_CHANGED, 5 | EVENT_NODE_ASSOCIATION_REMOVED, 6 | ) 7 | from .node_child_base import OZWNodeChildBase 8 | 9 | 10 | class OZWNodeAssociation(OZWNodeChildBase): 11 | """Model for Node Associations.""" 12 | 13 | EVENT_ADDED = EVENT_NODE_ASSOCIATION_ADDED 14 | EVENT_CHANGED = EVENT_NODE_ASSOCIATION_CHANGED 15 | EVENT_REMOVED = EVENT_NODE_ASSOCIATION_REMOVED 16 | 17 | @property 18 | def name(self) -> str: 19 | """Return Name.""" 20 | return self.data.get("Name") 21 | 22 | @property 23 | def help(self) -> str: 24 | """Return Help.""" 25 | return self.data.get("Help") 26 | 27 | @property 28 | def max_associations(self) -> int: 29 | """Return MaxAssociations.""" 30 | return self.data.get("MaxAssociations") 31 | 32 | @property 33 | def members(self) -> list: 34 | """Return Members.""" 35 | return self.data.get("Members") 36 | 37 | @property 38 | def time_stamp(self) -> int: 39 | """Return TimeStamp.""" 40 | return self.data.get("TimeStamp") 41 | -------------------------------------------------------------------------------- /openzwavemqtt/models/node_child_base.py: -------------------------------------------------------------------------------- 1 | """Base class with Node specific helpers.""" 2 | 3 | from typing import cast 4 | 5 | from ..base import ZWaveBase 6 | 7 | 8 | class OZWNodeChildBase(ZWaveBase): 9 | """Base class for objects that are descendants of a Node object.""" 10 | 11 | @property 12 | def node(self): 13 | """Return the node that this child belongs to.""" 14 | from .node import OZWNode 15 | 16 | parent = self.parent 17 | 18 | while parent is not None and not isinstance(parent, OZWNode): 19 | parent = parent.parent 20 | 21 | if isinstance(parent, OZWNode): 22 | return cast(OZWNode, parent) 23 | 24 | raise RuntimeError("Object is not a descendant of a Node") 25 | 26 | def __repr__(self): 27 | """Return a representation of this object.""" 28 | iden = f" {self.id}" if self.id else "" 29 | 30 | try: 31 | node = self.node.id 32 | except RuntimeError: 33 | node = " (bad!)" 34 | 35 | return f"<{type(self).__name__}{iden} (node: {node})>" 36 | -------------------------------------------------------------------------------- /openzwavemqtt/models/node_instance.py: -------------------------------------------------------------------------------- 1 | """Model for the Node instance level.""" 2 | from typing import Optional 3 | 4 | from ..base import ItemCollection 5 | from ..const import ( 6 | EVENT_NODE_INSTANCE_ADDED, 7 | EVENT_NODE_INSTANCE_CHANGED, 8 | EVENT_NODE_INSTANCE_REMOVED, 9 | CommandClass, 10 | ValueIndex, 11 | ) 12 | from .command_class import OZWCommandClass 13 | from .node_child_base import OZWNodeChildBase 14 | from .value import OZWValue 15 | 16 | 17 | class OZWNodeInstance(OZWNodeChildBase): 18 | """Model for Node Instance.""" 19 | 20 | EVENT_ADDED = EVENT_NODE_INSTANCE_ADDED 21 | EVENT_CHANGED = EVENT_NODE_INSTANCE_CHANGED 22 | EVENT_REMOVED = EVENT_NODE_INSTANCE_REMOVED 23 | 24 | @property 25 | def instance(self) -> int: 26 | """Return Instance.""" 27 | return self.data.get("Instance") 28 | 29 | @property 30 | def time_stamp(self) -> int: 31 | """Return TimeStamp.""" 32 | return self.data.get("TimeStamp") 33 | 34 | def create_collections(self): 35 | """Create collections that Node supports.""" 36 | return {"commandclass": ItemCollection(OZWCommandClass)} 37 | 38 | def get_command_class( 39 | self, command_class_id: CommandClass 40 | ) -> Optional[OZWCommandClass]: 41 | """Return a specific CommandClass on this NodeInstance (if exists).""" 42 | # pylint: disable=no-member 43 | for command_class in self.commandclasses(): 44 | if command_class.command_class_id == command_class_id: 45 | return command_class 46 | return None 47 | 48 | def has_command_class(self, command_class_id: CommandClass) -> bool: 49 | """Determine if the node has the given CommandClass.""" 50 | return self.get_command_class(command_class_id) is not None 51 | 52 | def get_value( 53 | self, command_class_id: CommandClass, value_index: ValueIndex 54 | ) -> Optional[OZWValue]: 55 | """Return a specific OZWValue on this node (if exists).""" 56 | command_class = self.get_command_class(command_class_id) 57 | return command_class.get_value(value_index) if command_class else None 58 | 59 | def has_value( 60 | self, command_class_id: CommandClass, value_index: ValueIndex 61 | ) -> bool: 62 | """Determine if this NodeInstance has the given OZWValue.""" 63 | return self.get_value(command_class_id, value_index) is not None 64 | -------------------------------------------------------------------------------- /openzwavemqtt/models/node_statistics.py: -------------------------------------------------------------------------------- 1 | """Model for Node Statistics.""" 2 | from typing import List 3 | 4 | from ..base import ZWaveBase 5 | from ..const import EVENT_NODE_ADDED, EVENT_NODE_REMOVED, EVENT_NODE_STATISTICS_CHANGED 6 | 7 | 8 | class OZWNodeStatistics(ZWaveBase): 9 | """Model for Node Statistics.""" 10 | 11 | EVENT_ADDED = EVENT_NODE_ADDED 12 | EVENT_CHANGED = EVENT_NODE_STATISTICS_CHANGED 13 | EVENT_REMOVED = EVENT_NODE_REMOVED 14 | 15 | @property 16 | def send_count(self) -> int: 17 | """Return sendCount.""" 18 | return self.data.get("sendCount") 19 | 20 | @property 21 | def sent_failed(self) -> int: 22 | """Return sentFailed.""" 23 | return self.data.get("sentFailed") 24 | 25 | @property 26 | def retries(self) -> int: 27 | """Return retries.""" 28 | return self.data.get("retries") 29 | 30 | @property 31 | def received_packets(self) -> int: 32 | """Return receivedPackets.""" 33 | return self.data.get("receivedPackets") 34 | 35 | @property 36 | def received_dup_packets(self) -> int: 37 | """Return receivedDupPackets.""" 38 | return self.data.get("receivedDupPackets") 39 | 40 | @property 41 | def received_unsolicited(self) -> int: 42 | """Return receivedUnsolicited.""" 43 | return self.data.get("receivedUnsolicited") 44 | 45 | @property 46 | def last_sent_time_stamp(self) -> int: 47 | """Return lastSentTimeStamp.""" 48 | return self.data.get("lastSentTimeStamp") 49 | 50 | @property 51 | def last_received_time_stamp(self) -> int: 52 | """Return lastReceivedTimeStamp.""" 53 | return self.data.get("lastReceivedTimeStamp") 54 | 55 | @property 56 | def last_request_rtt(self) -> int: 57 | """Return lastRequestRTT.""" 58 | return self.data.get("lastRequestRTT") 59 | 60 | @property 61 | def average_request_rtt(self) -> int: 62 | """Return averageRequestRTT.""" 63 | return self.data.get("averageRequestRTT") 64 | 65 | @property 66 | def last_response_rtt(self) -> int: 67 | """Return lastResponseRTT.""" 68 | return self.data.get("lastResponseRTT") 69 | 70 | @property 71 | def average_response_rtt(self) -> int: 72 | """Return averageResponseRTT.""" 73 | return self.data.get("averageResponseRTT") 74 | 75 | @property 76 | def quality(self) -> int: 77 | """Return quality.""" 78 | return self.data.get("quality") 79 | 80 | @property 81 | def extended_tx_supported(self) -> bool: 82 | """Return extendedTXSupported.""" 83 | return self.data.get("extendedTXSupported") 84 | 85 | @property 86 | def tx_time(self) -> int: 87 | """Return txTime.""" 88 | return self.data.get("txTime") 89 | 90 | @property 91 | def hops(self) -> int: 92 | """Return hops.""" 93 | return self.data.get("hops") 94 | 95 | @property 96 | def rssi1(self) -> str: 97 | """Return rssi_1.""" 98 | return self.data.get("rssi_1") 99 | 100 | @property 101 | def rssi2(self) -> str: 102 | """Return rssi_2.""" 103 | return self.data.get("rssi_2") 104 | 105 | @property 106 | def rssi3(self) -> str: 107 | """Return rssi_3.""" 108 | return self.data.get("rssi_3") 109 | 110 | @property 111 | def rssi4(self) -> str: 112 | """Return rssi_4.""" 113 | return self.data.get("rssi_4") 114 | 115 | @property 116 | def rssi5(self) -> str: 117 | """Return rssi_5.""" 118 | return self.data.get("rssi_5") 119 | 120 | @property 121 | def route1(self) -> int: 122 | """Return route_1.""" 123 | return self.data.get("route_1") 124 | 125 | @property 126 | def route2(self) -> int: 127 | """Return route_2.""" 128 | return self.data.get("route_2") 129 | 130 | @property 131 | def route3(self) -> int: 132 | """Return route_3.""" 133 | return self.data.get("route_3") 134 | 135 | @property 136 | def route4(self) -> int: 137 | """Return route_4.""" 138 | return self.data.get("route_4") 139 | 140 | @property 141 | def ack_channel(self) -> int: 142 | """Return ackChannel.""" 143 | return self.data.get("ackChannel") 144 | 145 | @property 146 | def last_tx_channel(self) -> int: 147 | """Return lastTXChannel.""" 148 | return self.data.get("lastTXChannel") 149 | 150 | @property 151 | def route_scheme(self) -> str: 152 | """Return routeScheme.""" 153 | return self.data.get("routeScheme") 154 | 155 | @property 156 | def route_used(self) -> str: 157 | """Return routeUsed.""" 158 | return self.data.get("routeUsed") 159 | 160 | @property 161 | def route_speed(self) -> str: 162 | """Return routeSpeed.""" 163 | return self.data.get("routeSpeed") 164 | 165 | @property 166 | def route_tries(self) -> int: 167 | """Return routeTries.""" 168 | return self.data.get("routeTries") 169 | 170 | @property 171 | def last_failed_link_from(self) -> int: 172 | """Return lastFailedLinkFrom.""" 173 | return self.data.get("lastFailedLinkFrom") 174 | 175 | @property 176 | def last_failed_link_to(self) -> int: 177 | """Return lastFailedLinkTo.""" 178 | return self.data.get("lastFailedLinkTo") 179 | 180 | @property 181 | def routes(self) -> List[int]: 182 | """Return routes.""" 183 | return [ 184 | self.data.get("route_1"), 185 | self.data.get("route_2"), 186 | self.data.get("route_3"), 187 | self.data.get("route_4"), 188 | ] 189 | 190 | @property 191 | def rssi(self) -> List[int]: 192 | """Return rssi.""" 193 | return [ 194 | self.data.get("rssi_1"), 195 | self.data.get("rssi_2"), 196 | self.data.get("rssi_3"), 197 | self.data.get("rssi_4"), 198 | self.data.get("rssi_5"), 199 | ] 200 | -------------------------------------------------------------------------------- /openzwavemqtt/models/value.py: -------------------------------------------------------------------------------- 1 | """Model for the Value.""" 2 | from typing import Any, cast, Optional 3 | 4 | from ..const import ( 5 | EVENT_VALUE_ADDED, 6 | EVENT_VALUE_CHANGED, 7 | EVENT_VALUE_REMOVED, 8 | CommandClass, 9 | ValueGenre, 10 | ValueType, 11 | ) 12 | from .node_child_base import OZWNodeChildBase 13 | 14 | 15 | class OZWValue(OZWNodeChildBase): 16 | """Representation of an OpenZWave Value object.""" 17 | 18 | EVENT_ADDED = EVENT_VALUE_ADDED 19 | EVENT_CHANGED = EVENT_VALUE_CHANGED 20 | EVENT_REMOVED = EVENT_VALUE_REMOVED 21 | 22 | @property 23 | def label(self) -> str: 24 | """Return Label.""" 25 | return self.data.get("Label") 26 | 27 | @property 28 | def value(self) -> Any: 29 | """Return Value.""" 30 | return self.data.get("Value") 31 | 32 | @property 33 | def units(self) -> str: 34 | """Return Units.""" 35 | return self.data.get("Units") 36 | 37 | @property 38 | def min(self) -> Optional[int]: 39 | """Return Min.""" 40 | return self.data.get("Min") 41 | 42 | @property 43 | def max(self) -> Optional[int]: 44 | """Return Max.""" 45 | return self.data.get("Max") 46 | 47 | @property 48 | def type(self) -> ValueType: 49 | """Return Type.""" 50 | try: 51 | return ValueType(self.data.get("Type")) 52 | except ValueError: 53 | return ValueType.UNKNOWN 54 | 55 | @property 56 | def instance(self) -> int: 57 | """Return Instance.""" 58 | return self.data.get("Instance") 59 | 60 | @property 61 | def command_class(self) -> CommandClass: 62 | """Return CommandClass this value belongs to.""" 63 | return self.parent.command_class_id 64 | 65 | @property 66 | def index(self) -> int: 67 | """Return Index.""" 68 | return self.data["Index"] 69 | 70 | @property 71 | def genre(self) -> ValueGenre: 72 | """Return Genre.""" 73 | try: 74 | return ValueGenre(self.data.get("Genre")) 75 | except ValueError: 76 | return ValueGenre.UNKNOWN 77 | 78 | @property 79 | def help(self) -> str: 80 | """Return Help.""" 81 | return self.data.get("Help") 82 | 83 | @property 84 | def value_id_key(self) -> int: 85 | """Return ValueIDKey.""" 86 | return self.data.get("ValueIDKey") 87 | 88 | @property 89 | def read_only(self) -> bool: 90 | """Return ReadOnly.""" 91 | return self.data.get("ReadOnly") 92 | 93 | @property 94 | def write_only(self) -> bool: 95 | """Return WriteOnly.""" 96 | return self.data.get("WriteOnly") 97 | 98 | @property 99 | def value_set(self) -> bool: 100 | """Return ValueSet.""" 101 | return self.data.get("ValueSet") 102 | 103 | @property 104 | def value_polled(self) -> bool: 105 | """Return ValuePolled.""" 106 | return self.data.get("ValuePolled") 107 | 108 | @property 109 | def change_verified(self) -> bool: 110 | """Return ChangeVerified.""" 111 | return self.data.get("ChangeVerified") 112 | 113 | @property 114 | def event(self) -> str: 115 | """Return Event.""" 116 | return self.data.get("Event") 117 | 118 | @property 119 | def time_stamp(self) -> int: 120 | """Return TimeStamp.""" 121 | return self.data.get("TimeStamp") 122 | 123 | @property 124 | def ozw_instance(self): 125 | """Return OZWInstance this value belongs to.""" 126 | from .instance import OZWInstance 127 | 128 | parent = self.parent 129 | while parent is not None and not isinstance(parent, OZWInstance): 130 | parent = parent.parent 131 | 132 | if isinstance(parent, OZWInstance): 133 | return cast(OZWInstance, parent) 134 | 135 | def send_value(self, new_value): 136 | """Send an updated value to MQTT.""" 137 | instance_id = self.ozw_instance.id 138 | full_topic = f"{self.options.topic_prefix}{instance_id}/command/setvalue/" 139 | payload = {"ValueIDKey": self.value_id_key, "Value": new_value} 140 | self.options.send_message(full_topic, payload) 141 | -------------------------------------------------------------------------------- /openzwavemqtt/options.py: -------------------------------------------------------------------------------- 1 | """Options for the OZW MQTT Connection.""" 2 | from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union 3 | 4 | if TYPE_CHECKING: 5 | from .base import ZWaveBase # noqa: F401 6 | 7 | 8 | class OZWOptions: 9 | """OZW Options class.""" 10 | 11 | def __init__( 12 | self, 13 | send_message: Callable[[str, Union[str, dict]], None], 14 | topic_prefix: str = "OpenZWave/", 15 | instance_id: Optional[int] = None, 16 | ): 17 | """Initialize class.""" 18 | self.send_message = send_message 19 | self.topic_prefix = topic_prefix 20 | self.listeners: Dict[str, List[Callable[[Union[dict, "ZWaveBase"]], None]]] = {} 21 | self.instance_id = instance_id 22 | 23 | # Make sure topic prefix ends in a slash 24 | assert topic_prefix[-1] == "/" 25 | 26 | def listen( 27 | self, event: str, listener: Callable[[Union[dict, "ZWaveBase"]], None] 28 | ) -> Callable[[], None]: 29 | """Attach listener for events.""" 30 | self.listeners.setdefault(event, []).append(listener) 31 | return lambda: self.listeners[event].remove(listener) 32 | 33 | def notify(self, event: str, data: Union[dict, "ZWaveBase"]) -> None: 34 | """Notify listeners of a new event.""" 35 | for listener in self.listeners.get(event, []): 36 | listener(data) 37 | -------------------------------------------------------------------------------- /openzwavemqtt/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility module for OpenZWave.""" 2 | -------------------------------------------------------------------------------- /openzwavemqtt/util/lock.py: -------------------------------------------------------------------------------- 1 | """Utility functions for OpenZWave locks.""" 2 | from typing import Dict, List, Optional, Union 3 | 4 | from ..const import ( 5 | ATTR_CODE_SLOT, 6 | ATTR_IN_USE, 7 | ATTR_NAME, 8 | ATTR_USERCODE, 9 | CommandClass, 10 | ValueGenre, 11 | ValueIndex, 12 | ) 13 | from ..exceptions import InvalidValueError, NotFoundError, NotSupportedError 14 | from ..models.node import OZWNode 15 | 16 | 17 | def get_code_slots(node: OZWNode) -> List[Dict[str, Union[int, bool, str]]]: 18 | """Get all code slots on the lock and whether or not they are used.""" 19 | command_class = node.get_command_class(CommandClass.USER_CODE) 20 | 21 | if not command_class: 22 | raise NotSupportedError("Node doesn't have code slots") 23 | 24 | return [ 25 | { 26 | ATTR_CODE_SLOT: value.index, 27 | ATTR_NAME: value.label, 28 | ATTR_IN_USE: value.value_set, 29 | } 30 | for value in command_class.values() # type: ignore 31 | if value.genre == ValueGenre.USER 32 | ] 33 | 34 | 35 | def set_usercode(node: OZWNode, code_slot: int, usercode: str) -> None: 36 | """Set the usercode to index X on the lock.""" 37 | value = node.get_value(CommandClass.USER_CODE, code_slot) 38 | 39 | if not value: 40 | raise NotFoundError(f"Code slot {code_slot} not found") 41 | 42 | if len(str(usercode)) < 4: 43 | raise InvalidValueError("User code must be at least 4 digits") 44 | 45 | value.send_value(usercode) # type: ignore 46 | 47 | 48 | def clear_usercode(node: OZWNode, code_slot: int) -> None: 49 | """Clear usercode in slot X on the lock.""" 50 | value = node.get_value(CommandClass.USER_CODE, ValueIndex.CLEAR_USER_CODE) 51 | 52 | if not value: 53 | raise NotSupportedError("Node is not capable of clearing user codes") 54 | 55 | value.send_value(code_slot) # type: ignore 56 | # Sending twice because the first time it doesn't take 57 | value.send_value(code_slot) # type: ignore 58 | 59 | 60 | def get_usercodes(node: OZWNode) -> List[Dict[str, Optional[Union[int, bool, str]]]]: 61 | """Get all code slots and usercodes on the lock.""" 62 | command_class = node.get_command_class(CommandClass.USER_CODE) 63 | 64 | if not command_class: 65 | raise NotSupportedError("Node doesn't have code slots") 66 | 67 | return [ 68 | { 69 | ATTR_CODE_SLOT: value.index, 70 | ATTR_NAME: value.label, 71 | ATTR_IN_USE: value.value_set, 72 | ATTR_USERCODE: str(value.value) if value.value_set else None, 73 | } 74 | for value in command_class.values() # type: ignore 75 | if value.genre == ValueGenre.USER 76 | ] 77 | 78 | 79 | def get_usercode(node: OZWNode, code_slot: int) -> Optional[str]: 80 | """Get usercode from slot X on the lock.""" 81 | value = node.get_value(CommandClass.USER_CODE, code_slot) 82 | 83 | if not value: 84 | raise NotFoundError(f"Code slot {code_slot} not found") 85 | 86 | return str(value.value) if value.value_set else None 87 | -------------------------------------------------------------------------------- /openzwavemqtt/util/mqtt_client.py: -------------------------------------------------------------------------------- 1 | """Provide an MQTT client for connecting to the ozwdaemon via MQTT broker.""" 2 | import asyncio 3 | import json 4 | import logging 5 | import uuid 6 | from contextlib import AsyncExitStack 7 | from typing import Any, Callable, Optional, Set, Union 8 | 9 | from asyncio_mqtt import Client as AsyncioClient, MqttError 10 | import paho.mqtt.client as mqtt 11 | from paho.mqtt.properties import Properties 12 | from paho.mqtt.subscribeoptions import SubscribeOptions 13 | 14 | from openzwavemqtt import OZWManager, OZWOptions 15 | from openzwavemqtt.const import LOGGER 16 | 17 | PAHO_MQTT_LOGGER = logging.getLogger("paho.mqtt.client") 18 | TOPIC_OPENZWAVE = "OpenZWave" 19 | 20 | 21 | class MQTTClient: 22 | """Represent an MQTT client.""" 23 | 24 | def __init__( 25 | self, 26 | host: str, 27 | port: int = 1883, 28 | **client_options: Any, 29 | ) -> None: 30 | """Set up client.""" 31 | self.host = host 32 | self.port = port 33 | if "client_id" not in client_options: 34 | client_options["client_id"] = mqtt.base62(uuid.uuid4().int, padding=22) 35 | if "logger" not in client_options: 36 | client_options["logger"] = PAHO_MQTT_LOGGER 37 | client_options["clean_session"] = True 38 | self.client_options = client_options 39 | self.asyncio_client: AsyncioClient = None 40 | self.create_client() 41 | self.reconnect_interval = 1 42 | self.publish_queue: asyncio.Queue = asyncio.Queue() 43 | 44 | def create_client(self) -> None: 45 | """Create the asyncio client.""" 46 | self.asyncio_client = AsyncioClient( 47 | self.host, 48 | self.port, 49 | **self.client_options, 50 | ) 51 | 52 | async def connect(self, *, timeout: float = 10.0) -> None: 53 | """Connect to the broker. 54 | 55 | Can raise asyncio_mqtt.MqttError. 56 | """ 57 | await self.asyncio_client.connect(timeout=timeout) 58 | 59 | async def disconnect(self, *, timeout: float = 10.0) -> None: 60 | """Disconnect from the broker. 61 | 62 | Can raise asyncio_mqtt.MqttError. 63 | """ 64 | await self.asyncio_client.disconnect(timeout=timeout) 65 | 66 | async def publish( # pylint:disable=too-many-arguments 67 | self, 68 | topic: str, 69 | payload: Optional[str] = None, 70 | qos: int = 0, 71 | retain: bool = False, 72 | properties: Optional[Properties] = None, 73 | timeout: float = 10, 74 | ) -> None: 75 | """Publish to topic. 76 | 77 | Can raise asyncio_mqtt.MqttError. 78 | """ 79 | params: dict = {"qos": qos, "retain": retain, "timeout": timeout} 80 | if payload: 81 | params["payload"] = payload 82 | if properties: 83 | params["properties"] = properties 84 | 85 | LOGGER.debug("Sending message topic: %s, payload: %s", topic, payload) 86 | await self.asyncio_client.publish(topic, **params) 87 | 88 | async def subscribe( # pylint:disable=too-many-arguments 89 | self, 90 | topic: str, 91 | qos: int = 0, 92 | options: Optional[SubscribeOptions] = None, 93 | properties: Optional[Properties] = None, 94 | timeout: float = 10.0, 95 | ) -> None: 96 | """Subscribe to topic. 97 | 98 | Can raise asyncio_mqtt.MqttError. 99 | """ 100 | params: dict = {"qos": qos, "timeout": timeout} 101 | if options: 102 | params["options"] = options 103 | if properties: 104 | params["properties"] = properties 105 | 106 | await self.asyncio_client.subscribe(topic, **params) 107 | 108 | async def unsubscribe( 109 | self, topic: str, properties: Optional[Properties] = None, timeout: float = 10.0 110 | ) -> None: 111 | """Unsubscribe from topic. 112 | 113 | Can raise asyncio_mqtt.MqttError. 114 | """ 115 | params: dict = {"timeout": timeout} 116 | if properties: 117 | params["properties"] = properties 118 | 119 | await self.asyncio_client.unsubscribe(topic, **params) 120 | 121 | def send_message(self, topic: str, payload: Union[str, dict]) -> None: 122 | """Send a message from the manager options.""" 123 | to_publish = (topic, json.dumps(payload)) 124 | self.publish_queue.put_nowait(to_publish) 125 | 126 | async def _handle_publish(self) -> None: 127 | """Publish messages as they are put on the queue.""" 128 | while True: 129 | to_publish: tuple = await self.publish_queue.get() 130 | await self.publish(*to_publish) 131 | self.publish_queue.task_done() 132 | 133 | async def start_client(self, manager: OZWManager) -> None: 134 | """Start the client with the manager.""" 135 | # Reconnect automatically until the client is stopped. 136 | while True: 137 | try: 138 | await self._subscribe_manager(manager) 139 | except MqttError as err: 140 | self.reconnect_interval = min(self.reconnect_interval * 2, 900) 141 | LOGGER.error( 142 | "MQTT error: %s. Reconnecting in %s seconds", 143 | err, 144 | self.reconnect_interval, 145 | ) 146 | await asyncio.sleep(self.reconnect_interval) 147 | self.create_client() # reset connect/reconnect futures 148 | 149 | async def _subscribe_manager(self, manager: OZWManager) -> None: 150 | """Connect and subscribe to manager topics.""" 151 | async with AsyncExitStack() as stack: 152 | # Keep track of the asyncio tasks that we create, so that 153 | # we can cancel them on exit. 154 | tasks: Set[asyncio.Task] = set() 155 | 156 | # Connect to the MQTT broker. 157 | await stack.enter_async_context(self.asyncio_client) 158 | # Reset the reconnect interval after successful connection. 159 | self.reconnect_interval = 1 160 | 161 | publish_task = asyncio.create_task(self._handle_publish()) 162 | tasks.add(publish_task) 163 | 164 | # Messages that doesn't match a filter will get logged and handled here. 165 | messages = await stack.enter_async_context( 166 | self.asyncio_client.unfiltered_messages() 167 | ) 168 | 169 | messages_task = asyncio.create_task( 170 | handle_messages(messages, manager.receive_message) 171 | ) 172 | tasks.add(messages_task) 173 | 174 | # Note that we subscribe *after* starting the message loggers. 175 | # Otherwise, we may miss retained messages. 176 | topic = f"{manager.options.topic_prefix}#" 177 | await self.subscribe(topic) 178 | 179 | # Wait for everything to complete (or fail due to, e.g., network errors). 180 | await asyncio.gather(*tasks) 181 | 182 | 183 | async def handle_messages(messages: Any, callback: Callable[[str, str], None]) -> None: 184 | """Handle messages with callback.""" 185 | async for message in messages: 186 | # Note that we assume that the message payload is an 187 | # UTF8-encoded string (hence the `bytes.decode` call). 188 | payload = message.payload.decode() 189 | LOGGER.debug("Received message topic: %s, payload: %s", message.topic, payload) 190 | callback(message.topic, payload) 191 | 192 | 193 | async def run_client() -> None: 194 | """Run client.""" 195 | client = MQTTClient("localhost") 196 | options = OZWOptions( 197 | send_message=client.send_message, topic_prefix=f"{TOPIC_OPENZWAVE}/" 198 | ) 199 | manager = OZWManager(options) 200 | 201 | await client.start_client(manager) 202 | 203 | 204 | def main() -> None: 205 | """Run main.""" 206 | fmt = "%(asctime)s %(levelname)s (%(threadName)s) [%(name)s] %(message)s" 207 | logging.basicConfig(format=fmt, level=logging.DEBUG) 208 | LOGGER.info("Starting client.") 209 | 210 | try: 211 | asyncio.run(run_client()) 212 | except KeyboardInterrupt: 213 | LOGGER.info("Exiting client.") 214 | 215 | 216 | if __name__ == "__main__": 217 | main() 218 | -------------------------------------------------------------------------------- /openzwavemqtt/util/node.py: -------------------------------------------------------------------------------- 1 | """Utility functions for OpenZWave nodes.""" 2 | from typing import cast, Dict, List, Union 3 | 4 | from ..const import ( 5 | ATTR_LABEL, 6 | ATTR_MAX, 7 | ATTR_MIN, 8 | ATTR_OPTIONS, 9 | ATTR_PARAMETER, 10 | ATTR_POSITION, 11 | ATTR_TYPE, 12 | ATTR_VALUE, 13 | ATTR_HELP, 14 | CommandClass, 15 | ValueGenre, 16 | ValueType, 17 | ) 18 | from ..exceptions import InvalidValueError, NotFoundError, WrongTypeError 19 | from ..manager import OZWManager 20 | from ..models.node import OZWNode 21 | from ..models.value import OZWValue 22 | 23 | 24 | def get_node_from_manager( 25 | manager: OZWManager, instance_id: int, node_id: int 26 | ) -> OZWNode: 27 | """Get OZWNode from OZWManager.""" 28 | instance = manager.get_instance(instance_id) # type: ignore 29 | if not instance: 30 | raise NotFoundError(f"OZW Instance {instance_id} not found") 31 | 32 | node = cast(OZWNode, instance.get_node(node_id)) 33 | if not node: 34 | raise NotFoundError(f"OZW Node {node_id} not found") 35 | 36 | return node 37 | 38 | 39 | def _set_bool_config_parameter(value: OZWValue, new_value: Union[bool, str]) -> bool: 40 | """Set a ValueType.BOOL config parameter.""" 41 | if isinstance(new_value, bool): 42 | value.send_value(new_value) # type: ignore 43 | return new_value 44 | 45 | if isinstance(new_value, str): 46 | new_value = new_value.lower() 47 | if new_value in ("true", "false"): 48 | payload = new_value == "true" 49 | value.send_value(payload) # type: ignore 50 | return payload 51 | 52 | raise WrongTypeError("Configuration parameter value must be true or false") 53 | 54 | raise WrongTypeError( 55 | ( 56 | f"Configuration parameter type {value.type} does not match " 57 | f"the value type {type(new_value)}" 58 | ) 59 | ) 60 | 61 | 62 | def _set_list_config_parameter(value: OZWValue, new_value: Union[int, str]) -> int: 63 | """Set a ValueType.LIST config parameter.""" 64 | try: 65 | new_value = int(new_value) 66 | except (TypeError, ValueError): 67 | pass 68 | 69 | if isinstance(new_value, (int, str)): 70 | for option in value.value["List"]: 71 | if new_value not in (option["Label"], option["Value"]): 72 | continue 73 | try: 74 | payload = int(option["Value"]) 75 | except ValueError: 76 | payload = option["Value"] 77 | value.send_value(payload) # type: ignore 78 | return payload 79 | 80 | raise NotFoundError(f"New value is not a valid option ({value.value['List']})") 81 | 82 | raise WrongTypeError( 83 | ( 84 | f"Configuration parameter type {value.type} does not match " 85 | f"the value type {type(new_value)}" 86 | ) 87 | ) 88 | 89 | 90 | def _set_bitset_config_parameter( 91 | value: OZWValue, new_value: List[Dict[str, Union[int, str, bool]]] 92 | ) -> List[Dict[str, Union[int, str, bool]]]: 93 | """Set a ValueType.BITSET config parameter.""" 94 | # Check that exactly one of ATTR_POSITION and ATTR_LABEL is provided, and that 95 | # ATTR_POSITION is an int and ATTR_LABEL is a str. Check that ATTR_VALUE is 96 | # provided and is bool. 97 | if not isinstance(new_value, list) or any( 98 | (ATTR_POSITION in bit and ATTR_LABEL in bit) 99 | or (ATTR_POSITION not in bit and ATTR_LABEL not in bit) 100 | or (ATTR_POSITION in bit and not isinstance(bit[ATTR_POSITION], int)) 101 | or (ATTR_LABEL in bit and not isinstance(bit[ATTR_LABEL], str)) 102 | or ATTR_VALUE not in bit 103 | or not isinstance(bit[ATTR_VALUE], bool) 104 | for bit in new_value 105 | ): 106 | raise WrongTypeError( 107 | ( 108 | "Configuration parameter value must be in the form of a " 109 | f"list of dicts with the {ATTR_VALUE} key and either the " 110 | f"{ATTR_POSITION} or {ATTR_LABEL} key defined. {ATTR_VALUE} " 111 | f"should be a bool, {ATTR_POSITION} should be an int, and " 112 | f"{ATTR_LABEL} should be a string." 113 | ) 114 | ) 115 | 116 | # Check that all keys in dictionary are a valid position or label 117 | if any( 118 | not any( 119 | bool( 120 | ATTR_POSITION in new_bit 121 | and new_bit[ATTR_POSITION] == int(bit["Position"]) 122 | ) 123 | or (ATTR_LABEL in new_bit and new_bit[ATTR_LABEL] == bit["Label"]) 124 | for bit in value.value 125 | ) 126 | for new_bit in new_value 127 | ): 128 | raise NotFoundError( 129 | "Configuration parameter value has an invalid bit position or label" 130 | ) 131 | 132 | value.send_value(new_value) # type: ignore 133 | return new_value 134 | 135 | 136 | def _set_int_config_parameter(value: OZWValue, new_value: int) -> int: 137 | """Set a ValueType.INT config parameter.""" 138 | try: 139 | new_value = int(new_value) 140 | except ValueError as err: 141 | raise WrongTypeError( 142 | ( 143 | f"Configuration parameter type {value.type} does not match " 144 | f"the value type {type(new_value)}" 145 | ) 146 | ) from err 147 | 148 | if (value.max is not None and new_value > value.max) or ( 149 | value.min is not None and new_value < value.min 150 | ): 151 | raise InvalidValueError( 152 | f"Value {new_value} out of range of parameter (Range: {value.min}-{value.max})" 153 | ) 154 | value.send_value(new_value) # type: ignore 155 | return new_value 156 | 157 | 158 | def set_config_parameter( 159 | node: OZWNode, 160 | parameter: int, 161 | new_value: Union[int, str, bool, List[Dict[str, Union[int, str, bool]]]], 162 | ) -> Union[int, str, bool, List[Dict[str, Union[int, str, bool]]]]: 163 | """Set config parameter to a node.""" 164 | value = node.get_value(CommandClass.CONFIGURATION, parameter) 165 | if not value: 166 | raise NotFoundError( 167 | f"Configuration parameter {parameter} for OZW Node Instance not found" 168 | ) 169 | 170 | # Bool can be passed in as string or bool 171 | if value.type == ValueType.BOOL: 172 | return _set_bool_config_parameter(value, new_value) # type: ignore 173 | 174 | # List value can be passed in as string or int 175 | if value.type == ValueType.LIST: 176 | return _set_list_config_parameter(value, new_value) # type: ignore 177 | 178 | # Bitset value is passed in as dict 179 | if value.type == ValueType.BITSET: 180 | return _set_bitset_config_parameter(value, new_value) # type: ignore 181 | 182 | # Int, Byte, Short are always passed as int, Decimal should be float 183 | if value.type in (ValueType.INT, ValueType.BYTE, ValueType.SHORT): 184 | return _set_int_config_parameter(value, new_value) # type: ignore 185 | 186 | # This will catch BUTTON, STRING, and UNKNOWN ValueTypes 187 | raise WrongTypeError( 188 | f"Value type of {value.type} for parameter {parameter} not supported" 189 | ) 190 | 191 | 192 | def get_config_parameters( 193 | node: OZWNode, 194 | ) -> List[Dict[str, Union[int, str, bool, List[Dict[str, Union[int, str, bool]]]]]]: 195 | """Get config parameter from a node.""" 196 | values = [] 197 | 198 | for value in node.values(): 199 | value_to_return = {} 200 | # BUTTON types aren't supported yet, and STRING, RAW, SCHEDULE, 201 | # and UNKNOWN are not valid config parameter types 202 | if ( 203 | value.read_only 204 | or value.genre != ValueGenre.CONFIG 205 | or value.type 206 | in ( 207 | ValueType.BUTTON, 208 | ValueType.STRING, 209 | ValueType.RAW, 210 | ValueType.SCHEDULE, 211 | ValueType.UNKNOWN, 212 | ) 213 | ): 214 | continue 215 | 216 | value_to_return = { 217 | ATTR_LABEL: value.label, 218 | ATTR_TYPE: value.type.value, 219 | ATTR_PARAMETER: value.index, 220 | ATTR_HELP: value.help, 221 | } 222 | 223 | if value.type == ValueType.BOOL: 224 | value_to_return[ATTR_VALUE] = value.value 225 | 226 | elif value.type == ValueType.LIST: 227 | value_to_return[ATTR_VALUE] = value.value["Selected"] 228 | value_to_return[ATTR_OPTIONS] = value.value["List"] 229 | 230 | elif value.type == ValueType.BITSET: 231 | value_to_return[ATTR_VALUE] = [ 232 | { 233 | ATTR_LABEL: bit["Label"], 234 | ATTR_POSITION: int(bit["Position"]), 235 | ATTR_VALUE: bool(bit["Value"]), 236 | } 237 | for bit in value.value 238 | ] 239 | 240 | elif value.type in (ValueType.INT, ValueType.BYTE, ValueType.SHORT): 241 | value_to_return[ATTR_VALUE] = int(value.value) 242 | value_to_return[ATTR_MAX] = value.max 243 | value_to_return[ATTR_MIN] = value.min 244 | 245 | values.append(value_to_return) 246 | 247 | return values 248 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | persistent=no 7 | 8 | [BASIC] 9 | good-names=id,i,j,k,ex,Run,_,fp 10 | 11 | [MESSAGES CONTROL] 12 | # Reasons disabled: 13 | # locally-disabled - it spams too much 14 | # too-many-* - are not enforced for the sake of readability 15 | # too-few-* - same as too-many-* 16 | # import-outside-toplevel - TODO 17 | disable= 18 | bad-continuation, 19 | duplicate-code, 20 | fixme, 21 | import-outside-toplevel, 22 | locally-disabled, 23 | too-few-public-methods, 24 | too-many-public-methods, 25 | 26 | [REPORTS] 27 | score=no 28 | 29 | [TYPECHECK] 30 | # For attrs 31 | ignored-classes=_CountingAttr 32 | 33 | [FORMAT] 34 | expected-line-ending-format=LF 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asyncio-mqtt==0.12.1 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | -r requirements_lint.txt 3 | -r requirements_script.txt 4 | -r requirements_test.txt 5 | tox==3.24.5 6 | 7 | -e . 8 | -------------------------------------------------------------------------------- /requirements_lint.txt: -------------------------------------------------------------------------------- 1 | black==22.1.0 2 | flake8==4.0.1 3 | mypy==0.941 4 | pydocstyle==6.1.1 5 | pylint==2.12.2 6 | -------------------------------------------------------------------------------- /requirements_script.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/beerfactory/hbmqtt.git@07c4c70f061003f208ad7e510b6e8fce4d7b3a6c#egg=hbmqtt 2 | paho-mqtt==1.6.1 3 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.1 2 | pytest-cov==3.0.0 3 | pytest-timeout==2.1.0 4 | -------------------------------------------------------------------------------- /script/dump_mqtt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Dump mqtt output.""" 3 | import argparse 4 | import logging 5 | from threading import Timer 6 | from typing import Any 7 | 8 | try: 9 | import paho.mqtt.client as mqtt 10 | except ImportError: 11 | mqtt = None 12 | 13 | 14 | def get_args() -> argparse.Namespace: 15 | """Get arguments.""" 16 | parser = argparse.ArgumentParser(description="Dump Instance") 17 | parser.add_argument( 18 | "--host", type=str, default="localhost", help="Host of the MQTT server." 19 | ) 20 | parser.add_argument( 21 | "--port", type=int, default=1883, help="Port that the MQTT server runs on." 22 | ) 23 | return parser.parse_args() 24 | 25 | 26 | def main() -> None: 27 | """Run main entrypoint.""" 28 | args = get_args() 29 | mqttc = mqtt.Client() 30 | mqttc.enable_logger(logging.getLogger("dump_instance")) 31 | 32 | def print_message( 33 | _client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage 34 | ) -> None: 35 | """Print message.""" 36 | payload = msg.payload.decode().replace("\n", "") 37 | print(f"{msg.topic},{payload}") 38 | 39 | mqttc.on_message = print_message 40 | 41 | try: 42 | mqttc.connect(args.host, args.port, 60) 43 | except ConnectionRefusedError: 44 | print(f"Failed to connect to {args.host}:{args.port}") 45 | return 46 | mqttc.subscribe("OpenZWave/#", 0) 47 | 48 | # Give it two seconds to receive all messages before we disconnect. 49 | Timer(2, mqttc.disconnect).start() 50 | 51 | mqttc.loop_forever() 52 | 53 | 54 | if __name__ == "__main__": 55 | if mqtt is None: 56 | print("Please install paho-mqtt to use this script.") 57 | print("python3 -m pip install paho-mqtt") 58 | else: 59 | main() 60 | -------------------------------------------------------------------------------- /script/instance_from_file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Create an instance from a file with dumped mqtt messages.""" 3 | import argparse 4 | import re 5 | from typing import Optional, Set, Tuple 6 | 7 | import openzwavemqtt 8 | from openzwavemqtt import base 9 | 10 | 11 | class ExitException(Exception): 12 | """Represent an exit error.""" 13 | 14 | 15 | def get_args() -> argparse.Namespace: 16 | """Get arguments.""" 17 | parser = argparse.ArgumentParser(description="Dump Instance") 18 | parser.add_argument("filename", type=str, help="File with messages to process.") 19 | return parser.parse_args() 20 | 21 | 22 | def load_mgr_from_file(mgr: openzwavemqtt.OZWManager, file_path: str) -> None: 23 | """Load manager from file.""" 24 | with open(file_path, "rt", encoding="utf-8") as fp: 25 | for line in fp: 26 | topic, payload = line.strip().split(",", 1) 27 | try: 28 | mgr.receive_message(topic, payload) 29 | except ValueError as err: 30 | raise ExitException( 31 | f"Unable to process message on topic {topic} as JSON: {payload}" 32 | ) from err 33 | 34 | 35 | def camelcase_to_snake_case(name: str) -> str: 36 | """Convert camelCase to snake_case.""" 37 | # Otherwise ZWave -> _z_wave_ in names. 38 | name = ( 39 | name.replace("ZWave", "Zwave") 40 | .replace("OpenZwave", "Openzwave") 41 | .replace("_", "") 42 | ) 43 | s_1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) 44 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s_1).lower() 45 | 46 | 47 | def verify_integrity( 48 | model: base.ZWaveBase, warned: Optional[Set[Tuple[str, str]]] = None 49 | ) -> None: 50 | """Verify the integrity of the loaded data.""" 51 | if warned is None: 52 | warned = set() 53 | 54 | model_name = type(model).__name__ 55 | obj_name = f"{model_name}/{model.id}" 56 | 57 | if model.pending_messages is not None: 58 | print(f"{obj_name} has pending messages!") 59 | 60 | if model.data is not None and model.data != model.DEFAULT_VALUE: 61 | for key, value in model.data.items(): 62 | prop_name = camelcase_to_snake_case(key) 63 | 64 | if (model_name, key) in warned: 65 | continue 66 | 67 | if hasattr(model, prop_name): 68 | continue 69 | 70 | print( 71 | f""" 72 | 73 | # {model_name} 74 | 75 | @property 76 | def {prop_name}(self) -> {type(value).__name__}: 77 | \"\"\"Return {key}.\"\"\" 78 | return self.data.get("{key}") 79 | 80 | """ 81 | ) 82 | warned.add((model_name, key)) 83 | 84 | # Process children 85 | for model_or_collection in model.collections.values(): 86 | if isinstance(model_or_collection, base.ZWaveBase): 87 | verify_integrity(model_or_collection, warned) 88 | continue 89 | 90 | if isinstance(model_or_collection, base.ItemCollection): 91 | for model_ in model_or_collection.collection.values(): 92 | verify_integrity(model_, warned) 93 | 94 | 95 | def main() -> None: 96 | """Run main entrypoint.""" 97 | args = get_args() 98 | mgr = openzwavemqtt.OZWManager(openzwavemqtt.OZWOptions(print)) 99 | load_mgr_from_file(mgr, args.filename) 100 | verify_integrity(mgr) 101 | 102 | 103 | if __name__ == "__main__": 104 | try: 105 | main() 106 | except ExitException as error: 107 | print(f"Fatal error: {error}") 108 | -------------------------------------------------------------------------------- /script/ozw_emulator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Emulate MQTT Broker with OZW Daemon instance from MQTT dump.""" 3 | 4 | # Experimental ! For debugging purposes 5 | # This will host a MQTT (3.1.1) Broker on localhost:1883 6 | # Content of the provided MQTT dump file will be published on the broker 7 | # Setvalue command will be handled too. 8 | # Connect with Hass + Z-Wave MQTT Addon and/or MQTT Explorer to test userdumps 9 | 10 | # WARNING: Use dev version of HBMQTT pip install git+git://github.com/beerfactory/hbmqtt 11 | # Use manual yaml config for mqtt in Hass, because Config flow seems to default to 3.1 12 | 13 | import argparse 14 | import asyncio 15 | import json 16 | import logging 17 | 18 | from hbmqtt.broker import Broker 19 | from hbmqtt.client import MQTTClient 20 | from hbmqtt.mqtt.constants import QOS_0 21 | 22 | BROKER_CONFIG = { 23 | "listeners": { 24 | "default": {"max-connections": 50000, "type": "tcp"}, 25 | "my-tcp-1": {"bind": "127.0.0.1:1883"}, 26 | }, 27 | "timeout-disconnect-delay": 2, 28 | "auth": {"allow-anonymous": "true", "plugins": ["auth.anonymous"]}, 29 | "topic-check": {"enabled": True, "plugins": ["topic_taboo"]}, 30 | } 31 | 32 | 33 | def get_args() -> argparse.Namespace: 34 | """Get arguments.""" 35 | parser = argparse.ArgumentParser(description="OZW Emulator") 36 | parser.add_argument("filename", type=str, help="File with dump from mqtt.") 37 | return parser.parse_args() 38 | 39 | 40 | # pylint: disable=too-many-nested-blocks 41 | async def process_messages(mqtt_client: MQTTClient, mqtt_data: dict) -> None: 42 | """Keep reading incoming messages from subscribed topics.""" 43 | while True: 44 | msg = await mqtt_client.deliver_message() 45 | if not msg: 46 | continue 47 | topic = msg.topic 48 | data = msg.data.decode() 49 | if not data: 50 | continue 51 | data = json.loads(data) 52 | logging.info("Incoming message on topic %s --> %s", topic, data) 53 | if topic.endswith("command/setvalue/"): 54 | new_value = data["Value"] 55 | for value_topic in mqtt_data: 56 | if not value_topic.endswith(f'/value/{data["ValueIDKey"]}/'): 57 | continue 58 | payload = mqtt_data[value_topic] 59 | if isinstance(payload["Value"], dict): 60 | payload["Value"]["Selected_id"] = new_value 61 | # also update label 62 | for item in payload["Value"]["List"]: 63 | if item["Value"] == new_value: 64 | payload["Value"]["Selected"] = item["Label"] 65 | break 66 | elif isinstance(payload["Value"], (int, float, bool, str)): 67 | payload["Value"] = new_value 68 | else: 69 | logging.warning("setting this value type is not supported!") 70 | return 71 | payload = json.dumps(payload).encode() 72 | await mqtt_client.publish(value_topic, payload, retain=True) 73 | break 74 | 75 | 76 | async def emulate(args: argparse.Namespace) -> None: 77 | """Run broker and client and publish values.""" 78 | 79 | # Parse data into a dict 80 | mqtt_data = {} 81 | with open(args.filename, "rt", encoding="utf-8") as fp: 82 | for line in fp: 83 | item_topic, item_payload = line.strip().split(",", 1) 84 | mqtt_data[item_topic] = json.loads(item_payload) 85 | 86 | # Run Broker 87 | broker = Broker(BROKER_CONFIG) 88 | await broker.start() 89 | 90 | # Run Client 91 | client = MQTTClient() 92 | await client.connect("mqtt://localhost") 93 | 94 | # Publish all topics from the provided dump file 95 | for topic, data in mqtt_data.items(): 96 | # prettify json messages and clear base64 image data 97 | if "MetaData" in data and "ProductPicBase64" in data["MetaData"]: 98 | data["MetaData"]["ProductPicBase64"] = "" 99 | payload = json.dumps(data, indent=4).encode() 100 | await client.publish(topic, payload, retain=True) 101 | 102 | # Subscribe to command topic and start listening for commands 103 | await client.subscribe([("OpenZWave/1/command/#", QOS_0)]) 104 | try: 105 | await process_messages(client, mqtt_data) 106 | except asyncio.CancelledError: 107 | await client.disconnect() 108 | broker.shutdown() 109 | 110 | 111 | def main() -> None: 112 | """Run main entrypoint.""" 113 | args = get_args() 114 | formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" 115 | logging.basicConfig(level=logging.INFO, format=formatter) 116 | 117 | # Run 118 | try: 119 | asyncio.run(emulate(args)) 120 | except KeyboardInterrupt: 121 | pass 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | # To work with Black 4 | max-line-length = 88 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501, 12 | W503, 13 | E203, 14 | D202, 15 | W504 16 | 17 | [isort] 18 | multi_line_output = 3 19 | include_trailing_comma = True 20 | force_grid_wrap = 0 21 | use_parentheses = True 22 | line_length = 88 23 | 24 | [mypy] 25 | follow_imports = skip 26 | ignore_missing_imports = true 27 | check_untyped_defs = true 28 | disallow_incomplete_defs = true 29 | disallow_untyped_calls = true 30 | disallow_untyped_defs = true 31 | warn_return_any = true 32 | warn_unreachable = true 33 | warn_unused_ignores = true 34 | warn_incomplete_stub = true 35 | warn_redundant_casts = true 36 | warn_unused_configs = true 37 | 38 | [mypy-test.*,openzwavemqtt.models.command_class,openzwavemqtt.models.instance_statistics,openzwavemqtt.models.instance_status,openzwavemqtt.models.node_association,openzwavemqtt.models.node_child_base,openzwavemqtt.models.node_instance,openzwavemqtt.models.node_statistics,openzwavemqtt.models.node,openzwavemqtt.models.value,] 39 | ignore_errors = true 40 | 41 | [pydocstyle] 42 | add-ignore = D202 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup module for python-openzwave-mqtt.""" 2 | from pathlib import Path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | PROJECT_DIR = Path(__file__).parent.resolve() 7 | README_FILE = PROJECT_DIR / "README.md" 8 | VERSION = "1.4.0" 9 | 10 | 11 | setup( 12 | name="python-openzwave-mqtt", 13 | version=VERSION, 14 | url="https://github.com/cgarwood/python-openzwave-mqtt", 15 | download_url="https://github.com/cgarwood/python-openzwave-mqtt", 16 | author="Charles Garwood", 17 | author_email="cgarwood@gmail.com", 18 | description="Converts MQTT messages from qt-openzwave into Python objects and events", 19 | long_description=README_FILE.read_text(encoding="utf-8"), 20 | long_description_content_type="text/markdown", 21 | packages=find_packages(exclude=["test.*", "test"]), 22 | python_requires=">=3.8", 23 | include_package_data=True, 24 | zip_safe=False, 25 | extras_require={ 26 | "mqtt-client": ["asyncio-mqtt"], 27 | }, 28 | classifiers=[ 29 | "Development Status :: 4 - Beta", 30 | "Intended Audience :: Developers", 31 | "Natural Language :: English", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Topic :: Home Automation", 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide tests.""" 2 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Provide pytest fixtures.""" 2 | import json 3 | 4 | import pytest 5 | 6 | from openzwavemqtt import OZWManager, OZWOptions 7 | 8 | 9 | class MockOptions(OZWOptions): 10 | """Class with test options that keeps track of sent messages.""" 11 | 12 | def __init__(self): 13 | """Initialize mock options instance.""" 14 | self.mock_sent = [] 15 | super().__init__(lambda topic, msg: self.mock_sent.append((topic, msg))) 16 | 17 | 18 | class MockManager(OZWManager): 19 | """Represent a mock manager.""" 20 | 21 | def __init__(self, options=None): 22 | """Initialize mock manager instance.""" 23 | super().__init__(options or MockOptions()) 24 | 25 | def mock_receive_json(self, topic, json_payload): 26 | """Receive JSON payloads directly.""" 27 | self.receive_message(topic, json.dumps(json_payload)) 28 | 29 | 30 | @pytest.fixture(name="options") 31 | def options_fixture(): 32 | """Fixture for a manager.""" 33 | return MockOptions() 34 | 35 | 36 | @pytest.fixture(name="mgr") 37 | def mgr_fixture(options): 38 | """Fixture for a manager.""" 39 | return MockManager(options) 40 | -------------------------------------------------------------------------------- /test/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide tests for the models.""" 2 | -------------------------------------------------------------------------------- /test/models/test_instance.py: -------------------------------------------------------------------------------- 1 | """Tests for instance model.""" 2 | from openzwavemqtt.const import EVENT_INSTANCE_EVENT 3 | 4 | 5 | def test_events(mgr): 6 | """Test firing events.""" 7 | events = [] 8 | mgr.options.listen(EVENT_INSTANCE_EVENT, events.append) 9 | 10 | mgr.mock_receive_json("OpenZWave/1", {}) 11 | mgr.mock_receive_json( 12 | "OpenZWave/1/event/test-instance-event", {"data": "for-event"} 13 | ) 14 | 15 | assert len(events) == 1 16 | assert events[0] == {"event": "test-instance-event", "data": {"data": "for-event"}} 17 | -------------------------------------------------------------------------------- /test/models/test_instance_statistics.py: -------------------------------------------------------------------------------- 1 | """Provide tests for instance statistics.""" 2 | 3 | 4 | def test_statistics(mgr): 5 | """Test statistics.""" 6 | response_json = { 7 | "SOFCnt": 148, 8 | "ACKWaiting": 0, 9 | "readAborts": 0, 10 | "badChecksum": 1, 11 | "readCnt": 147, 12 | "writeCnt": 29, 13 | "CANCnt": 0, 14 | "NAKCnt": 0, 15 | "ACKCnt": 29, 16 | "OOFCnt": 0, 17 | "dropped": 0, 18 | "retries": 0, 19 | "callbacks": 0, 20 | "badroutes": 0, 21 | "noack": 7, 22 | "netbusy": 0, 23 | "notidle": 0, 24 | "txverified": 0, 25 | "nondelivery": 0, 26 | "routedbusy": 0, 27 | "broadcastReadCnt": 46, 28 | "broadcastWriteCnt": 9, 29 | } 30 | 31 | mgr.mock_receive_json("OpenZWave/1", {}) 32 | mgr.mock_receive_json("OpenZWave/1/statistics/", response_json) 33 | statistics = mgr.get_instance(1).get_statistics() 34 | 35 | assert statistics.sof_cnt == 148 36 | assert statistics.read_cnt == 147 37 | assert statistics.write_cnt == 29 38 | assert statistics.netbusy == 0 39 | -------------------------------------------------------------------------------- /test/models/test_node_child_base.py: -------------------------------------------------------------------------------- 1 | """Test Node Child Base model.""" 2 | import pytest 3 | 4 | from openzwavemqtt.models.node import OZWNode 5 | from openzwavemqtt.models.node_child_base import OZWNodeChildBase 6 | 7 | 8 | class MockDescendant(OZWNodeChildBase): 9 | """Mock a descendant.""" 10 | 11 | EVENT_CHANGED = "mock-changed" 12 | 13 | 14 | def test_node(): 15 | """Test a node.""" 16 | node = OZWNode(None, None, "mock-node-id", 1) 17 | 18 | child = MockDescendant(None, node, "mock-child-id", 12) 19 | assert child.node is node 20 | 21 | grandchild = MockDescendant(None, child, "mock-grandchild-id", 123) 22 | assert grandchild.node is node 23 | 24 | assert str(grandchild) == "" 25 | 26 | no_node_parent = MockDescendant(None, None, "", "") 27 | 28 | with pytest.raises(RuntimeError): 29 | # test access node property without valid parent (node) 30 | no_node_parent.node # pylint: disable=pointless-statement 31 | 32 | assert str(no_node_parent) == " (bad!))>" 33 | -------------------------------------------------------------------------------- /test/models/test_node_statistics.py: -------------------------------------------------------------------------------- 1 | """Provide tests for node statistics.""" 2 | 3 | 4 | def test_statistics(mgr): 5 | """Test statistics.""" 6 | response_json = { 7 | "ackChannel": 0, 8 | "averageRequestRTT": 31, 9 | "averageResponseRTT": 47, 10 | "extendedTXSupported": False, 11 | "hops": 0, 12 | "lastFailedLinkFrom": 0, 13 | "lastFailedLinkTo": 0, 14 | "lastRecievedTimeStamp": 0, 15 | "lastRequestRTT": 29, 16 | "lastResponseRTT": 44, 17 | "lastSentTimeStamp": 0, 18 | "lastTXChannel": 0, 19 | "quality": 0, 20 | "receivedDupPackets": 3, 21 | "receivedPackets": 109, 22 | "receivedUnsolicited": 98, 23 | "retries": 0, 24 | "routeScheme": "Idle", 25 | "routeSpeed": "Auto", 26 | "routeTries": 0, 27 | "routeUsed": "", 28 | "route_1": 0, 29 | "route_2": 0, 30 | "route_3": 0, 31 | "route_4": 0, 32 | "rssi_1": "", 33 | "rssi_2": "", 34 | "rssi_3": "", 35 | "rssi_4": "", 36 | "rssi_5": "", 37 | "sendCount": 10, 38 | "sentFailed": 0, 39 | "txTime": 0, 40 | } 41 | 42 | mgr.mock_receive_json("OpenZWave/1", {}) 43 | mgr.mock_receive_json("OpenZWave/1/node/2", {}) 44 | mgr.mock_receive_json("OpenZWave/1/node/2/statistics/", response_json) 45 | statistics = mgr.get_instance(1).get_node(2).get_statistics() 46 | assert statistics.ack_channel == 0 47 | assert statistics.average_response_rtt == 47 48 | assert statistics.average_request_rtt == 31 49 | assert statistics.send_count == 10 50 | assert statistics.parent.id == 2 51 | -------------------------------------------------------------------------------- /test/models/test_value.py: -------------------------------------------------------------------------------- 1 | """Provide tests for the node value.""" 2 | from openzwavemqtt.const import ( 3 | EVENT_VALUE_ADDED, 4 | EVENT_VALUE_CHANGED, 5 | EVENT_VALUE_REMOVED, 6 | ) 7 | 8 | 9 | def test_value_events(mgr): 10 | """Test value events.""" 11 | events = [] 12 | 13 | # Fill parent data. 14 | mgr.mock_receive_json("OpenZWave/1/node/2", {}) 15 | mgr.mock_receive_json("OpenZWave/1/node/2/instance/1", {}) 16 | mgr.mock_receive_json("OpenZWave/1/node/2/instance/1/commandclass/4", {}) 17 | 18 | # Listen for value added 19 | mgr.options.listen(EVENT_VALUE_ADDED, events.append) 20 | mgr.mock_receive_json( 21 | "OpenZWave/1/node/2/instance/1/commandclass/4/value/3", {"Value": "yo"} 22 | ) 23 | assert len(events) == 1 24 | assert events[0].id == 3 25 | assert events[0].value == "yo" 26 | assert events[0].parent.id == 4 27 | 28 | # Test OZWNode.values shortcut 29 | assert list(mgr.get_instance(1).get_node(2).values())[0].id == 3 30 | 31 | # Listen for value changed 32 | mgr.options.listen(EVENT_VALUE_CHANGED, events.append) 33 | mgr.mock_receive_json( 34 | "OpenZWave/1/node/2/instance/1/commandclass/4/value/3", {"Value": "yo2"} 35 | ) 36 | assert len(events) == 2 37 | assert events[0].id == 3 38 | assert events[0].value == "yo2" 39 | 40 | # Show how to use collection helpers 41 | assert ( 42 | list(mgr.get_instance(1).get_node(2).get_instance(1).commandclasses())[0] 43 | .get_value(3) 44 | .value 45 | == "yo2" 46 | ) 47 | 48 | # Listen for value removed 49 | mgr.options.listen(EVENT_VALUE_REMOVED, events.append) 50 | mgr.receive_message("OpenZWave/1/node/2/instance/1/commandclass/4/value/3", "") 51 | assert len(events) == 3 52 | assert events[0].id == 3 53 | -------------------------------------------------------------------------------- /test/test_base.py: -------------------------------------------------------------------------------- 1 | """Provide tests for the model base.""" 2 | from collections import deque 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from openzwavemqtt import base 8 | 9 | 10 | class Level3(base.ZWaveBase): 11 | """Represent a level 3 descendant.""" 12 | 13 | EVENT_ADDED = "level3_added" 14 | EVENT_CHANGED = "level3_change" 15 | EVENT_REMOVED = "level3_removed" 16 | 17 | @property 18 | def hello(self): 19 | """Return hello data.""" 20 | return self.data.get("hello") 21 | 22 | 23 | class Level2(base.ZWaveBase): 24 | """Represent a level 2 descendant.""" 25 | 26 | DIRECT_COLLECTION = "level3" 27 | EVENT_ADDED = "level2_added" 28 | EVENT_CHANGED = "level2_change" 29 | EVENT_REMOVED = "level2_removed" 30 | 31 | PLURAL_NAME = "level_twos" 32 | 33 | def create_collections(self): 34 | """Create collections.""" 35 | return {"level3": base.ItemCollection(Level3)} 36 | 37 | 38 | class Level1(base.ZWaveBase): 39 | """Represent a level 1 descendant.""" 40 | 41 | DIRECT_COLLECTION = "level2" 42 | EVENT_ADDED = "level1_added" 43 | EVENT_CHANGED = "level1_change" 44 | EVENT_REMOVED = "level1_removed" 45 | 46 | def create_collections(self): 47 | """Create collections.""" 48 | return {"level2": base.ItemCollection(Level2)} 49 | 50 | 51 | @pytest.fixture(name="level1") 52 | def level1_fixture(options): 53 | """Return a level 1 descendant.""" 54 | return Level1(options, None, None, None) 55 | 56 | 57 | def test_direct_collection(level1, caplog): 58 | """Test direct collection.""" 59 | level1.process_message(deque(), {"info": 1}) 60 | level1.process_message(deque(["2"]), {"info": 1}) 61 | level1.process_message(deque(["2", "3"]), {"hello": 1}) 62 | assert level1.get_level2(2).get_level3(3).hello == 1 63 | 64 | # Only works on numbers 65 | level1.process_message(deque(["2", "a"]), {"hello": 1}) 66 | assert level1.get_level2(2).get_level3("a") is None 67 | assert "cannot process message" in caplog.text 68 | 69 | 70 | def test_pending_messages(level1, options): 71 | """Test pending messages.""" 72 | events = [] 73 | options.notify = lambda event, data: events.append(event) 74 | 75 | # Only message for level3 has been received, level2 is none 76 | level1.process_message(deque(["2", "3"]), {"hello": 1}) 77 | assert level1.get_level2(2) is None 78 | assert not events 79 | 80 | # Message for level2, level3 received, level1 still None 81 | level1.process_message(deque(["2"]), {"hello": 1}) 82 | assert level1.get_level2(2) is None 83 | assert not events 84 | 85 | # Level 1 receives data, process all child messages. 86 | level1.process_message(deque(), {"info": 1}) 87 | assert level1.get_level2(2).get_level3(3).hello == 1 88 | assert events == ["level2_added", "level3_added"] 89 | 90 | 91 | def test_recursive_remove(level1, options): 92 | """Test recursive remove.""" 93 | events = [] 94 | 95 | level1.process_message(deque(), {"info": 1}) 96 | level1.process_message(deque(["2"]), {"info": 1}) 97 | level1.process_message(deque(["2", "3"]), {"hello": 1}) 98 | 99 | options.notify = lambda event, data: events.append(event) 100 | level1.process_message(deque(["2"]), base.EMPTY_PAYLOAD) 101 | 102 | assert events == ["level3_removed", "level2_removed"] 103 | 104 | 105 | def test_topic(options): 106 | """Test topic property.""" 107 | 108 | class Level4(base.ZWaveBase): 109 | """Represent a level 4 descendant.""" 110 | 111 | EVENT_ADDED = "level4_added" 112 | EVENT_CHANGED = "level4_change" 113 | EVENT_REMOVED = "level4_removed" 114 | 115 | class Level3Statistics(base.ZWaveBase): 116 | """Represent level 3 statistics.""" 117 | 118 | EVENT_CHANGED = "level3statistics_change" 119 | 120 | # Patch in a non-direct collection 121 | with patch.object( 122 | Level3, 123 | "create_collections", 124 | lambda _: { 125 | "level4": base.ItemCollection(Level4), 126 | "statistics": Level3Statistics, 127 | }, 128 | ): 129 | level1 = Level1(options, None, None, None) 130 | level1.process_message(deque(), {"info": 1}) 131 | level1.process_message(deque(["2"]), {"info": 1}) 132 | level1.process_message(deque(["2", "3"]), {"hello": 1}) 133 | level1.process_message(deque(["2", "3", "level4", "4"]), {"hello": 1}) 134 | level1.process_message(deque(["2", "3", "statistics"]), {"hello": 1}) 135 | 136 | assert ( 137 | level1.get_level2(2) # pylint: disable=no-member 138 | .get_level3(3) 139 | .get_level4(4) 140 | .topic 141 | == "OpenZWave/2/3/level4/4" 142 | ) 143 | assert ( 144 | level1.get_level2(2) # pylint: disable=no-member 145 | .get_level3(3) 146 | .get_statistics() 147 | .topic 148 | == "OpenZWave/2/3/statistics" 149 | ) 150 | 151 | 152 | def test_automatic_collections(level1): 153 | """Test automatic collections.""" 154 | level1.process_message(deque(), {"info": 1}) 155 | level1.process_message(deque(["2"]), {"info": 1}) 156 | level1.process_message(deque(["2", "3"]), {"hello": 1}) 157 | 158 | # Test overridden using PLURAL_NAME 159 | assert list(level1.level_twos()) == [level1.get_level2(2)] 160 | 161 | # Test default name 162 | assert list(level1.get_level2(2).level3s()) == [level1.get_level2(2).get_level3(3)] 163 | 164 | 165 | def test_warn_unhandled(level1, caplog): 166 | """Test warn unhandled.""" 167 | level1.process_message(deque(), {"info": 1}) 168 | level1.process_message(deque(["2"]), {"info": 1}) 169 | level1.process_message(deque(["2", "something"]), {"info": 1}) 170 | 171 | assert ( 172 | "Level2 cannot process message OpenZWave/2/something: {'info': 1}" 173 | in caplog.text 174 | ) 175 | 176 | 177 | def test_discarder(level1, options, caplog): 178 | """Test we can discard messages.""" 179 | with patch.object( 180 | Level1, "create_collections", lambda _: {"command": base.DiscardMessages()} 181 | ): 182 | level1 = Level1(options, None, None, None) 183 | level1.process_message(deque(), {"info": 1}) 184 | level1.process_message(deque(["command"]), {"info": 1}) 185 | 186 | assert "cannot process message" not in caplog.text 187 | 188 | 189 | def test_events(level1, options): 190 | """Test we can fire events messages.""" 191 | events = [] 192 | options.notify = lambda event, data: events.append((event, data)) 193 | 194 | with patch.object( 195 | Level1, 196 | "create_collections", 197 | lambda _: { 198 | "event": base.EventMessages( 199 | options, "super_event", lambda topic, msg: topic[0] 200 | ) 201 | }, 202 | ): 203 | level1 = Level1(options, None, None, None) 204 | level1.process_message(deque(), {"info": 1}) 205 | level1.process_message( 206 | deque(["event", "test-event-type"]), {"data": "for-event"} 207 | ) 208 | 209 | assert len(events) == 1 210 | event, data = events[0] 211 | assert event == "super_event" 212 | assert data == {"event": "test-event-type", "data": {"data": "for-event"}} 213 | 214 | 215 | def test_repr(): 216 | """Test repr function.""" 217 | 218 | class TestNode(base.ZWaveBase): 219 | """Represent a test node.""" 220 | 221 | EVENT_CHANGED = "bla" 222 | 223 | inst = TestNode(None, None, "mock-topic-part", "mock-id") 224 | assert str(inst) == "" 225 | -------------------------------------------------------------------------------- /test/test_manager.py: -------------------------------------------------------------------------------- 1 | """Provide tests for the manager.""" 2 | 3 | 4 | def test_receive_message(mgr): 5 | """Test receive message processing.""" 6 | 7 | messages = 0 8 | 9 | def mock_process_message(topic_parts, payload): 10 | """Test data comes in ok.""" 11 | nonlocal messages 12 | messages += 1 13 | assert list(topic_parts) == ["1", "node", "2", "value", "3"] 14 | assert payload == {"mock": "payload"} 15 | 16 | mgr.process_message = mock_process_message 17 | 18 | mgr.receive_message("OpenZWave/1/node/2/value/3/", '{"mock":"payload"}') 19 | 20 | assert messages == 1 21 | 22 | # Assert that we can filter messages with incorrect instance id. 23 | mgr.options.instance_id = 1 24 | mgr.receive_message("OpenZWave/2/node/2/value/3/", '{"mock":"payload"}') 25 | 26 | assert messages == 1 27 | 28 | mgr.receive_message("OpenZWave/1/node/2/value/3/", '{"mock":"payload"}') 29 | 30 | assert messages == 2 31 | -------------------------------------------------------------------------------- /test/util/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for util module.""" 2 | -------------------------------------------------------------------------------- /test/util/test_node.py: -------------------------------------------------------------------------------- 1 | """Tests for node util submodule.""" 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | 6 | from openzwavemqtt.const import ATTR_LABEL, ATTR_POSITION, ATTR_VALUE, ValueType 7 | from openzwavemqtt.exceptions import InvalidValueError, NotFoundError, WrongTypeError 8 | from openzwavemqtt.models.node import OZWNode 9 | from openzwavemqtt.models.value import OZWValue 10 | from openzwavemqtt.util.node import set_config_parameter 11 | 12 | 13 | @pytest.fixture(name="node") 14 | def mock_node_fixture(options): 15 | """Mock OZWNode.""" 16 | return OZWNode(options, None, "test", None) 17 | 18 | 19 | @pytest.fixture(name="mock_value") 20 | def mock_value_fixture(): 21 | """Mock OZWValue.""" 22 | return Mock(spec=OZWValue) 23 | 24 | 25 | @pytest.fixture(name="mock_get_value") 26 | def mock_get_value_fixture(mock_value): 27 | """Patch get_value.""" 28 | with patch("openzwavemqtt.util.node.OZWNode.get_value", return_value=mock_value): 29 | yield 30 | 31 | 32 | def test_set_bool_config_parameter( 33 | node, mock_value, mock_get_value # pylint: disable=unused-argument 34 | ): 35 | """Test setting a ValueType.BOOL config parameter.""" 36 | mock_value.type = ValueType.BOOL 37 | 38 | assert set_config_parameter(node, 1, True) 39 | assert not set_config_parameter(node, 1, False) 40 | assert set_config_parameter(node, 1, "True") 41 | assert not set_config_parameter(node, 1, "False") 42 | 43 | with pytest.raises(WrongTypeError): 44 | set_config_parameter(node, 1, "test") 45 | 46 | with pytest.raises(WrongTypeError): 47 | set_config_parameter(node, 1, 95) 48 | 49 | 50 | def test_set_list_config_parameter( 51 | node, mock_value, mock_get_value # pylint: disable=unused-argument 52 | ): 53 | """Test setting a ValueType.LIST config parameter.""" 54 | mock_value.type = ValueType.LIST 55 | mock_value.value = {"List": [{"Label": "test", "Value": 0}]} 56 | 57 | assert set_config_parameter(node, 1, "0") == 0 58 | assert set_config_parameter(node, 1, 0) == 0 59 | assert set_config_parameter(node, 1, "test") == 0 60 | 61 | with pytest.raises(NotFoundError): 62 | set_config_parameter(node, 1, 1) 63 | 64 | with pytest.raises(WrongTypeError): 65 | set_config_parameter(node, 1, ["test"]) 66 | 67 | mock_value.value = {"List": [{"Label": "test", "Value": "test"}]} 68 | assert set_config_parameter(node, 1, "test") == "test" 69 | 70 | 71 | def test_set_bitset_config_parameter( 72 | node, mock_value, mock_get_value # pylint: disable=unused-argument 73 | ): 74 | """Test setting a ValueType.BITSET config parameter.""" 75 | mock_value.type = ValueType.BITSET 76 | mock_value.value = [{"Position": 1, "Label": "test", "Value": False}] 77 | 78 | with pytest.raises(WrongTypeError): 79 | set_config_parameter( 80 | node, 1, [{ATTR_POSITION: 1, ATTR_LABEL: "test", ATTR_VALUE: True}] 81 | ) 82 | 83 | with pytest.raises(WrongTypeError): 84 | set_config_parameter(node, 1, [{ATTR_VALUE: True}]) 85 | 86 | with pytest.raises(WrongTypeError): 87 | set_config_parameter(node, 1, [{ATTR_POSITION: "test", ATTR_VALUE: True}]) 88 | 89 | with pytest.raises(WrongTypeError): 90 | set_config_parameter(node, 1, [{ATTR_LABEL: 1, ATTR_VALUE: True}]) 91 | 92 | with pytest.raises(WrongTypeError): 93 | set_config_parameter(node, 1, [{ATTR_POSITION: 1}]) 94 | 95 | with pytest.raises(WrongTypeError): 96 | set_config_parameter(node, 1, [{ATTR_POSITION: 1, ATTR_VALUE: 1}]) 97 | 98 | with pytest.raises(NotFoundError): 99 | set_config_parameter(node, 1, [{ATTR_POSITION: 2, ATTR_VALUE: True}]) 100 | 101 | with pytest.raises(NotFoundError): 102 | set_config_parameter( 103 | node, 1, [{ATTR_LABEL: "test not found", ATTR_VALUE: True}] 104 | ) 105 | 106 | assert set_config_parameter(node, 1, [{ATTR_LABEL: "test", ATTR_VALUE: True}]) == [ 107 | {ATTR_LABEL: "test", ATTR_VALUE: True} 108 | ] 109 | assert set_config_parameter(node, 1, [{ATTR_POSITION: 1, ATTR_VALUE: True}]) == [ 110 | {ATTR_POSITION: 1, ATTR_VALUE: True} 111 | ] 112 | 113 | 114 | def test_set_int_config_parameter( 115 | node, mock_value, mock_get_value # pylint: disable=unused-argument 116 | ): 117 | """Test setting a ValueType.INT config parameter.""" 118 | mock_value.type = ValueType.INT 119 | mock_value.value = 1 120 | mock_value.min = 0 121 | mock_value.max = 10 122 | 123 | with pytest.raises(WrongTypeError): 124 | set_config_parameter(node, 1, "test") 125 | 126 | with pytest.raises(InvalidValueError): 127 | set_config_parameter(node, 1, -1) 128 | 129 | with pytest.raises(InvalidValueError): 130 | set_config_parameter(node, 1, 11) 131 | 132 | assert set_config_parameter(node, 1, 1) == 1 133 | assert set_config_parameter(node, 1, "1") == 1 134 | 135 | 136 | def test_invalid_config_parameter_types( 137 | node, 138 | mock_value, 139 | mock_get_value, # pylint: disable=unused-argument 140 | ): 141 | """Test invalid config parameter types.""" 142 | for value_type in ( 143 | ValueType.DECIMAL, 144 | ValueType.RAW, 145 | ValueType.SCHEDULE, 146 | ValueType.UNKNOWN, 147 | ): 148 | mock_value.type = value_type 149 | with pytest.raises(WrongTypeError): 150 | set_config_parameter(node, 1, True) 151 | 152 | 153 | def test_config_parameter_not_found(node): 154 | """Test config parameter can't be found.""" 155 | with patch("openzwavemqtt.util.node.OZWNode.get_value", return_value=None): 156 | with pytest.raises(NotFoundError): 157 | set_config_parameter(node, 1, True) 158 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, lint, mypy 3 | skip_missing_interpreters = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.8: py38, lint, mypy 8 | 3.9: py39 9 | 10 | [testenv] 11 | commands = 12 | pytest --timeout=30 --cov=openzwavemqtt --cov-report= {posargs} 13 | deps = 14 | -rrequirements_test.txt 15 | 16 | [testenv:lint] 17 | basepython = python3 18 | ignore_errors = True 19 | commands = 20 | black --check ./ 21 | flake8 openzwavemqtt script test 22 | pylint openzwavemqtt script test 23 | pydocstyle openzwavemqtt script test 24 | deps = 25 | -rrequirements.txt 26 | -rrequirements_lint.txt 27 | -rrequirements_script.txt 28 | -rrequirements_test.txt 29 | 30 | [testenv:mypy] 31 | basepython = python3 32 | ignore_errors = True 33 | commands = 34 | mypy openzwavemqtt script 35 | deps = 36 | -rrequirements.txt 37 | -rrequirements_lint.txt 38 | -rrequirements_script.txt 39 | --------------------------------------------------------------------------------