pax_global_header00006660000000000000000000000064147723110610014514gustar00rootroot0000000000000052 comment=cf5a21f87beeb7bba351ba87289b9653d0120b0c PyISY-3.4.0/000077500000000000000000000000001477231106100124755ustar00rootroot00000000000000PyISY-3.4.0/.devcontainer/000077500000000000000000000000001477231106100152345ustar00rootroot00000000000000PyISY-3.4.0/.devcontainer/Dockerfile000066400000000000000000000013651477231106100172330ustar00rootroot00000000000000ARG VARIANT=3-bullseye FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN \ apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ git \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* WORKDIR /workspaces # Install Python dependencies from requirements COPY requirements.txt requirements-dev.txt ./ COPY docs/requirements.txt ./requirements-docs.txt RUN pip3 install -r requirements.txt \ -r requirements-dev.txt \ -r requirements-docs.txt \ && rm -f requirements.txt requirements-dev.txt requirements-docs.txt ENV PATH=/root/.local/bin:${PATH} # Set the default shell to bash instead of sh ENV SHELL /bin/bash PyISY-3.4.0/.devcontainer/devcontainer.json000066400000000000000000000031421477231106100206100ustar00rootroot00000000000000{ "name": "PyISY Devcontainer", "build": { "context": "..", "dockerfile": "Dockerfile", "args": { "VARIANT": "3.9-bullseye" } }, "runArgs": ["-e", "GIT_EDITOR=code --wait"], "postCreateCommand": ["/bin/bash", ".devcontainer/postCreate.sh"], "customizations": { "vscode": { "extensions": [ "ms-python.vscode-pylance", "visualstudioexptteam.vscodeintellicode", "esbenp.prettier-vscode", "github.vscode-pull-request-github", "streetsidesoftware.code-spell-checker", "njpwerner.autodocstring", "ms-python.black-formatter" ], "settings": { "python.pythonPath": "/usr/local/bin/python", "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.formatting.blackPath": "/usr/local/bin/black", "python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", "python.linting.mypyPath": "/usr/local/bin/mypy", "python.linting.pylintPath": "/usr/local/bin/pylint", "python.formatting.provider": "black", "python.testing.pytestArgs": ["--no-cov"], "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" } }, "terminal.integrated.defaultProfile.linux": "zsh" } } } } PyISY-3.4.0/.devcontainer/postCreate.sh000077500000000000000000000005001477231106100176770ustar00rootroot00000000000000#!/bin/bash cd /workspaces/PyISY # Setup the example folder as copy of the example. mkdir -p example cp -r pyisy/__main__.py example/example_connection.py # Install the editable local package pip3 install -e . pip3 install -r requirements-dev.txt # Install pre-commit requirements pre-commit install --install-hooks PyISY-3.4.0/.github/000077500000000000000000000000001477231106100140355ustar00rootroot00000000000000PyISY-3.4.0/.github/dependabot.yml000066400000000000000000000004551477231106100166710ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: github-actions: patterns: - "*" - package-ecosystem: pip directory: "/" schedule: interval: daily open-pull-requests-limit: 10 PyISY-3.4.0/.github/workflows/000077500000000000000000000000001477231106100160725ustar00rootroot00000000000000PyISY-3.4.0/.github/workflows/ci.yml000066400000000000000000000006471477231106100172170ustar00rootroot00000000000000name: pre-commit "on": pull_request: push: branches: - v2.x.x - v3.x.x jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies run: python3 -m pip install -r requirements.txt -r requirements-dev.txt - uses: pre-commit/action@v3.0.1 PyISY-3.4.0/.github/workflows/pythonpublish.yml000066400000000000000000000026421477231106100215310ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package "on": release: types: [created] jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ deploy: permissions: id-token: write runs-on: ubuntu-latest needs: - build name: >- Publish Python 🐍 distribution 📦 to PyPI environment: name: release url: https://pypi.org/p/PyISY steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 PyISY-3.4.0/.gitignore000077500000000000000000000016641477231106100144770ustar00rootroot00000000000000# Emacs backup file *~ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ test_scripts/ var/ *.egg-info/ .installed.cfg *.egg .mypy.cache/ # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ .AppleDouble # Example directory built by devcontainer example/ # Output when dumping to file enabled .output/* PyISY-3.4.0/.pre-commit-config.yaml000066400000000000000000000024351477231106100167620ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-json - id: check-toml - id: check-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] # Run the formatter. - id: ruff-format - hooks: - args: [ "--ignore-words-list=pyisy,hass,isy,nid,dof,dfof,don,dfon,tim,automic,automicus,BATLVL,homeassistant,colorlog,nd", '--skip="./.*,*.json"', --quiet-level=2, ] exclude_types: [json] id: codespell repo: https://github.com/codespell-project/codespell rev: v2.2.2 - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.0 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.0.0-alpha.4 hooks: - id: prettier PyISY-3.4.0/.readthedocs.yaml000066400000000000000000000007601477231106100157270ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py # Optionally build your docs in additional formats such as PDF # formats: # - pdf # Optionally set the version of Python and requirements # required to build your docs python: version: 3.7 install: - requirements: docs/requirements.txt PyISY-3.4.0/.vscode/000077500000000000000000000000001477231106100140365ustar00rootroot00000000000000PyISY-3.4.0/.vscode/extensions.json000066400000000000000000000001651477231106100171320ustar00rootroot00000000000000{ "recommendations": [ "esbenp.prettier-vscode", "ms-python.python", "ms-python.black-formatter" ] } PyISY-3.4.0/.vscode/settings.json000066400000000000000000000005551477231106100165760ustar00rootroot00000000000000{ "python.linting.pylintEnabled": true, "python.linting.enabled": true, "python.formatting.provider": "black", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "python.linting.flake8Enabled": false, "cSpell.words": ["automodule"], "editor.formatOnSaveMode": "file" } PyISY-3.4.0/.yamllint000066400000000000000000000022311477231106100143250ustar00rootroot00000000000000ignore: | azure-*.yml rules: braces: level: error min-spaces-inside: 0 max-spaces-inside: 1 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 brackets: level: error min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 colons: level: error max-spaces-before: 0 max-spaces-after: 1 commas: level: error max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: level: error require-starting-space: true min-spaces-from-content: 2 comments-indentation: level: error document-end: level: error present: false document-start: level: error present: false empty-lines: level: error max: 1 max-start: 0 max-end: 1 hyphens: level: error max-spaces-after: 1 indentation: level: error spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: level: error line-length: disable new-line-at-end-of-file: level: error new-lines: level: error type: unix trailing-spaces: level: error truthy: level: error PyISY-3.4.0/CHANGELOG.md000066400000000000000000000341041477231106100143100ustar00rootroot00000000000000## CHANGELOG ### GitHub Release Versioning As of v3.0.7, this module will document all changes within the GitHub release information to avoid duplication. ### [v3.0.6] Fix Group States for Stateless Controllers - Fix Group States for Stateless Controllers (Fixes #256) - ControlLinc and RemoteLinc (v1) devices are excluded from group states to correctly reflect the state since these devices don't update the controller status unless a button is pushed. - Bump pyupgrade from 2.31.0 to 2.31.1 - Bump pylint from 2.12.2 to 2.13.4 - Bump black from 22.1.0 to 22.3.0 ### [v3.0.5] Forced Republish of Package - No changes ### [v3.0.3] - Maintenance Release - Prevent callback exception from disconnecting websocket (#248) ### [v3.0.2] - Unsubscribe on Lost Connection - Attempt to unsubscribe instead of hard disconnect on non-critical socket errors (TCP Socket only, does not affect websockets). - Bump pyupgrade from 2.29.1 to 2.31.0 - Update `.devcontainer` to latest VSCode Template - Bump black from 21.12b0 to 22.1.0 (#247) ### [v3.0.1] - Fix Unsubscribe SOAP Message and Dependency Updates - Bump codespell from 2.0.0 to 2.1.0 (#195) - Bump flake8 from 3.9.2 to 4.0.1 (#229) - Bump isort from 5.8.0 to 5.10.1 (#232) - Bump pyupgrade from 2.16.0 to 2.29.1 (#234) - Bump black from 21.5b1 to 21.12b0 (#238) - Bump pylint from 2.8.2 to 2.12.2 (#239) - Fixed Unsubscribe soap body (#240) ### [v3.0.0] - Async All the Things #### Breaking Changes - Module now uses asynchronous communications via `asyncio` and `aiohttp` for communicating with the ISY. Updates are required to run the module in an asyncio event loop. - Connection with the ISY is no longer automatically initialized when the `ISY` or `Connection` classes are initialized. The `await isy.initialize()` function must be called when ready to connect. To test a connection only, you can use `Connection.test_connection()` after initializing at least a `Connection` class. - When sending a command, the node status is no longer updated presumptively using a `hint` value. If you are not using either the websocket or event stream, you will need to manually call `node.update(wait_time=UPDATE_INTERVAL)` for the node after calling the `node.send_cmd()` to update the value of the node (#155). - Group/Scene Status now excludes the state of any Insteon battery powered devices (on ISYv5 firmware only). These devices often have stale states and only update when they change, not when other controllers in the scene change; this leads to incorrect or misleading Group/Scene states (#156). #### Changed - Module can now be used/tested from the command-line with the new `__main__.py` script; you can test a connection with `python3 -m pyisy http://your-isy-url:80 username password`. - Module now supports using the Websocket connections to the ISY instead of a SOAP-message based socket. This can be enabled by setting the `use_websocket=True` keyword parameter when initializing the `ISY()` class. - A new helper function has been added to create an `aiohttp.ClientSession` compliant with the ISY: `Connection.get_new_client_session(use_https, tls_ver=1.1)` will return a web session that can be passed to the init functions of `ISY` and `Connection` classes. - Add support for setting and retrieving Z-Wave Device Parameters using `node.set_zwave_parameter()` and `node.get_zwave_parameter()`. - Allow renaming of nodes and groups for ISY v5.2.0 or later using the `node.rename()` method (#157). - Add a folder property to each node and group (#159) - Force UTF-8 decoding of responses and ignore errors (#126) - Re-instate Documentation via Sphinx and ReadTheDocs (#150) (still a work in progress...) - Fix Group All On improper reporting (7a4b3b4) - Add DevContainer for development in VS Code. ### [v2.1.0] - Property Updates, Timestamps, Status Handling, and more... #### Breaking Changes - `Node.dimmable` has been depreciated in favor of `Node.is_dimmable` to make the naming more consistent with `is_lock` and `is_thermostat`. `Node.dimmable` will still work, however, plan for it to be removed in the future. - `Node.is_dimmable` will only include the first subnode for Insteon devices in type 1. This should represent the main (load) button for KeypadLincs and the light for FanLincs, all other subnodes (KPL buttons and Fan Motor) are not dimmable (fixes #110) - This removes the `log=` parameter when initializing new `Connection` and `ISY` class instances. Please update any loading functions you may use to remove this `log=` parameter. #### Changed / Fixed - Changed the default Status Property (`ST`) unit of measurement (UOM) to `ISY_PROP_NOT_SET = "-1"`: Some NodeServer and Z-Wave nodes do not make use of the `ST` (or status) property in the ISY and only report `aux_properties`; in addition, most NodeServer nodes do not report the `ST` property when all nodes are retrieved, they only report it when queried directly or in the Event Stream. Previously, there was no way to differentiate between Insteon Nodes that don't have a valid status yet (after ISY reboot) and the other types of nodes that don't report the property correctly since they both reported `ISY_VALUE_UNKNOWN`. The `ISY_PROP_NOT_SET` allows differentiation between the two conditions based on having a valid UOM or not. Fixes #98. - Rewrite the Node status update receiver: currently, when a Node's status is updated, the `formatted` property is not updated and the `uom`/`prec` are updated with separate functions from outside of the Node's class. This updates the receiver to pass a `NodeProperty` instance into the Node, and allows the Node to update all of it's properties if they've changed, before reporting the status change to the subscribers. This makes the `formatted` property actually useful. - Logging Cleanup: Removes reliance on `isy` parent objects to provide logger and uses a module-wide `_LOGGER`. Everything will be logged under the `pyisy` namespace except Events. Events maintains a separate logger namespace to allow targeting in handlers of `pyisy.events`. #### Added - Added `*.last_update` and `*.last_changed` properties which are UTC Datetime Timestamps, to allow detection of stale data. Fixes #99 - Add connection events for the Event Stream to allow subscription and callbacks. Attach a callback with `isy.connection_events(callback)` and receive a string with the event detail. See `constants.py` for events starting with prefix `ES_`. - Add a VSCode Devcontainer based on Python 3.8 - Update the package requirements to explicitly include dateutil and the dev requirements for pre-commit - Add pyupgrade hook to pre-commit and run it on the whole repo. #### All PRs in this Version: - Revise Node.dimmable property to exclude non-dimmable subnodes (#122) - Logging cleanup and consolidation (#106) - Fix #109 - Update for events depreciation warning - Add Devcontainer, Update Requirements, Use PyUpgrade (#105) - Guard against overwriting known attributes with blanks (#112) - Minor code cleanups (#104) - Fix Property Updates, Add Timestamps, Unused Status Handling (#100) - Fix parameter name (#102) - Add connection events target (#101) #### Dependency Changes: - Bump black from 19.10b0 to 20.8b1 - Bump pyupgrade from 2.3.0 to 2.7.2 - Bump codespell from 1.16.0 to 1.17.1 - Bump flake8 from 3.8.1 to 3.8.3 - Bump pydocstyle from 5.0.2 to 5.1.1 - Bump pylint from 2.4.4 to 2.6.0 - Bump isort from 4.3.21 to 5.5.2 ### [v2.0.2] - Version 2.0 Initial Release #### Summary: V2 is a significant refactoring and cleanup of the original PyISY code, with the primary goal of (1) fixing as many bugs in one shot as possible and (2) moving towards PEP8 compliant code with as few breaking changes as possible. #### Breaking Changes: - **CRITICAL** All module and folder names are now lower-case. - All `import PyISY` and `from PyISY import *` must be updated to `import pyisy` and `from pyisy import *`. - All class imports (e.g. `from PyISY.Nodes import Node` is now `from pyisy.nodes import Node`). Class names are still capitalized / CamelCase. - A node Event is now returned as an `NodeProperty(dict)` object. In most cases this is a benefit because it returns more details than just the received command (value, uom, precision, etc); direct comparisons will now fail unless updated: - "`event == "DON"`" must be replaced with "`event.control == "DON"`" - Node Unit of Measure is returned as a string if it is not a list of UOMs, otherwise it is returned as a list. Previously this was returned as a 1-item list if there was only 1 UOM. - ISYv4 and before returned the UOM as a string ('%/on/off' or 'degrees'), ISYv5 phases this out and uses numerical UOMs that correspond to a defined value in the SDK (included in constants file). - Previous implementations of `unit = uom[0]` should be replaced with `unit = uom` and for compatibility, UOM should be checked if it is a list with `isinstance(uom, list)`. ```python uom = self._node.uom if isinstance(uom, list): uom = uom[0] ``` - Functions and properties have been renamed to snake_case from CamelCase. - Property `node.hasChildren` has been renamed to `node.has_children`. - Node Parent property has been renamed. Internal property is `node._parent_nid`, but externally accessible property is `node.parent_node`. - `node.controlEvents` has been renamed to `node.control_events`. - `variable.setInit` and `variable.set_value` have been renamed to `variable.set_init` and `variable.set_value`. - `ISY.sendX10` has been renamed to `ISY.send_x10_cmd`. - Network Resources `updateThread` function has been renamed to `update_threaded`. - Properties `nid`, `pid`, `nids`, `pids` have been renamed to `address` and `addresses` for consisitency. Variables still use `vid`; however, they also include an `address` property of the form `type.id`. - Node Functions `on()` and `off()` have been renamed to `turn_on()` and `turn_off()` - Node.lock() and Node.unlock() methods are now Node.secure_lock() and Node.secure_unlock(). - Node climate and fan speed functions have been reduced and require a proper command from UOM 98/99 (see `constants.py`): - For example to activate PROGRAM AUTO mode, call `node.set_climate_mode("program_auto")` - Program functions have been renamed: - `runThen` -> `run_then` - `runElse` -> `run_else` - `enableRunAtStartup` -> `enable_run_at_startup` - `disableRunAtStartup` -> `disable_run_at_startup` - Climate Module Retired as per [UDI Announcement](https://www.universal-devices.com/byebyeclimatemodule/) - Remove dependency on VarEvents library - Calling `node.status.update(value)` (non-silent) to require the ISY to update the node has been removed. Use the proper functions (e.g. `on()`, `off()`) to request the ISY update. Note: all internal functions previously used `silent=True` mode. - Variables `val` property is now `status` for consistency. - Variables `lastEdit` property is now `last_edited` and no longer fires events on its own. Use a single subscriber to pick up changes to `status`, `init`, and `ts`. - Group All On property no longer first its own event. Subscribe to the status events for changes. - Subscriptions for status changes need to be updated: ```python # Old: node.status.subscribe("changed", self.on_update) # New: node.status_events.subscribe(self.on_update) ``` - Program properties no longer fire their own events, but will fire the main status_event when something is changed. - Program property changes to conform to snake_case. - `lastUpdate` -> `last_update` - `lastRun` -> `last_run` - `lastFinished` -> `last_finished` - `runAtStartup` -> `run_at_startup` #### New: - Major code refactoring to consolidate nested function calls, remove redundant code. - Black Formatting and Linting to PEP8. - Modification of the `Connection` class to allow initializing a connection to the ISY and making calls externally, without the need to initialize a full `ISY` class with all properties. - Adding retries for failed REST calls to the ISY #46 - Add support for ISY Portal (incl. multiple ISYs): - Initialize the connection with: ```python isy = ISY( address="my.isy.io", port=443, username="your@portal.email", password="yourpassword", use_https=True, tls_ver=1.1, log=None, webroot="/isy/unique_isy_url_code_from_portal", ) # Unique URL can be found in ISY Portal under # Tools > Information > ISY Information ``` - Adds increased Z-Wave support by returning Z-Wave Properties under the `Node.zwave_props` property: - `category` - `devtype_mfg` - `devtype_gen` - `basic_type` - `generic_type` - `specific_type` - `mfr_id` - `prod_type_id` - `product_id` - Expose UUID, Firmware, and Hostname properties for referencing inside the `isy` object. - Various node commands have been renamed / newly exposed: - `start_manual_dimming` - `stop_manual_dimming` - `set_climate_setpoint` - `set_climate_setpoint_heat` - `set_climate_setpoint_cool` - `set_fan_speed` - `set_climate_mode` - `beep` - `brighten` - `dim` - `fade_down` - `fade_up` - `fade_stop` - `fast_on` - `fast_off` - In addition to the `node.parent_node` which returns a `Node` object if a node has a primary/parent node other than itself, there is now a `node.primary_node` property, which just returns the address of the primary node. If the device/group _is_ the primary node, this is the same as the address (this is the `pnode` tag from `/rest/nodes`). - Expose the ISY Query Function (`/rest/query`) as `isy.query()` #### Fixes: - #11, #19, #22, #23, #31, #32, #41, #43, #45, #46, #51, #55, #59, #60, #82, #83 - Malformed climate control commands - They were missing the `self._id` parameter, were missing a `.conn` in the command path and did not convert the values to strings before attempting to encode. - They are sending \*2 for the temperature for ALL thermostats instead of just Insteon/UOM 101. - Several modes were missing for the Insteon Thermostats. - Fix Node.aux_properties inconsistent typing #43 and now updates the existing aux_props instead of re-writing the entire dict. - Zwave multisensor support #31 -- Partial Fix. [Forum Thread is here](https://community.home-assistant.io/t/isy994-z-wave-sensor-enhancements-testers-wanted/124188) PyISY-3.4.0/LICENSE.txt000066400000000000000000000011041477231106100143140ustar00rootroot00000000000000 Copyright 2014 Automicus Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. PyISY-3.4.0/README.md000066400000000000000000000046001477231106100137540ustar00rootroot00000000000000## PyISY ### Python Library for the ISY Controller This library allows for easy interaction with ISY nodes, programs, variables, and the network module. This class also allows for functions to be assigned as handlers when ISY parameters are changed. ISY parameters can be monitored automatically as changes are reported from the device. **NOTE:** Significant changes have been made in V2, please refer to the [CHANGELOG](CHANGELOG.md) for details. It is recommended you do not update to the latest version without testing for any unknown breaking changes or impacts to your dependent code. ### Examples See the [examples](examples/) folder for connection examples. The full documentation is available at https://pyisy.readthedocs.io. ### Development Team - Greg Laabs ([@overloadut]) - Maintainer - Ryan Kraus ([@rmkraus]) - Creator - Tim ([@shbatm]) - Version 2 Contributor ### Contributing A note on contributing: contributions of any sort are more than welcome! This repo uses precommit hooks to validate all code. We use `black` to format our code, `isort` to sort our imports, `flake8` for linting and syntax checks, and `codespell` for spell check. To use [pre-commit](https://pre-commit.com/#installation), see the installation instructions for more details. Short version: ```shell # From your copy of the pyisy repo folder: pip install pre-commit pre-commit install ``` A [VSCode DevContainer](https://code.visualstudio.com/docs/remote/containers#_getting-started) is also available to provide a consistent development environment. Assuming you have the pre-requisites installed from the link above (VSCode, Docker, & Remote-Containers Extension), to get started: 1. Fork the repository. 2. Clone the repository to your computer. 3. Open the repository using Visual Studio code. 4. When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. - If you don't see this notification, open the command palette and select Remote-Containers: Reopen Folder in Container. 5. Once started, you will also have a `test_scripts/` folder with a copy of the example scripts to run in the container which won't be committed to the repo, so you can update them with your connection details and test directly on your ISY. [@overloadut]: https://github.com/overloadut [@rmkraus]: https://github.com/rmkraus [@shbatm]: https://github.com/shbatm PyISY-3.4.0/docs/000077500000000000000000000000001477231106100134255ustar00rootroot00000000000000PyISY-3.4.0/docs/Makefile000066400000000000000000000011721477231106100150660ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) PyISY-3.4.0/docs/conf.py000066400000000000000000000050521477231106100147260ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys from unittest import mock import sphinx_rtd_theme sys.path.insert(0, os.path.abspath("..")) MOCK_MODULES = [ "dateutil", "aiohttp", ] for mod_name in MOCK_MODULES: sys.modules[mod_name] = mock.Mock() # -- Project information ----------------------------------------------------- project = "PyISY" copyright = "2021, rmkraus, overloadut, shbatm" author = "rmkraus, overloadut, shbatm" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ # "recommonmark", "sphinx.ext.todo", "sphinx.ext.viewcode", "sphinx.ext.autodoc", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # The short X.Y version. # version = '1.0' # The full version, including alpha/beta/rc tags. # release = '1.0.5' # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False PyISY-3.4.0/docs/constants.rst000066400000000000000000000003741477231106100161770ustar00rootroot00000000000000PyISY Constants =============== Constants used by the PyISY module are derived from `The ISY994 Developer Cookbook `_. .. automodule:: pyisy.constants :members: PyISY-3.4.0/docs/index.rst000066400000000000000000000035571477231106100153000ustar00rootroot00000000000000PyISY ===== A Python Library for the ISY994 Controller ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module was developed to communicate with the `UDI ISY994 `_ home automation hub via the hub's REST interface and Websocket/SOAP event streams. It provides near real-time updates from the device and allows control of all devices that are supported within the ISY. This module also allows for functions to be assigned as handlers when ISY parameters are changed. ISY parameters can be monitored automatically as changes are reported from the device. .. warning:: THIS DOCUMENTATION IS STILL A WORK-IN-PROGRESS. Some of the details have not yet been updated for Version 2 or Version 3 of the PyISY Module. If you would like to help, please contribute on GitHub. Project Information ~~~~~~~~~~~~~~~~~~~ .. note:: This documentation is specific to PyISY Version 3.x.x, which uses asynchronous communications and the asyncio module. If you need threaded (synchronous) support please use Version 2.x.x. | Docs: `ReadTheDocs `_ | Source: `GitHub `_ Installation ~~~~~~~~~~~~ The easiest way to install this package is using pip with the command: .. code-block:: bash pip3 install pyisy See the :ref:`PyISY Tutorial` for guidance on how to use the module. Requirements ~~~~~~~~~~~~ This package requires three other packages, also available from pip. They are installed automatically when PyISY is installed using pip. * `requests `_ * `dateutil `_ * `aiohttp `_ Contents ~~~~~~~~ .. toctree:: :maxdepth: 1 quickstart library constants Indices and Tables ~~~~~~~~~~~~~~~~~~ * :ref:`genindex` * :ref:`search` PyISY-3.4.0/docs/library.rst000066400000000000000000000030341477231106100156230ustar00rootroot00000000000000PyISY Library Reference ======================= ISY Class --------- .. autoclass:: pyisy.isy.ISY :members: Node Manager Class ------------------ .. autoclass:: pyisy.nodes.Nodes :members: :special-members: Node Base Class --------------- .. autoclass:: pyisy.nodes.nodebase.NodeBase :members: :special-members: Node Class ---------- .. autoclass:: pyisy.nodes.Node :members: :special-members: Group Class ----------- .. autoclass:: pyisy.nodes.Group :members: :special-members: Program Manager Class --------------------- .. autoclass:: pyisy.programs.Programs :members: :special-members: Folder Class ------------ .. autoclass:: pyisy.programs.Folder :members: :special-members: Program Class ------------- .. autoclass:: pyisy.programs.Program :members: :special-members: :inherited-members: Variable Manager Class ---------------------- .. autoclass:: pyisy.variables.Variables :members: :special-members: Variable Class -------------- .. autoclass:: pyisy.variables.Variable :members: :special-members: Clock Class ----------- .. autoclass:: pyisy.clock.Clock :members: :special-members: NetworkResources Class ---------------------- .. autoclass:: pyisy.networking.NetworkResources :members: :special-members: NetworkCommand Class -------------------- .. autoclass:: pyisy.networking.NetworkCommand :members: :special-members: Connection Class ---------------- .. autoclass:: pyisy.connection.Connection :members: :special-members: PyISY-3.4.0/docs/make.bat000066400000000000000000000013701477231106100150330ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd PyISY-3.4.0/docs/quickstart.rst000066400000000000000000000263071477231106100163610ustar00rootroot00000000000000.. _tutorial: PyISY Tutorial ============== This is the basic user guide for the PyISY Python module. This module was developed to communicate with the UDI ISY-994 home automation hub via the hub's REST interface and Websocket/SOAP event streams. It provides near real-time updates from the device and allows control of all devices that are supported within the ISY. The way this module works is by connecting to your device, gathering information about the configuration and nodes connected, and then creating a local shadow structure that mimics the ISY's internal structure; similar to how UDI's Admin Console works in Java. Once connected, the module can then be used by other programs or Python scripts to interact with the ISY and either a) get the status of a node, program, variable, etc., or b) run a command on those items. .. note:: This documentation is specific to PyISY Version 3.x.x, which uses asynchronous communications and the asyncio module. If you need threaded (synchronous) support please use Version 2.x.x. Environment Setup ----------------- This module can be installed via pip in any environment supporting Python 3.7 or later: .. code-block:: shell pip3 install pyisy Quick Start ~~~~~~~~~~~ Starting with Version 3, this module can connect directly from the the command line to immediately print the list of nodes, and connect to the event stream and print the events as they are sent from the ISY. After installation, you can test the connection with the following .. code-block:: shell python3 -m pyisy http://your-isy-url:80 username password A good starting point for developing your own code is to copy the `__main__.py` file from the module's source code. This walks you through how to create the connections and some simple commands to get you started. You can download it from GitHub: ``_ Basic Usage ----------- Testing Your Connection ~~~~~~~~~~~~~~~~~~~~~~~ When connecting to the ISY, it will connect and download all available information and populate the local structures. Sometimes you just want to make sure the connection works before setting everything up. This can be done using the :class:`Connection` Class. .. code-block:: python import asyncio import logging from urlparse import urlparse from pyisy import ISY from pyisy.connection import ISYConnectionError, ISYInvalidAuthError, get_new_client_session _LOGGER = logging.getLogger(__name__) """Validate the user input allows us to connect.""" user = "username" password = "password" host = urlparse("http://isy994-ip-address:port/") tls_version = "1.2" # Can be False if using HTTP if host.scheme == "http": https = False port = host.port or 80 elif host.scheme == "https": https = True port = host.port or 443 else: _LOGGER.error("host value in configuration is invalid.") return False # Use the helper function to get a new aiohttp.ClientSession. websession = get_new_client_session(https, tls_ver) # Connect to ISY controller. isy_conn = Connection( host.hostname, port, user, password, use_https=https, tls_ver=tls_version, webroot=host.path, websession=websession, ) try: with async_timeout.timeout(30): isy_conf_xml = await isy_conn.test_connection() except (ISYInvalidAuthError, ISYConnectionError): _LOGGER.error( "Failed to connect to the ISY, please adjust settings and try again." ) Once you have a connection class and successfully tested the configuration, you can then use the :class:`Configuration` Class to get some additional details about the ISY, including the firmware version, name, and installed options like Networking, Variables, or NodeServers. .. code-block:: python try: isy_conf = Configuration(xml=isy_conf_xml) except ISYResponseParseError as error: raise CannotConnect from error if not isy_conf or "name" not in isy_conf or not isy_conf["name"]: raise CannotConnect Connecting to the Controller ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Connecting to the controller is simple and will create an instance of the :class:`ISY` class. This instance is what we will use to interact with the controller. By default when connecting to the ISY, it will load all available modules; this means all of the Nodes, Scenes, Programs, and Variables. The networking module will only be loaded if it is available. As mentioned above, the best starting point for your own script is the `__main__.py` file. This includes the basic connection to the ISY and also connecting to the event stream. Looking at the main function here, you can see the general flow: 1. Validate the settings 2. Create (or provide) an `asyncio` WebSession. 3. Create an instance of the :class:`ISY` Class 4. Initialize the connection with :meth:`isy.initialize`. 5. Connect to the :class:`WebSocketClient` for real-time event updates. 6. Safely shutdown the connection when done with :meth:`isy.shutdown()`. .. literalinclude:: ../pyisy/__main__.py :language: python :pyobject: main General Structure of the ISY Class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :class:`ISY` Class holds the local "shadow" copy of the ISY's structure and status. You can access the different components just like Python a `dict`. Each category is a `dict`-like object that holds the structure, and then each element is populated within that structure. - Nodes & Groups (Scenes): :class:`isy.nodes` - Programs & Program Folders: :class:`isy.programs` - Variables: :class:`isy.variables` - Network Resources: :class:`isy.networking` - Clock Info: :class:`isy.clock` - Configuration Info: :class:`isy.configuration` Controlling a Node on the Insteon Network ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Let's get straight into the fun by toggling a node on the Insteon network. To interact with both Insteon nodes and scenes, the nodes subclass is used. The best way to connect to a node is by using its address directly. Nodes and folders on the ISY controller can also be called by their name. .. code:: python # interact with node using address NODE = '22 5C EB 1' node = isy.nodes[NODE] await node.turn_off() sleep(5) await node.turn_on() .. code:: python # interact with node using name node = isy.nodes['Living Room Lights'] await node.turn_off() sleep(5) await node.turn_on() Controlling a Scene (Group) on the Insteon Network ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Just a small point of order here. The words Group and Scene are used interchangeably on the ISY device and similarly in this library. Don't let this confuse you. Now, groups and nodes are controlled in nearly identical ways. They can be referenced by either name or address. .. code:: python # control scene by address SCENE = '28614' await isy.nodes[SCENE].turn_off() asyncio.sleep(5) await isy.nodes[SCENE].turn_on() .. code:: python # control scene by name await isy.nodes['Downstairs Dim'].turn_off() asyncio.sleep(5) await isy.nodes['Downstairs Dim'].turn_on() Controlling an ISY Program ~~~~~~~~~~~~~~~~~~~~~~~~~~ Programs work the same way. I feel like you are probably getting the hang of this now, so I'll only show an example using an address. One major difference between programs and nodes and groups is that with programs, you can also interact directly with folders. .. code:: python # controlling a program PROG = '005E' await isy.programs[PROG].run() asyncio.sleep(3) await isy.programs[PROG].run_else() asyncio.sleep(3) await isy.programs[PROG].run_then() In order to interact with a folder as if it were a program, there is one extra step involved. .. code:: python PROG_FOLDER = '0061' # the leaf property must be used to get an object that acts like program await isy.programs[PROG_FOLDER].leaf.run() Controlling ISY Variables ~~~~~~~~~~~~~~~~~~~~~~~~~ Variables can be a little tricky. There are integer variables and state variables. Integer variables are called with a 1 and state variables are called with a 2. Below is an example of both. .. code:: python # controlling an integer variable var = isy.variables[1][3] await var.set_value(0) print(var.status) await var.set_value(6) print(var.status) .. parsed-literal:: 0 6 .. code:: python # controlling a state variable (Type 2) init value var = isy.variables[2][14] await var.set_init(0) print(var.init) await var.set_init(6) print(var.init) .. parsed-literal:: 0 6 Controlling the Networking Module ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This is in the works and coming soon. Event Updates ------------- This library can subscribe to the ISY's Event Stream to receive updates on devices as they are manipulated. This means that your program can respond to events on your controller in real time using websockets and a subscription-based event notification. Subscribing to Updates ~~~~~~~~~~~~~~~~~~~~~~ .. warning:: THIS DOCUMENTATION IS STILL A WORK-IN-PROGRESS. The details have not yet been updated for Version 2 or Version 3 of the PyISY Module. If you would like to help, please contribute on GitHub. The ISY class will not be receiving updates by default. It is, however, easy to enable, and it is done like so. .. code:: python isy.auto_update = True By default, PyISY will detect when the controller is no longer responding and attempt a reconnect. Keep in mind though, it can take up to two minutes to detect a lost connection. This means if you restart your controller, in about two minutes PyISY will detect that, reconnect, and update all the elements to their updated state. To turn off auto reconnects, the following parameter can be changed. .. code:: python isy.auto_reconnect = False Now, once the connection is lost, it will stay disconnected until it is told to reconnect. Binding Events to Updates ~~~~~~~~~~~~~~~~~~~~~~~~~ Using the VarEvents library, we can bind functions to be called when certain events take place. Subscribing to an event will return a handler that we can use to unsubscribe later. For a full list of events, check out the VarEvents documentation. .. code:: python def notify(e): print('Notification Received') # interact with node using address NODE = '22 5C EB 1' node = isy.nodes[NODE] handler = node.status.subscribe('changed', notify) Now, when we make a change to the node, we will receive the notification... .. code:: python node.status.update(100) .. parsed-literal:: Notification Received Now we can unsubscribe from the event using the handler. .. code:: python handler.unsubscribe() node.status.update(75) More details about event handling are discussed inside the rest of the documentation, but that is the basics. PyISY-3.4.0/docs/requirements.txt000066400000000000000000000000441477231106100167070ustar00rootroot00000000000000sphinx_rtd_theme>=0.5.1 mock>=4.0.3 PyISY-3.4.0/pyisy/000077500000000000000000000000001477231106100136525ustar00rootroot00000000000000PyISY-3.4.0/pyisy/__init__.py000077500000000000000000000027351477231106100157750ustar00rootroot00000000000000""" PyISY - Python Library for the ISY Controller. This module is a set of Python bindings for the ISY's REST API. The ISY is developed by Universal Devices and is a home automation controller for Insteon and X10 devices. Copyright 2015 Ryan M. Kraus rmkraus at gmail dot com Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ from __future__ import annotations from importlib.metadata import PackageNotFoundError, version from .exceptions import ( ISYConnectionError, ISYInvalidAuthError, ISYMaxConnections, ISYResponseParseError, ISYStreamDataError, ISYStreamDisconnected, ) from .isy import ISY try: __version__ = version("pyisy") except PackageNotFoundError: __version__ = "unknown" __all__ = [ "ISY", "ISYConnectionError", "ISYInvalidAuthError", "ISYMaxConnections", "ISYResponseParseError", "ISYStreamDataError", "ISYStreamDisconnected", ] __author__ = "Ryan M. Kraus" __email__ = "rmkraus at gmail dot com" __date__ = "February 2020" PyISY-3.4.0/pyisy/__main__.py000066400000000000000000000107551477231106100157540ustar00rootroot00000000000000"""Implementation of module for command line. The module can be tested by running the following command: `python3 -m pyisy http://your-isy-url:80 username password` Use `python3 -m pyisy -h` for full usage information. This script can also be copied and used as a template for using this module. """ from __future__ import annotations import argparse import asyncio import logging import time from urllib.parse import urlparse from . import ISY from .connection import ISYConnectionError, ISYInvalidAuthError, get_new_client_session from .constants import NODE_CHANGED_ACTIONS, SYSTEM_STATUS from .logging import LOG_VERBOSE, enable_logging from .nodes import NodeChangedEvent _LOGGER = logging.getLogger(__name__) async def main(url, username, password, tls_ver, events, node_servers): """Execute connection to ISY and load all system info.""" _LOGGER.info("Starting PyISY...") t_0 = time.time() host = urlparse(url) if host.scheme == "http": https = False port = host.port or 80 elif host.scheme == "https": https = True port = host.port or 443 else: _LOGGER.error("host value in configuration is invalid.") return False # Use the helper function to get a new aiohttp.ClientSession. websession = get_new_client_session(https, tls_ver) # Connect to ISY controller. isy = ISY( host.hostname, port, username=username, password=password, use_https=https, tls_ver=tls_ver, webroot=host.path, websession=websession, use_websocket=True, ) try: await isy.initialize(node_servers) except (ISYInvalidAuthError, ISYConnectionError): _LOGGER.exception("Failed to connect to the ISY, please adjust settings and try again.") await isy.shutdown() return None except Exception as err: _LOGGER.exception("Unknown error occurred: %s", err.args[0]) await isy.shutdown() raise # Print a representation of all the Nodes _LOGGER.debug(repr(isy.nodes)) _LOGGER.info("Total Loading time: %.2fs", time.time() - t_0) node_changed_subscriber = None system_status_subscriber = None def node_changed_handler(event: NodeChangedEvent) -> None: """Handle a node changed event sent from Nodes class.""" (event_desc, _) = NODE_CHANGED_ACTIONS[event.action] _LOGGER.info( "Subscriber--Node %s Changed: %s %s", event.address, event_desc, event.event_info if event.event_info else "", ) def system_status_handler(event: str) -> None: """Handle a system status changed event sent ISY class.""" _LOGGER.info("System Status Changed: %s", SYSTEM_STATUS.get(event)) try: if events: isy.websocket.start() node_changed_subscriber = isy.nodes.status_events.subscribe(node_changed_handler) system_status_subscriber = isy.status_events.subscribe(system_status_handler) await asyncio.Event.wait() except asyncio.CancelledError: pass finally: if node_changed_subscriber: node_changed_subscriber.unsubscribe() if system_status_subscriber: system_status_subscriber.unsubscribe() await isy.shutdown() if __name__ == "__main__": parser = argparse.ArgumentParser(prog=__package__) parser.add_argument("url", type=str) parser.add_argument("username", type=str) parser.add_argument("password", type=str) parser.add_argument("-t", "--tls-ver", dest="tls_ver", type=float) parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-q", "--no-events", dest="no_events", action="store_true") parser.add_argument("-n", "--node-servers", dest="node_servers", action="store_true") parser.set_defaults(use_https=False, tls_ver=1.1, verbose=False) args = parser.parse_args() enable_logging(LOG_VERBOSE if args.verbose else logging.DEBUG) _LOGGER.info( "ISY URL: %s, username: %s, TLS: %s", args.url, args.username, args.tls_ver, ) try: asyncio.run( main( url=args.url, username=args.username, password=args.password, tls_ver=args.tls_ver, events=(not args.no_events), node_servers=args.node_servers, ) ) except KeyboardInterrupt: _LOGGER.warning("KeyboardInterrupt received. Disconnecting!") PyISY-3.4.0/pyisy/clock.py000066400000000000000000000114571477231106100153270ustar00rootroot00000000000000"""ISY Clock/Location Information.""" from __future__ import annotations from asyncio import sleep from datetime import datetime from typing import TYPE_CHECKING from xml.dom import minidom from .constants import ( EMPTY_TIME, TAG_DST, TAG_LATITUDE, TAG_LONGITUDE, TAG_MILIATRY_TIME, TAG_NTP, TAG_SUNRISE, TAG_SUNSET, TAG_TZ_OFFSET, XML_TRUE, ) from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from .helpers import ntp_to_system_time, value_from_xml from .logging import _LOGGER if TYPE_CHECKING: from .isy import ISY class Clock: """ ISY Clock class cobject. DESCRIPTION: This class handles the ISY clock/location info. ATTRIBUTES: isy: The ISY device class last_called: the time of the last call to /rest/time tz_offset: The Time Zone Offset of the ISY dst: Daylight Savings Time Enabled or not latitude: ISY Device Latitude longitude: ISY Device Longitude sunrise: ISY Calculated Sunrise sunset: ISY Calculated Sunset military: If the clock is military time or not. """ def __init__(self, isy: ISY, xml: str | None = None) -> None: """ Initialize the network resources class. isy: ISY class xml: String of xml data containing the configuration data """ self.isy = isy self._last_called = EMPTY_TIME self._tz_offset = 0 self._dst = False self._latitude = 0.0 self._longitude = 0.0 self._sunrise = EMPTY_TIME self._sunset = EMPTY_TIME self._military = False if xml is not None: self.parse(xml) def __str__(self) -> str: """Return a string representing the clock Class.""" return f"ISY Clock (Last Updated {self.last_called})" def __repr__(self) -> str: """Return a long string showing all the clock values.""" props = [name for name, value in vars(Clock).items() if isinstance(value, property)] return f"ISY Clock: { ({prop: str(getattr(self, prop)) for prop in props})!r}" def parse(self, xml: str) -> None: """ Parse the xml data. xml: String of the xml data """ try: xmldoc = minidom.parseString(xml) except XML_ERRORS as exc: _LOGGER.error("%s: Clock", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc tz_offset_sec = int(value_from_xml(xmldoc, TAG_TZ_OFFSET)) self._tz_offset = tz_offset_sec / 3600 self._dst = value_from_xml(xmldoc, TAG_DST) == XML_TRUE self._latitude = float(value_from_xml(xmldoc, TAG_LATITUDE)) self._longitude = float(value_from_xml(xmldoc, TAG_LONGITUDE)) self._military = value_from_xml(xmldoc, TAG_MILIATRY_TIME) == XML_TRUE self._last_called = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_NTP))) self._sunrise = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_SUNRISE))) self._sunset = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_SUNSET))) _LOGGER.info("ISY Loaded Clock Information") async def update(self, wait_time: int = 0) -> None: """ Update the contents of the networking class. wait_time: [optional] Amount of seconds to wait before updating """ await sleep(wait_time) xml = await self.isy.conn.get_time() self.parse(xml) async def update_thread(self, interval: int) -> None: """ Continually update the class until it is told to stop. Should be run as a task in the event loop. """ while self.isy.auto_update: await self.update(interval) @property def last_called(self) -> datetime: """Get the time of the last call to /rest/time in UTC.""" return self._last_called @property def tz_offset(self) -> float: """Provide the Time Zone Offset from the isy in Hours.""" return self._tz_offset @property def dst(self) -> bool: """Confirm if DST is enabled or not on the ISY.""" return self._dst @property def latitude(self) -> float: """Provide the latitude information from the isy.""" return self._latitude @property def longitude(self) -> float: """Provide the longitude information from the isy.""" return self._longitude @property def sunrise(self) -> datetime: """Provide the sunrise information from the isy (UTC).""" return self._sunrise @property def sunset(self) -> datetime: """Provide the sunset information from the isy (UTC).""" return self._sunset @property def military(self) -> bool: """Confirm if military time is in use or not on the isy.""" return self._military PyISY-3.4.0/pyisy/configuration.py000077500000000000000000000062101477231106100170750ustar00rootroot00000000000000"""ISY Configuration Lookup.""" from __future__ import annotations from xml.dom import minidom from .constants import ( ATTR_DESC, ATTR_ID, TAG_DESC, TAG_FEATURE, TAG_FIRMWARE, TAG_INSTALLED, TAG_NAME, TAG_NODE_DEFS, TAG_PRODUCT, TAG_ROOT, TAG_VARIABLES, XML_TRUE, ) from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from .helpers import value_from_nested_xml, value_from_xml from .logging import _LOGGER class Configuration(dict): """ ISY Configuration class. DESCRIPTION: This class handles the ISY configuration. USAGE: This object may be used in a similar way as a dictionary with the either module names or ids being used as keys and a boolean indicating whether the module is installed will be returned. With the exception of 'firmware' and 'uuid', which will return their respective values. PARAMETERS: Portal Integration - Check-it.ca Gas Meter SEP ESP Water Meter Z-Wave RCS Zigbee Device Support Irrigation/ETo Module Electricity Monitor AMI Electricity Meter URL A10/X10 for INSTEON Portal Integration - GreenNet.com Networking Module OpenADR Current Cost Meter Weather Information Broadband SEP Device Portal Integration - BestBuy.com Elk Security System Portal Integration - MobiLinc NorthWrite NOC Module EXAMPLE: # configuration['Networking Module'] True # configuration['21040'] True """ def __init__(self, xml: str | None = None) -> None: """ Initialize configuration class. xml: String of xml data containing the configuration data """ super().__init__() if xml is not None: self.parse(xml) def parse(self, xml: str) -> None: """ Parse the xml data. xml: String of the xml data """ try: xmldoc = minidom.parseString(xml) except XML_ERRORS as exc: _LOGGER.error("%s: Configuration", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc self["firmware"] = value_from_xml(xmldoc, TAG_FIRMWARE) self["uuid"] = value_from_nested_xml(xmldoc, [TAG_ROOT, ATTR_ID]) self["name"] = value_from_nested_xml(xmldoc, [TAG_ROOT, TAG_NAME]) self["model"] = value_from_nested_xml(xmldoc, [TAG_PRODUCT, TAG_DESC], "ISY") self["variables"] = bool(value_from_xml(xmldoc, TAG_VARIABLES) == XML_TRUE) self["nodedefs"] = bool(value_from_xml(xmldoc, TAG_NODE_DEFS) == XML_TRUE) features = xmldoc.getElementsByTagName(TAG_FEATURE) for feature in features: idnum = value_from_xml(feature, ATTR_ID) desc = value_from_xml(feature, ATTR_DESC) installed_raw = value_from_xml(feature, TAG_INSTALLED) installed = bool(installed_raw == XML_TRUE) self[idnum] = installed self[desc] = self[idnum] _LOGGER.info("ISY Loaded Configuration") PyISY-3.4.0/pyisy/connection.py000066400000000000000000000276371477231106100164020ustar00rootroot00000000000000"""Connection to the ISY.""" from __future__ import annotations import asyncio import ssl from urllib.parse import quote, urlencode import aiohttp from .constants import ( METHOD_GET, URL_CLOCK, URL_CONFIG, URL_DEFINITIONS, URL_MEMBERS, URL_NETWORK, URL_NODES, URL_PING, URL_PROGRAMS, URL_RESOURCES, URL_STATUS, URL_SUBFOLDERS, URL_VARIABLES, VAR_INTEGER, VAR_STATE, XML_FALSE, XML_TRUE, ) from .exceptions import ISYConnectionError, ISYInvalidAuthError from .logging import _LOGGER, enable_logging MAX_HTTPS_CONNECTIONS_ISY = 2 MAX_HTTP_CONNECTIONS_ISY = 5 MAX_HTTPS_CONNECTIONS_IOX = 20 MAX_HTTP_CONNECTIONS_IOX = 50 MAX_RETRIES = 5 RETRY_BACKOFF = [0.01, 0.10, 0.25, 1, 2] # Seconds HTTP_OK = 200 # Valid request received, will run it HTTP_UNAUTHORIZED = 401 # User authentication failed HTTP_NOT_FOUND = 404 # Unrecognized request received and ignored HTTP_SERVICE_UNAVAILABLE = 503 # Valid request received, system too busy to run it HTTP_TIMEOUT = 30 HTTP_HEADERS = { "Connection": "keep-alive", "Keep-Alive": "5000", "Accept-Encoding": "gzip, deflate", } EMPTY_XML_RESPONSE = '' class Connection: """Connection object to manage connection to and interaction with ISY.""" def __init__( self, address: str, port: int, username: str, password: str, use_https: bool = False, tls_ver: float = 1.1, webroot: str = "", websession: aiohttp.ClientSession | None = None, ) -> None: """Initialize the Connection object.""" if len(_LOGGER.handlers) == 0: enable_logging(add_null_handler=True) self._address = address self._port = port self._username = username self._password = password self._auth = aiohttp.BasicAuth(self._username, self._password) self._webroot = webroot.rstrip("/") self.req_session = websession self._tls_ver = tls_ver self.use_https = use_https self._url = f"http{'s' if self.use_https else ''}://{self._address}:{self._port}{self._webroot}" self.semaphore = asyncio.Semaphore( MAX_HTTPS_CONNECTIONS_ISY if use_https else MAX_HTTP_CONNECTIONS_ISY ) if websession is None: websession = get_new_client_session(use_https, tls_ver) self.req_session = websession self.sslcontext = get_sslcontext(use_https, tls_ver) async def test_connection(self) -> str | None: """Test the connection and get the config for the ISY.""" config = await self.get_config(retries=None) if not config: _LOGGER.error("Could not connect to the ISY with the parameters provided.") raise ISYConnectionError return config def increase_available_connections(self) -> None: """Increase the number of allowed connections for newer hardware.""" _LOGGER.debug("Increasing available simultaneous connections") self.semaphore = asyncio.Semaphore( MAX_HTTPS_CONNECTIONS_IOX if self.use_https else MAX_HTTP_CONNECTIONS_IOX ) async def close(self) -> None: """Cleanup connections and prepare for exit.""" await self.req_session.close() @property def connection_info(self) -> dict[str, str | int | bytes | None]: """Return the connection info required to connect to the ISY.""" connection_info = {} connection_info["auth"] = self._auth.encode() connection_info["addr"] = self._address connection_info["port"] = int(self._port) connection_info["passwd"] = self._password connection_info["webroot"] = self._webroot if self.use_https and self._tls_ver: connection_info["tls"] = self._tls_ver return connection_info @property def url(self) -> str: """Return the full connection url.""" return self._url # COMMON UTILITIES def compile_url(self, path: list[str], query: str | None = None) -> str: """Compile the URL to fetch from the ISY.""" url = self.url if path is not None: url += "/rest/" + "/".join([quote(item) for item in path]) if query is not None: url += "?" + urlencode(query) return url async def request(self, url: str, retries: int = 0, ok404: bool = False, delay: int = 0) -> str | None: """Execute request to ISY REST interface.""" _LOGGER.debug("ISY Request: %s", url) if delay: await asyncio.sleep(delay) try: async with ( self.semaphore, self.req_session.get( url, auth=self._auth, headers=HTTP_HEADERS, timeout=HTTP_TIMEOUT, ssl=self.sslcontext, ) as res, ): endpoint = url.split("rest", 1)[1] if res.status == HTTP_OK: _LOGGER.debug("ISY Response Received: %s", endpoint) results = await res.text(encoding="utf-8", errors="ignore") if results != EMPTY_XML_RESPONSE: return results _LOGGER.debug("Invalid empty XML returned: %s", endpoint) res.release() if res.status == HTTP_NOT_FOUND: if ok404: _LOGGER.debug("ISY Response Received %s", endpoint) res.release() return "" _LOGGER.error("ISY Reported an Invalid Command Received %s", endpoint) res.release() return None if res.status == HTTP_UNAUTHORIZED: _LOGGER.error("Invalid credentials provided for ISY connection.") res.release() raise ISYInvalidAuthError("Invalid credentials provided for ISY connection.") if res.status == HTTP_SERVICE_UNAVAILABLE: _LOGGER.warning("ISY too busy to process request %s", endpoint) res.release() except asyncio.TimeoutError: _LOGGER.warning("Timeout while trying to connect to the ISY.") except ( aiohttp.ClientOSError, aiohttp.ServerDisconnectedError, ): _LOGGER.debug("ISY not ready or closed connection.") except aiohttp.ClientResponseError as err: _LOGGER.error("Client Response Error from ISY: %s %s.", err.status, err.message) except aiohttp.ClientError as err: _LOGGER.error( "ISY Could not receive response from device because of a network issue: %s", type(err), ) if retries is None: raise ISYConnectionError if retries < MAX_RETRIES: _LOGGER.debug( "Retrying ISY Request in %ss, retry %s.", RETRY_BACKOFF[retries], retries + 1, ) # sleep to allow the ISY to catch up await asyncio.sleep(RETRY_BACKOFF[retries]) # recurse to try again return await self.request(url, retries + 1, ok404=ok404) # fail for good _LOGGER.error( "Bad ISY Request: (%s) Failed after %s retries.", url, retries, ) return None async def ping(self) -> bool: """Test connection to the ISY and return True if alive.""" req_url = self.compile_url([URL_PING]) result = await self.request(req_url, ok404=True) return result is not None async def get_description(self) -> str | None: """Fetch the services description from the ISY.""" url = "https://" if self.use_https else "http://" url += f"{self._address}:{self._port}{self._webroot}/desc" return await self.request(url) async def get_config(self, retries: int = 0) -> str | None: """Fetch the configuration from the ISY.""" req_url = self.compile_url([URL_CONFIG]) return await self.request(req_url, retries=retries) async def get_programs(self, address: int | str | None = None) -> str | None: """Fetch the list of programs from the ISY.""" addr = [URL_PROGRAMS] if address is not None: addr.append(str(address)) req_url = self.compile_url(addr, {URL_SUBFOLDERS: XML_TRUE}) return await self.request(req_url) async def get_nodes(self) -> str | None: """Fetch the list of nodes/groups/scenes from the ISY.""" req_url = self.compile_url([URL_NODES], {URL_MEMBERS: XML_FALSE}) return await self.request(req_url) async def get_status(self) -> str | None: """Fetch the status of nodes/groups/scenes from the ISY.""" req_url = self.compile_url([URL_STATUS]) return await self.request(req_url) async def get_variable_defs(self) -> list[str | BaseException] | None: """Fetch the list of variables from the ISY.""" req_list = [ [URL_VARIABLES, URL_DEFINITIONS, VAR_INTEGER], [URL_VARIABLES, URL_DEFINITIONS, VAR_STATE], ] req_urls = [self.compile_url(req) for req in req_list] return await asyncio.gather(*[self.request(req_url) for req_url in req_urls], return_exceptions=True) async def get_variables(self) -> str | None: """Fetch the variable details from the ISY to update local copy.""" req_list = [ [URL_VARIABLES, METHOD_GET, VAR_INTEGER], [URL_VARIABLES, METHOD_GET, VAR_STATE], ] req_urls = [self.compile_url(req) for req in req_list] results = await asyncio.gather( *[self.request(req_url) for req_url in req_urls], return_exceptions=True ) results = [r for r in results if r is not None] # Strip any bad requests. result = "".join(results) return result.replace('', "") async def get_network(self) -> str | None: """Fetch the list of network resources from the ISY.""" req_url = self.compile_url([URL_NETWORK, URL_RESOURCES]) return await self.request(req_url) async def get_time(self) -> str | None: """Fetch the system time info from the ISY.""" req_url = self.compile_url([URL_CLOCK]) return await self.request(req_url) def get_new_client_session(use_https: bool, tls_ver: float = 1.1) -> aiohttp.ClientSession: """Create a new Client Session for Connecting.""" if use_https: if not can_https(tls_ver): raise (ValueError("PyISY could not connect to the ISY. Check log for SSL/TLS error.")) return aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) return aiohttp.ClientSession() def get_sslcontext(use_https: bool, tls_ver: float = 1.1) -> ssl.SSLContext | None: """Create an SSLContext object to use for the connections.""" if not use_https: return None if tls_ver == 1.1: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) elif tls_ver == 1.2: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) # Allow older ciphers for older ISYs context.set_ciphers("DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK") return context def can_https(tls_ver: float) -> bool: """ Verify minimum requirements to use an HTTPS connection. Returns boolean indicating whether HTTPS is available. """ output = True # check that Python was compiled against correct OpenSSL lib if "PROTOCOL_TLSv1_1" not in dir(ssl): _LOGGER.error("PyISY cannot use HTTPS: Compiled against old OpenSSL library. See docs.") output = False # check the requested TLS version if tls_ver not in [1.1, 1.2]: _LOGGER.error("PyISY cannot use HTTPS: Only TLS 1.1 and 1.2 are supported by the ISY controller.") output = False return output PyISY-3.4.0/pyisy/constants.py000066400000000000000000000737141477231106100162540ustar00rootroot00000000000000"""Constants for the PyISY Module.""" from __future__ import annotations import datetime UPDATE_INTERVAL = 0.5 # Time Constants / Strings EMPTY_TIME = datetime.datetime(year=1, month=1, day=1) ISY_EPOCH_OFFSET = 36524 MILITARY_TIME = "%Y/%m/%d %H:%M:%S" STANDARD_TIME = "%Y/%m/%d %I:%M:%S %p" XML_STRPTIME = "%Y%m%d %H:%M:%S" XML_STRPTIME_YY = "%y%m%d %H:%M:%S" XML_TRUE = "true" XML_FALSE = "false" XML_ON = "" XML_OFF = "" POLL_TIME = 10 RECONNECT_DELAY = 60 SOCKET_BUFFER_SIZE = 4096 THREAD_SLEEP_TIME = 30.0 ES_LOST_STREAM_CONNECTION = "lost_stream_connection" ES_CONNECTED = "connected" ES_DISCONNECTED = "disconnected" ES_START_UPDATES = "start_updates" ES_STOP_UPDATES = "stop_updates" ES_INITIALIZING = "stream_initializing" ES_LOADED = "stream_loaded" ES_RECONNECT_FAILED = "reconnect_failed" ES_RECONNECTING = "reconnecting" ES_DISCONNECTING = "stream_disconnecting" ES_NOT_STARTED = "not_started" ISY_VALUE_UNKNOWN = -1 * float("inf") ISY_PROP_NOT_SET = "-1" # Dictionary of X10 commands. X10_COMMANDS = {"all_off": 1, "all_on": 4, "on": 3, "off": 11, "bright": 7, "dim": 15} ACTION_EVENT_STATUS = "0" ACTION_GET_STATUS = "1" ACTION_KEY_CHANGED = "2" ACTION_INFO_STRING = "3" ACTION_IR_LEARN = "4" ACTION_SCHEDULE = "5" ACTION_VAR_STATUS = "6" ACTION_VAR_INIT = "7" ACTION_KEY = "8" ATTR_ACTION = "action" ATTR_CONTROL = "control" ATTR_DESC = "desc" ATTR_FLAG = "flag" ATTR_FORMATTED = "formatted" ATTR_ID = "id" ATTR_INIT = "init" ATTR_INSTANCE = "instance" ATTR_LAST_CHANGED = "last_changed" ATTR_LAST_UPDATE = "last_update" ATTR_NODE_DEF_ID = "nodeDefId" ATTR_PARENT = "parentId" ATTR_PRECISION = "prec" ATTR_SET = "set" ATTR_STATUS = "status" ATTR_STREAM_ID = "sid" ATTR_TS = "ts" ATTR_UNIT_OF_MEASURE = "uom" ATTR_VAL = "val" # Used for Variables. ATTR_VALUE = "value" # Used for everything else. ATTR_VAR = "var" DEFAULT_PRECISION = "0" DEFAULT_UNIT_OF_MEASURE = "" CONFIG_NETWORKING = "Networking Module" CONFIG_PORTAL = "Portal Integration - UDI" TAG_ADDRESS = "address" TAG_CATEGORY = "cat" TAG_CONFIG = "config" TAG_DESC = "desc" TAG_DESCRIPTION = "description" TAG_DEVICE_TYPE = "devtype" TAG_DST = "DST" TAG_ENABLED = "enabled" TAG_EVENT_INFO = "eventInfo" TAG_FAMILY = "family" TAG_FEATURE = "feature" TAG_FIRMWARE = "app_full_version" TAG_FOLDER = "folder" TAG_FORMATTED = "fmtAct" TAG_GENERIC = "gen" TAG_GROUP = "group" TAG_INSTALLED = "isInstalled" TAG_IS_LOAD = "isLoad" TAG_LATITUDE = "Lat" TAG_LINK = "link" TAG_LOCATION = "location" TAG_LONGITUDE = "Long" TAG_MFG = "mfg" TAG_MILIATRY_TIME = "IsMilitary" TAG_NAME = "name" TAG_NET_RULE = "NetRule" TAG_NODE = "node" TAG_NODE_DEFS = "nodedefs" TAG_NTP = "NTP" TAG_PARENT = "parent" TAG_PARAMETER = "parameter" TAG_PRGM_FINISH = "f" TAG_PRGM_RUN = "r" TAG_PRGM_RUNNING = "running" TAG_PRGM_STATUS = "s" TAG_PRIMARY_NODE = "pnode" TAG_PRODUCT = "product" TAG_PROGRAM = "program" TAG_PROPERTY = "property" TAG_ROOT = "root" TAG_SIZE = "size" TAG_SPOKEN = "spoken" TAG_SUNRISE = "Sunrise" TAG_SUNSET = "Sunset" TAG_TYPE = "type" TAG_TZ_OFFSET = "TMZOffset" TAG_VALUE = "value" TAG_VARIABLE = "e" TAG_VARIABLES = "variables" PROTO_FOLDER = "program_folder" PROTO_GROUP = "group" PROTO_INSTEON = "insteon" PROTO_INT_VAR = "integer_variable" PROTO_ISY = "isy" PROTO_NETWORK = "network" PROTO_NODE_SERVER = "node_server" PROTO_PROGRAM = "program" PROTO_STATE_VAR = "state_variable" PROTO_ZIGBEE = "zigbee" PROTO_ZWAVE = "zwave" FAMILY_CORE = "0" FAMILY_INSTEON = "1" FAMILY_UPB = "2" FAMILY_RCS = "3" FAMILY_ZWAVE = "4" FAMILY_AUTO = "5" FAMILY_GENERIC = "6" FAMILY_UDI = "7" FAMILY_BRULTECH = "8" FAMILY_NCD = "9" FAMILY_NODESERVER = "10" FAMILY_ZMATTER_ZWAVE = "12" PROP_BATTERY_LEVEL = "BATLVL" PROP_BUSY = "BUSY" PROP_COMMS_ERROR = "ERR" PROP_ENERGY_MODE = "CLIEMD" PROP_HEAT_COOL_STATE = "CLIHCS" PROP_HUMIDITY = "CLIHUM" PROP_ON_LEVEL = "OL" PROP_RAMP_RATE = "RR" PROP_SCHEDULE_MODE = "CLISMD" PROP_SETPOINT_COOL = "CLISPC" PROP_SETPOINT_HEAT = "CLISPH" PROP_STATUS = "ST" PROP_TEMPERATURE = "CLITEMP" PROP_UOM = "UOM" PROP_ZWAVE_PREFIX = "ZW_" METHOD_COMMAND = "cmd" METHOD_GET = "get" METHOD_SET = "set" URL_CHANGE = "change" URL_CLOCK = "time" URL_CONFIG = "config" URL_DEFINITIONS = "definitions" URL_MEMBERS = "members" URL_NETWORK = "networking" URL_NODE = "node" URL_NODES = "nodes" URL_NOTES = "notes" URL_PING = "ping" URL_PROGRAMS = "programs" URL_QUERY = "query" URL_RESOURCES = "resources" URL_STATUS = "status" URL_SUBFOLDERS = "subfolders" URL_VARIABLES = "vars" URL_ZWAVE = "zwave" URL_PROFILE_NS = "profiles/ns" URL_ZMATTER_ZWAVE = "zmatter/zwave" VAR_INTEGER = "1" VAR_STATE = "2" CLIMATE_SETPOINT_MIN_GAP = 2 CMD_BACKLIGHT = "BL" CMD_BEEP = "BEEP" CMD_BRIGHTEN = "BRT" CMD_CLIMATE_FAN_SETTING = "CLIFS" CMD_CLIMATE_MODE = "CLIMD" CMD_DIM = "DIM" CMD_DISABLE = "disable" CMD_DISABLE_RUN_AT_STARTUP = "disableRunAtStartup" CMD_ENABLE = "enable" CMD_ENABLE_RUN_AT_STARTUP = "enableRunAtStartup" CMD_FADE_DOWN = "FDDOWN" CMD_FADE_STOP = "FDSTOP" CMD_FADE_UP = "FDUP" CMD_MANUAL_DIM_BEGIN = "BMAN" # Depreciated, use Fade CMD_MANUAL_DIM_STOP = "SMAN" # Depreciated, use Fade CMD_MODE = "MODE" CMD_OFF = "DOF" CMD_OFF_FAST = "DFOF" CMD_ON = "DON" CMD_ON_FAST = "DFON" CMD_RESET = "RESET" CMD_RUN = "run" CMD_RUN_ELSE = "runElse" CMD_RUN_THEN = "runThen" CMD_SECURE = "SECMD" CMD_STOP = "stop" CMD_X10 = "X10" COMMAND_FRIENDLY_NAME = { "ADRPST": "auto_dr_processing_state", "AIRFLOW": "air_flow", "ALARM": "alarm", "ANGLE": "angle_position", "ANGLPOS": "angle_position", "ATMPRES": "atmospheric_pressure", "AWAKE": "awake", "BARPRES": "barometric_pressure", "CC": "current", "CLIFRS": "fan_running_state", "CLIFSO": "fan_setting_override", "CO2LVL": "co2_level", "CPW": "power", "CTL": "controller_action", "CV": "voltage", "DELAY": "delay", "DEWPT": "dew_point", "DISTANC": "distance", "DOF3": "off_3x_key_presses", "DOF4": "off_4x_key_presses", "DOF5": "off_5x_key_presses", "DON3": "on_3x_key_presses", "DON4": "on_4x_key_presses", "DON5": "on_5x_key_presses", "ELECCON": "electrical_conductivity", "ELECRES": "electrical_resistivity", PROP_COMMS_ERROR: "device_communication_errors", "ETO": "evapotranspiration", "FATM": "fat_mass", "FREQ": "frequency", "GPV": "general_purpose", "GUST": "gust", "GV0": "custom_control_0", "GV1": "custom_control_1", "GV2": "custom_control_2", "GV3": "custom_control_3", "GV4": "custom_control_4", "GV5": "custom_control_5", "GV6": "custom_control_6", "GV7": "custom_control_7", "GV8": "custom_control_8", "GV9": "custom_control_9", "GV10": "custom_control_10", "GV11": "custom_control_11", "GV12": "custom_control_12", "GV13": "custom_control_13", "GV14": "custom_control_14", "GV15": "custom_control_15", "GV16": "custom_control_16", "GV17": "custom_control_17", "GV18": "custom_control_18", "GV19": "custom_control_19", "GV20": "custom_control_20", "GV21": "custom_control_21", "GV22": "custom_control_22", "GV23": "custom_control_23", "GV24": "custom_control_24", "GV25": "custom_control_25", "GV26": "custom_control_26", "GV27": "custom_control_27", "GV28": "custom_control_28", "GV29": "custom_control_29", "GV30": "custom_control_30", "GVOL": "gas_volume", "HAIL": "hail", "HEATIX": "heat_index", "HR": "heart_rate", "LUMIN": "luminance", "METHANE": "methane_density", "MOIST": "moisture", "MOON": "moon_phase", "MUSCLEM": "muscle_mass", "OZONE": "ozone", "PCNT": "pulse_count", "PF": "power_factor", "PM10": "particulate_matter_10", "PM25": "particulate_matter_2.5", "POP": "percent_chance_of_precipitation", "PPW": "polarized_power", "PRECIP": "precipitation", "PULSCNT": "pulse_count", "RADON": "radon_concentration", "RAINRT": "rain_rate", "RELMOD": "relative_modulation_level", "RESPR": "respiratory_rate", "RFSS": "rf_signal_strength", "ROTATE": "rotation", "RR": "ramp_rate", "SEISINT": "seismic_intensity", "SEISMAG": "seismic_magnitude", "SMOKED": "smoke_density", "SOILH": "soil_humidity", "SOILR": "soil_reactivity", "SOILS": "soil_salinity", "SOILT": "soil_temperature", "SOLRAD": "solar_radiation", "SPEED": "speed", "SVOL": "sound_volume", "TANKCAP": "tank_capacity", "TEMPEXH": "exhaust_temperature", "TEMPOUT": "outside_temperature", "TIDELVL": "tide_level", "TIME": "time", "TIMEREM": "time_remaining", "TPW": "total_energy_used", "UAC": "user_number", "USRNUM": "user_number", "UV": "uv_light", "VOCLVL": "voc_level", "WATERF": "water_flow", "WATERP": "water_pressure", "WATERT": "water_temperature", "WATERTB": "boiler_water_temperature", "WATERTD": "domestic_hot_water_temperature", "WEIGHT": "weight", "WINDCH": "wind_chill", "WINDDIR": "wind_direction", "WVOL": "water_volume", CMD_BACKLIGHT: "backlight", CMD_BEEP: "beep", CMD_BRIGHTEN: "bright", CMD_CLIMATE_FAN_SETTING: "fan_state", CMD_CLIMATE_MODE: "climate_mode", CMD_DIM: "dim", CMD_FADE_DOWN: "fade_down", CMD_FADE_STOP: "fade_stop", CMD_FADE_UP: "fade_up", CMD_MANUAL_DIM_BEGIN: "brighten_manual", CMD_MANUAL_DIM_STOP: "stop_manual", CMD_MODE: "mode", CMD_OFF: "off", CMD_OFF_FAST: "fastoff", CMD_ON: "on", CMD_ON_FAST: "faston", CMD_RESET: "reset", CMD_SECURE: "secure", CMD_X10: "x10_command", PROP_BATTERY_LEVEL: "battery_level", PROP_BUSY: "busy", PROP_ENERGY_MODE: "energy_saving_mode", PROP_HEAT_COOL_STATE: "heat_cool_state", PROP_HUMIDITY: "humidity", PROP_ON_LEVEL: "on_level", PROP_SCHEDULE_MODE: "schedule_mode", PROP_SETPOINT_COOL: "cool_setpoint", PROP_SETPOINT_HEAT: "heat_setpoint", PROP_STATUS: "status", PROP_TEMPERATURE: "temperature", PROP_UOM: "unit_of_measure", } EVENT_PROPS_IGNORED = [ CMD_BEEP, CMD_BRIGHTEN, CMD_DIM, CMD_MANUAL_DIM_BEGIN, CMD_MANUAL_DIM_STOP, CMD_FADE_UP, CMD_FADE_DOWN, CMD_FADE_STOP, CMD_OFF, CMD_OFF_FAST, CMD_ON, CMD_ON_FAST, CMD_RESET, CMD_X10, PROP_BUSY, PROP_STATUS, ] COMMAND_NAME = {val: key for key, val in COMMAND_FRIENDLY_NAME.items()} # Referenced from ISY-WSDK-5.0.4\WSDL\family.xsd NODE_FAMILY_ID = { FAMILY_CORE: "Default", FAMILY_INSTEON: "Insteon", FAMILY_UPB: "UPB", FAMILY_RCS: "RCS", FAMILY_ZWAVE: "Z-Wave", FAMILY_AUTO: "Auto_DR", FAMILY_GENERIC: "Group", FAMILY_UDI: "UDI", FAMILY_BRULTECH: "Brultech", FAMILY_NCD: "NCD", FAMILY_NODESERVER: "Node_Server", FAMILY_ZMATTER_ZWAVE: "ZMatter_Z-Wave", } UOM_CLIMATE_MODES = "98" UOM_CLIMATE_MODES_ZWAVE = "67" UOM_DOUBLE_TEMP = "101" UOM_FAN_MODES = "99" UOM_INDEX = "25" UOM_PERCENTAGE = "51" UOM_SECONDS = "57" UOM_FRIENDLY_NAME = { "1": "A", "2": "", # Binary / On-Off "3": "btu/h", "4": "°C", "5": "cm", "6": "ft³", "7": "ft³/min", "8": "m³", "9": "day", "10": "days", "12": "dB", "13": "dB A", "14": "°", "16": "macroseismic", "17": "°F", "18": "ft", "19": "hour", "20": "hours", "21": "%AH", "22": "%RH", "23": "inHg", "24": "in/hr", UOM_INDEX: "index", "26": "K", "27": "keyword", "28": "kg", "29": "kV", "30": "kW", "31": "kPa", "32": "KPH", "33": "kWh", "34": "liedu", "35": "L", "36": "lx", "37": "mercalli", "38": "m", "39": "m³/hr", "40": "m/s", "41": "mA", "42": "ms", "43": "mV", "44": "min", "45": "min", "46": "mm/hr", "47": "month", "48": "MPH", "49": "m/s", "50": "Ω", UOM_PERCENTAGE: "%", "52": "lbs", "53": "pf", "54": "ppm", "55": "pulse count", "57": "s", "58": "s", "59": "S/m", "60": "m_b", "61": "M_L", "62": "M_w", "63": "M_S", "64": "shindo", "65": "SML", "69": "gal", "71": "UV index", "72": "V", "73": "W", "74": "W/m²", "75": "weekday", "76": "°", "77": "year", "82": "mm", "83": "km", "85": "Ω", "86": "kΩ", "87": "m³/m³", "88": "Water activity", "89": "RPM", "90": "Hz", "91": "°", "92": "° South", "100": "", "101": "° (x2)", "102": "kWs", "103": "$", "104": "¢", "105": "in", "106": "mm/day", "107": "", # raw 1-byte unsigned value "108": "", # raw 2-byte unsigned value "109": "", # raw 3-byte unsigned value "110": "", # raw 4-byte unsigned value "111": "", # raw 1-byte signed value "112": "", # raw 2-byte signed value "113": "", # raw 3-byte signed value "114": "", # raw 4-byte signed value "116": "mi", "117": "mbar", "118": "hPa", "119": "Wh", "120": "in/day", } UOM_TO_STATES = { "11": { # Deadbolt Status "0": "unlocked", "100": "locked", "101": "unknown", "102": "problem", }, "15": { # Door Lock Alarm "1": "master code changed", "2": "tamper code entry limit", "3": "escutcheon removed", "4": "key-manually locked", "5": "locked by touch", "6": "key-manually unlocked", "7": "remote locking jammed bolt", "8": "remotely locked", "9": "remotely unlocked", "10": "deadbolt jammed", "11": "battery too low to operate", "12": "critical low battery", "13": "low battery", "14": "automatically locked", "15": "automatic locking jammed bolt", "16": "remotely power cycled", "17": "lock handling complete", "19": "user deleted", "20": "user added", "21": "duplicate pin", "22": "jammed bolt by locking with keypad", "23": "locked by keypad", "24": "unlocked by keypad", "25": "keypad attempt outside schedule", "26": "hardware failure", "27": "factory reset", "28": "manually not fully locked", "29": "all user codes deleted", "30": "new user code not added-duplicate code", "31": "keypad temporarily disabled", "32": "keypad busy", "33": "new program code entered", "34": "rf unlock with invalid user code", "35": "rf lock with invalid user codes", "36": "window-door is open", "37": "window-door is closed", "38": "window-door handle is open", "39": "window-door handle is closed", "40": "user code entered on keypad", "41": "power cycled", }, "66": { # Thermostat Heat/Cool State "0": "idle", "1": "heating", "2": "cooling", "3": "fan_only", "4": "pending heat", "5": "pending cool", "6": "vent", "7": "aux heat", "8": "2nd stage heating", "9": "2nd stage cooling", "10": "2nd stage aux heat", "11": "3rd stage aux heat", }, "67": { # Thermostat Mode "0": "off", "1": "heat", "2": "cool", "3": "auto", "4": "aux/emergency heat", "5": "resume", "6": "fan_only", "7": "furnace", "8": "dry air", "9": "moist air", "10": "auto changeover", "11": "energy save heat", "12": "energy save cool", "13": "away", "14": "program auto", "15": "program heat", "16": "program cool", }, "68": { # Thermostat Fan Mode "0": "auto", "1": "on", "2": "auto high", "3": "high", "4": "auto medium", "5": "medium", "6": "circulation", "7": "humidity circulation", "8": "left-right circulation", "9": "up-down circulation", "10": "quiet", }, "78": {"0": "off", "100": "on"}, # 0-Off 100-On "79": {"0": "open", "100": "closed"}, # 0-Open 100-Close "80": { # Thermostat Fan Run State "0": "off", "1": "on", "2": "on high", "3": "on medium", "4": "circulation", "5": "humidity circulation", "6": "right/left circulation", "7": "up/down circulation", "8": "quiet circulation", }, "84": {"0": "unlock", "1": "lock"}, # Secure Mode "93": { # Power Management Alarm "1": "power applied", "2": "ac mains disconnected", "3": "ac mains reconnected", "4": "surge detection", "5": "volt drop or drift", "6": "over current detected", "7": "over voltage detected", "8": "over load detected", "9": "load error", "10": "replace battery soon", "11": "replace battery now", "12": "battery is charging", "13": "battery is fully charged", "14": "charge battery soon", "15": "charge battery now", }, "94": { # Appliance Alarm "1": "program started", "2": "program in progress", "3": "program completed", "4": "replace main filter", "5": "failure to set target temperature", "6": "supplying water", "7": "water supply failure", "8": "boiling", "9": "boiling failure", "10": "washing", "11": "washing failure", "12": "rinsing", "13": "rinsing failure", "14": "draining", "15": "draining failure", "16": "spinning", "17": "spinning failure", "18": "drying", "19": "drying failure", "20": "fan failure", "21": "compressor failure", }, "95": { # Home Health Alarm "1": "leaving bed", "2": "sitting on bed", "3": "lying on bed", "4": "posture changed", "5": "sitting on edge of bed", }, "96": { # VOC Level "1": "clean", "2": "slightly polluted", "3": "moderately polluted", "4": "highly polluted", }, "97": { # Barrier Status "0": "closed", "100": "open", "101": "unknown", "102": "stopped", "103": "closing", "104": "opening", **{str(b): f"{b} %" for a, b in enumerate(list(range(1, 100)))}, # 1-99 are percentage open }, "98": { # Insteon Thermostat Mode "0": "off", "1": "heat", "2": "cool", "3": "auto", "4": "fan_only", "5": "program_auto", "6": "program_heat", "7": "program_cool", }, "99": {"7": "on", "8": "auto"}, # Insteon Thermostat Fan Mode "115": { # Most recent On style action taken for lamp control "0": "on", "1": "off", "2": "fade up", "3": "fade down", "4": "fade stop", "5": "fast on", "6": "fast off", "7": "triple press on", "8": "triple press off", "9": "4x press on", "10": "4x press off", "11": "5x press on", "12": "5x press off", }, } # Translate the "RR" Property to Seconds INSTEON_RAMP_RATES = { "0": 540, "1": 480, "2": 420, "3": 360, "4": 300, "5": 270, "6": 240, "7": 210, "8": 180, "9": 150, "10": 120, "11": 90, "12": 60, "13": 47, "14": 43, "15": 38.5, "16": 34, "17": 32, "18": 30, "19": 28, "20": 26, "21": 23.5, "22": 21.5, "23": 19, "24": 8.5, "25": 6.5, "26": 4.5, "27": 2, "28": 0.5, "29": 0.3, "30": 0.2, "31": 0.1, } # Thermostat Types/Categories. 4.8 Trane, 5.3 venstar, 5.10 Insteon Wireless, # 5.0x0B, 0x0F, 0x10, 0x13, 0x14, 0x15 - Insteon (alt. frequencies) INSTEON_TYPE_THERMOSTAT = [ "4.8", "5.3", "5.10", "5.11", "5.14", "5.15", "5.16", "5.17", "5.18", "5.19", "5.20", "5.21", ] INSTEON_TYPE_THERMOSTAT_TUP = tuple(INSTEON_TYPE_THERMOSTAT) ZWAVE_CAT_THERMOSTAT = ["140"] # Other special categories or types INSTEON_TYPE_LOCK = ["15.", "4.64"] INSTEON_TYPE_LOCK_TUP = tuple(INSTEON_TYPE_LOCK) ZWAVE_CAT_LOCK = ["111"] INSTEON_TYPE_DIMMABLE = ["1."] INSTEON_TYPE_DIMMABLE_TUP = tuple(INSTEON_TYPE_DIMMABLE) INSTEON_SUBNODE_DIMMABLE = " 1" ZWAVE_CAT_DIMMABLE = ["109", "119", "186"] # Insteon Battery Devices - States are ignored when checking the status of a group. INSTEON_STATELESS_TYPE = ["0.16.", "0.17.", "0.18.", "16."] # Not Used INSTEON_STATELESS_NODEDEFID = [ "BinaryAlarm", "BinaryAlarm_ADV", "BinaryControl", "BinaryControl_ADV", "RemoteLinc2", "RemoteLinc2_ADV", "DimmerSwitchOnly", ] # Referenced from ISY-WSDK 4_fam.xml # Included for user translations in external modules. # This is the Node.zwave_props.category property. DEVTYPE_CATEGORIES = { "0": "uninitialized", "101": "unknown", "102": "alarm", "103": "av control point", "104": "binary sensor", "105": "class a motor control", "106": "class b motor control", "107": "class c motor control", "108": "controller", "109": "dimmer switch", "110": "display", "111": "door lock", "112": "doorbell", "113": "entry control", "114": "gateway", "115": "installer tool", "116": "motor multiposition", "117": "climate sensor", "118": "multilevel sensor", "119": "multilevel switch", "120": "on/off power strip", "121": "on/off power switch", "122": "on/off scene switch", "123": "open/close valve", "124": "pc controller", "125": "remote", "126": "remote control", "127": "av remote control", "128": "simple remote control", "129": "repeater", "130": "residential hrv", "131": "satellite receiver", "132": "satellite receiver", "133": "scene controller", "134": "scene switch", "135": "security panel", "136": "set-top box", "137": "siren", "138": "smoke alarm", "139": "subsystem controller", "140": "thermostat", "141": "toggle", "142": "television", "143": "energy meter", "144": "pulse meter", "145": "water meter", "146": "gas meter", "147": "binary switch", "148": "binary alarm", "149": "aux alarm", "150": "co2 alarm", "151": "co alarm", "152": "freeze alarm", "153": "glass break alarm", "154": "heat alarm", "155": "motion sensor", "156": "smoke alarm", "157": "tamper alarm", "158": "tilt alarm", "159": "water alarm", "160": "door/window alarm", "161": "test alarm", "162": "low battery alarm", "163": "co end of life alarm", "164": "malfunction alarm", "165": "heartbeat", "166": "overheat alarm", "167": "rapid temp rise alarm", "168": "underheat alarm", "169": "leak detected alarm", "170": "level drop alarm", "171": "replace filter alarm", "172": "intrusion alarm", "173": "tamper code alarm", "174": "hardware failure alarm", "175": "software failure alarm", "176": "contact police alarm", "177": "contact fire alarm", "178": "contact medical alarm", "179": "wakeup alarm", "180": "timer", "181": "power management", "182": "appliance", "183": "home health", "184": "barrier", "185": "notification sensor", "186": "color switch", "187": "multilevel switch off on", "188": "multilevel switch down up", "189": "multilevel switch close open", "190": "multilevel switch ccw cw", "191": "multilevel switch left right", "192": "multilevel switch reverse forward", "193": "multilevel switch pull push", "194": "basic set", "195": "wall controller", "196": "barrier handle", "197": "sound switch", } # Referenced from ISY-WSDK cat.xml # Included for user translations in external modules. # This is the first part of the Node.type property (before the first ".") NODE_CATEGORIES = { "0": "generic controller", "1": "dimming control", "2": "switch/relay control", "3": "network bridge", "4": "irrigation control", "5": "climate control", "6": "pool control", "7": "sensors/actuators", "8": "home entertainment", "9": "energy management", "10": "appliance control", "11": "plumbing", "12": "communications", "13": "computer", "14": "windows/shades", "15": "access control", "16": "security/health/safety", "17": "surveillance", "18": "automotive", "19": "pet care", "20": "toys", "21": "timers/clocks", "22": "holiday", "113": "a10/x10", "127": "virtual", "254": "unknown", } # Node Change Actions NC_CLEAR_ERROR = "CE" NC_FOLDER_ADDED = "FD" NC_FOLDER_REMOVED = "FR" NC_FOLDER_RENAMED = "FN" NC_GROUP_ADDED = "GD" NC_GROUP_REMOVED = "GR" NC_GROUP_RENAMED = "GN" NC_NET_RENAMED = "WR" NC_NODE_ADDED = "ND" NC_NODE_ENABLED = "EN" NC_NODE_ERROR = "NE" NC_NODE_MOVED = "MV" NC_NODE_REMOVE_FROM_GROUP = "RG" NC_NODE_REMOVED = "NR" NC_NODE_RENAMED = "NN" NC_NODE_REVISED = "RV" NC_PARENT_CHANGED = "PC" NC_PENDING_DEVICE_OP = "WH" NC_PROGRAMMING_DEVICE = "WD" DEV_WRITING = "_7A" DEV_MEMORY = "_7M" # Node Change Code: (Description, EventInfo Tags) NODE_CHANGED_ACTIONS = { NC_CLEAR_ERROR: ("Node Comm. Errors Cleared", []), NC_FOLDER_ADDED: ("Folder Added", []), NC_FOLDER_REMOVED: ("Folder Removed", []), NC_FOLDER_RENAMED: ("Folder Renamed", ["newName"]), NC_GROUP_ADDED: ("Group Added", ["groupName", "groupType"]), NC_GROUP_REMOVED: ("Group Removed", []), NC_GROUP_RENAMED: ("Group Renamed", ["newName"]), NC_NET_RENAMED: ("Network Renamed", []), NC_NODE_ADDED: ("Node Added", ["nodeName", "nodeType"]), NC_NODE_ENABLED: ("Enabled/Disabled", ["enabled"]), NC_NODE_ERROR: ("Node Comm. Errors", []), NC_NODE_MOVED: ("Node moved into a Scene", ["movedNode", "linkType"]), NC_NODE_REMOVE_FROM_GROUP: ("Removed from Group (Scene)", ["removedNode"]), NC_NODE_REMOVED: ("Node Removed", []), NC_NODE_RENAMED: ("Node Renamed", ["newName"]), NC_NODE_REVISED: ("Node Revised (UPB)", []), NC_PARENT_CHANGED: ("Parent Changed", ["node", "nodeType", "parent", "parentType"]), NC_PENDING_DEVICE_OP: ("Pending Device Operation", []), NC_PROGRAMMING_DEVICE: ("Programming Device", []), DEV_WRITING: ("Progress Report", ["message"]), DEV_MEMORY: ("Memory Write", ["memory", "cmd1", "cmd2", "value"]), } SYSTEM_NOT_BUSY = "0" SYSTEM_BUSY = "1" SYSTEM_IDLE = "2" SYSTEM_SAFE_MODE = "3" SYSTEM_STATUS = { SYSTEM_NOT_BUSY: "Not Busy", SYSTEM_BUSY: "Busy", SYSTEM_IDLE: "Idle", SYSTEM_SAFE_MODE: "Safe Mode", } # Node Link Types NODE_IS_CONTROLLER = 0x10 # Node operations flags NODE_IS_INIT = 0x01 # needs to be initialized NODE_TO_SCAN = 0x02 # needs to be scanned NODE_IS_A_GROUP = 0x04 # it’s a group! NODE_IS_ROOT = 0x08 # it’s the root group NODE_IS_IN_ERR = 0x10 # it’s in error! NODE_IS_NEW = 0x20 # brand new node NODE_TO_DELETE = 0x40 # has to be deleted later NODE_IS_DEVICE_ROOT = 0x80 # root device such as KPL load DEV_CMD_MEMORY_WRITE = "0x2E" DEV_BL_ADDR = "0x0264" DEV_OL_ADDR = "0x0032" DEV_RR_ADDR = "0x0021" BACKLIGHT_SUPPORT = { "DimmerMotorSwitch": UOM_PERCENTAGE, "DimmerMotorSwitch_ADV": UOM_PERCENTAGE, "DimmerLampSwitch": UOM_PERCENTAGE, "DimmerLampSwitch_ADV": UOM_PERCENTAGE, "DimmerSwitchOnly": UOM_PERCENTAGE, "DimmerSwitchOnly_ADV": UOM_PERCENTAGE, "KeypadDimmer": UOM_INDEX, "KeypadDimmer_ADV": UOM_INDEX, "RelayLampSwitch": UOM_PERCENTAGE, "RelayLampSwitch_ADV": UOM_PERCENTAGE, "RelaySwitchOnlyPlusQuery": UOM_PERCENTAGE, "RelaySwitchOnlyPlusQuery_ADV": UOM_PERCENTAGE, "RelaySwitchOnly": UOM_PERCENTAGE, "RelaySwitchOnly_ADV": UOM_PERCENTAGE, "KeypadRelay": UOM_INDEX, "KeypadRelay_ADV": UOM_INDEX, "KeypadButton": UOM_INDEX, "KeypadButton_ADV": UOM_INDEX, } BACKLIGHT_INDEX = [ "On 0 / Off 0", "On 1 / Off 0", "On 2 / Off 0", "On 3 / Off 0", "On 4 / Off 0", "On 5 / Off 0", "On 6 / Off 0", "On 7 / Off 0", "On 8 / Off 0", "On 9 / Off 0", "On 10 / Off 0", "On 11 / Off 0", "On 12 / Off 0", "On 13 / Off 0", "On 14 / Off 0", "On 15 / Off 0", "On 0 / Off 1", "On 1 / Off 1", "On 2 / Off 1", "On 3 / Off 1", "On 4 / Off 1", "On 5 / Off 1", "On 6 / Off 1", "On 7 / Off 1", "On 8 / Off 1", "On 9 / Off 1", "On 10 / Off 1", "On 11 / Off 1", "On 12 / Off 1", "On 13 / Off 1", "On 14 / Off 1", "On 15 / Off 1", "On 0 / Off 2", "On 1 / Off 2", "On 2 / Off 2", "On 3 / Off 2", "On 4 / Off 2", "On 5 / Off 2", "On 6 / Off 2", "On 7 / Off 2", "On 8 / Off 2", "On 9 / Off 2", "On 10 / Off 2", "On 11 / Off 2", "On 12 / Off 2", "On 13 / Off 2", "On 14 / Off 2", "On 15 / Off 2", "On 0 / Off 3", "On 1 / Off 3", "On 2 / Off 3", "On 3 / Off 3", "On 4 / Off 3", "On 5 / Off 3", "On 6 / Off 3", "On 7 / Off 3", "On 8 / Off 3", "On 9 / Off 3", "On 10 / Off 3", "On 11 / Off 3", "On 12 / Off 3", "On 13 / Off 3", "On 14 / Off 3", "On 15 / Off 3", "On 0 / Off 4", "On 1 / Off 4", "On 2 / Off 4", "On 3 / Off 4", "On 4 / Off 4", "On 5 / Off 4", "On 6 / Off 4", "On 7 / Off 4", "On 8 / Off 4", "On 9 / Off 4", "On 10 / Off 4", "On 11 / Off 4", "On 12 / Off 4", "On 13 / Off 4", "On 14 / Off 4", "On 15 / Off 4", "On 0 / Off 5", "On 1 / Off 5", "On 2 / Off 5", "On 3 / Off 5", "On 4 / Off 5", "On 5 / Off 5", "On 6 / Off 5", "On 7 / Off 5", "On 8 / Off 5", "On 9 / Off 5", "On 10 / Off 5", "On 11 / Off 5", "On 12 / Off 5", "On 13 / Off 5", "On 14 / Off 5", "On 15 / Off 5", "On 0 / Off 6", "On 1 / Off 6", "On 2 / Off 6", "On 3 / Off 6", "On 4 / Off 6", "On 5 / Off 6", "On 6 / Off 6", "On 7 / Off 6", "On 8 / Off 6", "On 9 / Off 6", "On 10 / Off 6", "On 11 / Off 6", "On 12 / Off 6", "On 13 / Off 6", "On 14 / Off 6", "On 15 / Off 6", "On 0 / Off 7", "On 1 / Off 7", "On 2 / Off 7", "On 3 / Off 7", "On 4 / Off 7", "On 5 / Off 7", "On 6 / Off 7", "On 7 / Off 7", "On 8 / Off 7", "On 9 / Off 7", "On 10 / Off 7", "On 11 / Off 7", "On 12 / Off 7", "On 13 / Off 7", "On 14 / Off 7", "On 15 / Off 7", ] PyISY-3.4.0/pyisy/events/000077500000000000000000000000001477231106100151565ustar00rootroot00000000000000PyISY-3.4.0/pyisy/events/__init__.py000066400000000000000000000001071477231106100172650ustar00rootroot00000000000000"""ISY Event Stream Subclasses.""" from __future__ import annotations PyISY-3.4.0/pyisy/events/eventreader.py000066400000000000000000000100471477231106100200360ustar00rootroot00000000000000"""ISY TCP Socket Event Reader.""" from __future__ import annotations import errno import select import ssl from ..constants import SOCKET_BUFFER_SIZE from ..exceptions import ( ISYInvalidAuthError, ISYMaxConnections, ISYStreamDataError, ISYStreamDisconnected, ) class ISYEventReader: """Read in streams of ISY HTTP Events.""" HTTP_HEADER_SEPERATOR = b"\r\n" HTTP_HEADER_BODY_SEPERATOR = b"\r\n\r\n" HTTP_HEADER_BODY_SEPERATOR_LEN = 4 REACHED_MAX_CONNECTIONS_RESPONSE = b"HTTP/1.1 817" HTTP_NOT_AUTHORIZED_RESPONSE = b"HTTP/1.1 401" CONTENT_LENGTH_HEADER = b"content-length" HEADER_SEPERATOR = b":" def __init__(self, isy_read_socket): """Initialize the ISYEventStream class.""" self._event_buffer = b"" self._event_content_length = None self._event_count = 0 self._socket = isy_read_socket def read_events(self, timeout): """Read events from the socket.""" events = [] # poll socket for new data if not self._receive_into_buffer(timeout): return events while True: # Read the headers if we do not have content length yet if not self._event_content_length: seperator_position = self._event_buffer.find(self.HTTP_HEADER_BODY_SEPERATOR) if seperator_position == -1: return events self._parse_headers(seperator_position) # If we do not have a body yet if len(self._event_buffer) < self._event_content_length: return events # We have the body now body = self._event_buffer[0 : self._event_content_length] self._event_count += 1 self._event_buffer = self._event_buffer[self._event_content_length :] self._event_content_length = None events.append(body.decode(encoding="utf-8", errors="ignore")) def _receive_into_buffer(self, timeout): """Receive data on available on the socket. If we get an empty read on the first read attempt this means the isy has disconnected. If we get an empty read on the first read attempt and we have seen only one event, the isy has reached the maximum number of event listeners. """ inready, _, _ = select.select([self._socket], [], [], timeout) if self._socket not in inready: return False try: # We have data on the wire, read as much as we can # up to 32 * SOCKET_BUFFER_SIZE for read_count in range(32): new_data = self._socket.recv(SOCKET_BUFFER_SIZE) if len(new_data) == 0: if read_count != 0: break if self._event_count <= 1: raise ISYMaxConnections(self._event_buffer) raise ISYStreamDisconnected(self._event_buffer) self._event_buffer += new_data except ssl.SSLWantReadError: pass except OSError as ex: if ex.errno != errno.EWOULDBLOCK: raise return True def _parse_headers(self, seperator_position): """Find the content-length in the headers.""" headers = self._event_buffer[0:seperator_position] if headers.startswith(self.REACHED_MAX_CONNECTIONS_RESPONSE): raise ISYMaxConnections(self._event_buffer) if headers.startswith(self.HTTP_NOT_AUTHORIZED_RESPONSE): raise ISYInvalidAuthError(self._event_buffer) self._event_buffer = self._event_buffer[seperator_position + self.HTTP_HEADER_BODY_SEPERATOR_LEN :] for header in headers.split(self.HTTP_HEADER_SEPERATOR)[1:]: header_name, header_value = header.split(self.HEADER_SEPERATOR, 1) if header_name.strip().lower() != self.CONTENT_LENGTH_HEADER: continue self._event_content_length = int(header_value.strip()) if not self._event_content_length: raise ISYStreamDataError(headers) PyISY-3.4.0/pyisy/events/strings.py000066400000000000000000000027711477231106100172300ustar00rootroot00000000000000"""Strings for Event Stream Requests.""" from __future__ import annotations # Subscribe Message SUB_MSG = { "head": """POST /services HTTP/1.1 Host: {addr}:{port}{webroot} Authorization: {auth} Content-Length: {length} Content-Type: text/xml; charset="utf-8" SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Subscribe\r \r """, "body": """ REUSE_SOCKET infinite \r """, } # Unsubscribe Message UNSUB_MSG = { "head": """POST /services HTTP/1.1 Host: {addr}:{port}{webroot} Authorization: {auth} Content-Length: {length} Content-Type: text/xml; charset="utf-8" SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Unsubscribe\r \r """, "body": """ {sid} \r """, } # Resubscribe Message RESUB_MSG = { "head": """POST /services HTTP/1.1 Host: {addr}:{port}{webroot} Authorization: {auth} Content-Length: {length} Content-Type: text/xml; charset="utf-8" SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Subscribe\r \r """, "body": """ REUSE_SOCKET infinite {sid} \r """, } PyISY-3.4.0/pyisy/events/tcpsocket.py000066400000000000000000000246731477231106100175430ustar00rootroot00000000000000"""ISY Event Stream.""" from __future__ import annotations import asyncio import logging import socket import ssl import time import xml from threading import Thread, ThreadError from xml.dom import minidom from ..constants import ( ACTION_KEY, ACTION_KEY_CHANGED, ATTR_ACTION, ATTR_CONTROL, ATTR_ID, ATTR_STREAM_ID, ATTR_VAR, ES_CONNECTED, ES_DISCONNECTED, ES_INITIALIZING, ES_LOADED, ES_LOST_STREAM_CONNECTION, POLL_TIME, PROP_STATUS, RECONNECT_DELAY, TAG_EVENT_INFO, TAG_NODE, ) from ..exceptions import ISYInvalidAuthError, ISYMaxConnections, ISYStreamDataError from ..helpers import attr_from_xml, now, value_from_xml from ..logging import LOG_VERBOSE from . import strings from .eventreader import ISYEventReader _LOGGER = logging.getLogger(__name__) # Allows targeting pyisy.events in handlers. class EventStream: """Class to represent the Event Stream from the ISY.""" def __init__(self, isy, connection_info, on_lost_func=None): """Initialize the EventStream class.""" self.isy = isy self._running = False self._writer = None self._thread = None self._subscribed = False self._connected = False self._lasthb = None self._hbwait = 0 self._loaded = None self._on_lost_function = on_lost_func self._program_key = None self.cert = None self.data = connection_info # create TLS encrypted socket if we're using HTTPS if self.data.get("tls"): if self.data["tls"] == 1.1: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) else: context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) context.check_hostname = False self.socket = context.wrap_socket( socket.socket(socket.AF_INET, socket.SOCK_STREAM), server_hostname=f"https://{self.data['addr']}", ) else: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def _create_message(self, msg): """Prepare a message for sending.""" head = msg["head"] body = msg["body"] body = body.format(**self.data) length = len(body) head = head.format(length=length, **self.data) return head + body def _route_message(self, msg): """Route a received message from the event stream.""" # check xml formatting try: xmldoc = minidom.parseString(msg) except xml.parsers.expat.ExpatError: _LOGGER.warning("ISY Received Malformed XML:\n%s", msg) return _LOGGER.log(LOG_VERBOSE, "ISY Update Received:\n%s", msg) # A wild stream id appears! if f"{ATTR_STREAM_ID}=" in msg and ATTR_STREAM_ID not in self.data: self.update_received(xmldoc) # direct the event message cntrl = value_from_xml(xmldoc, ATTR_CONTROL) if not cntrl: return if cntrl == "_0": # ISY HEARTBEAT if self._loaded is None: self._loaded = ES_INITIALIZING self.isy.connection_events.notify(ES_INITIALIZING) elif self._loaded == ES_INITIALIZING: self._loaded = ES_LOADED self.isy.connection_events.notify(ES_LOADED) self._lasthb = now() self._hbwait = int(value_from_xml(xmldoc, ATTR_ACTION)) _LOGGER.debug("ISY HEARTBEAT: %s", self._lasthb.isoformat()) elif cntrl == PROP_STATUS: # NODE UPDATE self.isy.nodes.update_received(xmldoc) elif cntrl[0] != "_": # NODE CONTROL EVENT self.isy.nodes.control_message_received(xmldoc) elif cntrl == "_1": # Trigger Update if f"<{ATTR_VAR}" in msg: # VARIABLE self.isy.variables.update_received(xmldoc) elif f"<{ATTR_ID}>" in msg: # PROGRAM self.isy.programs.update_received(xmldoc) elif f"<{TAG_NODE}>" in msg and "[" in msg: # Node Server Update pass # This is most likely a duplicate node update. elif f"<{ATTR_ACTION}>" in msg: action = value_from_xml(xmldoc, ATTR_ACTION) if action == ACTION_KEY: self.data[ACTION_KEY] = value_from_xml(xmldoc, TAG_EVENT_INFO) return if action == ACTION_KEY_CHANGED: self._program_key = value_from_xml(xmldoc, TAG_NODE) # Need to reload programs asyncio.run_coroutine_threadsafe(self.isy.programs.update(), self.isy.loop) elif cntrl == "_3": # Node Changed/Updated self.isy.nodes.node_changed_received(xmldoc) def update_received(self, xmldoc): """Set the socket ID.""" self.data[ATTR_STREAM_ID] = attr_from_xml(xmldoc, "Event", ATTR_STREAM_ID) _LOGGER.debug("ISY Updated Events Stream ID %s", self.data[ATTR_STREAM_ID]) @property def running(self): """Return the running state of the thread.""" try: return self._thread.isAlive() except (AttributeError, RuntimeError, ThreadError): return False @running.setter def running(self, val): if val and not self.running: _LOGGER.info("ISY Starting Updates") if self.connect(): self.subscribe() self._running = True self._thread = Thread(target=self.watch) self._thread.daemon = True self._thread.start() else: _LOGGER.info("ISY Stopping Updates") self._running = False self.unsubscribe() self.disconnect() def write(self, msg): """Write data back to the socket.""" if self._writer is None: raise NotImplementedError("Function not available while socket is closed.") self._writer.write(msg) self._writer.flush() def connect(self): """Connect to the event stream socket.""" if not self._connected: try: self.socket.connect((self.data["addr"], self.data["port"])) if self.data.get("tls"): self.cert = self.socket.getpeercert() except OSError: _LOGGER.exception("PyISY could not connect to ISY event stream.") if self._on_lost_function is not None: self._on_lost_function() return False self.socket.setblocking(0) self._writer = self.socket.makefile("w") self._connected = True self.isy.connection_events.notify(ES_CONNECTED) return True return True def disconnect(self): """Disconnect from the Event Stream socket.""" if self._connected: self.socket.close() self._connected = False self._subscribed = False self._running = False self.isy.connection_events.notify(ES_DISCONNECTED) def subscribe(self): """Subscribe to the Event Stream.""" if not self._subscribed and self._connected: if ATTR_STREAM_ID not in self.data: msg = self._create_message(strings.SUB_MSG) self.write(msg) else: msg = self._create_message(strings.RESUB_MSG) self.write(msg) self._subscribed = True def unsubscribe(self): """Unsubscribe from the Event Stream.""" if self._subscribed and self._connected: try: msg = self._create_message(strings.UNSUB_MSG) self.write(msg) except (OSError, KeyError): _LOGGER.exception( "PyISY encountered a socket error while writing unsubscribe message to the socket.", ) self._subscribed = False self.disconnect() @property def connected(self): """Return if the module is connected to the ISY or not.""" return self._connected @property def heartbeat_time(self): """Return the last ISY Heartbeat time.""" if self._lasthb is not None: return (now() - self._lasthb).seconds return 0.0 def _lost_connection(self, delay=0): """React when the event stream connection is lost.""" _LOGGER.warning("PyISY lost connection to the ISY event stream.") self.isy.connection_events.notify(ES_LOST_STREAM_CONNECTION) self.unsubscribe() if self._on_lost_function is not None: time.sleep(delay) self._on_lost_function() def watch(self): """Watch the subscription connection and report if dead.""" if not self._subscribed: _LOGGER.debug("PyISY watch called without a subscription.") return event_reader = ISYEventReader(self.socket) while self._running and self._subscribed: # verify connection is still alive if self.heartbeat_time > self._hbwait: self._lost_connection() return try: events = event_reader.read_events(POLL_TIME) except ISYMaxConnections: _LOGGER.exception( "PyISY reached maximum connections, delaying reconnect attempt by %s seconds.", RECONNECT_DELAY, ) self._lost_connection(RECONNECT_DELAY) return except ISYInvalidAuthError: _LOGGER.exception("Invalid authentication used to connect to the event stream.") return except ISYStreamDataError as ex: _LOGGER.warning("PyISY encountered an error while reading the event stream: %s.", ex) self._lost_connection() return except OSError as ex: _LOGGER.warning( "PyISY encountered a socket error while reading the event stream: %s.", ex, ) self._lost_connection() return for message in events: try: self._route_message(message) except Exception as ex: # pylint: disable=broad-except # noqa: PERF203 _LOGGER.warning("PyISY encountered while routing message '%s': %s", message, ex) raise def __del__(self): """Ensure we unsubscribe on destroy.""" self.unsubscribe() PyISY-3.4.0/pyisy/events/websocket.py000066400000000000000000000257161477231106100175310ustar00rootroot00000000000000"""ISY Websocket Event Stream.""" from __future__ import annotations import asyncio import logging import xml from datetime import datetime from typing import TYPE_CHECKING from xml.dom import minidom import aiohttp from ..connection import get_new_client_session, get_sslcontext from ..constants import ( ACTION_KEY, ACTION_KEY_CHANGED, ATTR_ACTION, ATTR_CONTROL, ATTR_ID, ATTR_STREAM_ID, ATTR_VAR, ES_CONNECTED, ES_DISCONNECTED, ES_INITIALIZING, ES_LOST_STREAM_CONNECTION, ES_NOT_STARTED, ES_RECONNECTING, ES_STOP_UPDATES, PROP_STATUS, TAG_EVENT_INFO, TAG_NODE, ) from ..helpers import attr_from_xml, now, value_from_xml from ..logging import LOG_VERBOSE, enable_logging if TYPE_CHECKING: from ..isy import ISY _LOGGER = logging.getLogger(__name__) # Allows targeting pyisy.events in handlers. WS_HEADERS = { "Sec-WebSocket-Protocol": "ISYSUB", "Sec-WebSocket-Version": "13", "Origin": "com.universal-devices.websockets.isy", } WS_HEARTBEAT = 30 WS_HB_GRACE = 2 WS_TIMEOUT = 10.0 WS_MAX_RETRIES = 4 WS_RETRY_BACKOFF: list[float] = [0.01, 1, 10, 30, 60] # Seconds class WebSocketClient: """Class for handling web socket communications with the ISY.""" def __init__( self, isy: ISY, address: str, port: int, username: str, password: str, use_https: bool = False, tls_ver=1.1, webroot: str = "", websession: aiohttp.ClientSession | None = None, ) -> None: """Initialize a new Web Socket Client class.""" if len(_LOGGER.handlers) == 0: enable_logging(add_null_handler=True) self.isy = isy self._address = address self._port = port self._username = username self._password = password self._auth = aiohttp.BasicAuth(self._username, self._password) self._webroot = webroot.rstrip("/") self._tls_ver = tls_ver self.use_https = use_https self._status: str = ES_NOT_STARTED self._lasthb: datetime | None = None self._hbwait: int = WS_HEARTBEAT self._sid = None self._program_key = None self.websocket_task: asyncio.Task[None] = None self.guardian_task: asyncio.Task[None] = None if websession is None: websession = get_new_client_session(use_https, tls_ver) self.req_session = websession self.sslcontext = get_sslcontext(use_https, tls_ver) self._loop = asyncio.get_running_loop() self._reconnect_timer: asyncio.TimerHandle | None = None self._url = "wss://" if self.use_https else "ws://" self._url += f"{self._address}:{self._port}{self._webroot}/rest/subscribe" def start(self, retries: int = 0) -> None: """Start the websocket connection.""" if self.status != ES_CONNECTED: _LOGGER.debug("Starting websocket connection.") self.status = ES_INITIALIZING self.websocket_task = self._loop.create_task(self.websocket(retries)) self.guardian_task = self._loop.create_task(self._websocket_guardian()) def stop(self) -> None: """Close websocket connection.""" self.status = ES_STOP_UPDATES if self.websocket_task is not None: _LOGGER.debug("Stopping websocket connection.") self.websocket_task.cancel() if self.guardian_task is not None: self.guardian_task.cancel() self._lasthb = None if self._reconnect_timer is not None: self._reconnect_timer.cancel() self._reconnect_timer = None def _reconnect(self, retries: int = 0) -> None: """Reconnect to a disconnected websocket. This is a synchronous method that will be called from the event loop. Unlike the async reconnect method, this method does not use asyncio.sleep. """ if delay := self._reconnect_prepare(None, retries): self._reconnect_timer = self._loop.call_later(delay, self._reconnect_execute, retries) else: self._reconnect_execute(retries) def _reconnect_prepare(self, delay: float | None, retries: int) -> float: """Start the reconnect process.""" self.stop() self.status = ES_RECONNECTING if delay is None: delay = WS_RETRY_BACKOFF[retries] _LOGGER.info("PyISY attempting stream reconnect in %ss.", delay) return delay def _reconnect_execute(self, retries: int) -> None: """Finish the reconnect process.""" retries = (retries + 1) if retries < WS_MAX_RETRIES else WS_MAX_RETRIES self.start(retries) @property def status(self) -> str: """Return if the websocket is running or not.""" return self._status @status.setter def status(self, value): """Set the current node state and notify listeners.""" if self._status != value: self._status = value self.isy.connection_events.notify(self._status) return self._status @property def last_heartbeat(self) -> datetime | None: """Return the last received heartbeat time from the ISY.""" return self._lasthb @property def heartbeat_time(self) -> float: """Return the time since the last ISY Heartbeat.""" if self._lasthb is not None: return (now() - self._lasthb).seconds return 0.0 async def _websocket_guardian(self): """Watch and reset websocket connection if no messages received.""" while self.status != ES_STOP_UPDATES: await asyncio.sleep(self._hbwait) if ( self.websocket_task.cancelled() or self.websocket_task.done() or self.heartbeat_time > self._hbwait + WS_HB_GRACE ): _LOGGER.debug("Websocket missed a heartbeat, resetting connection.") self.status = ES_LOST_STREAM_CONNECTION self._reconnect() return async def _route_message(self, msg: str) -> None: """Route a received message from the event stream.""" # check xml formatting try: xmldoc = minidom.parseString(msg) except xml.parsers.expat.ExpatError: _LOGGER.warning("ISY Received Malformed XML:\n%s", msg) return _LOGGER.log(LOG_VERBOSE, "ISY Update Received:\n%s", msg) # A wild stream id appears! if f"{ATTR_STREAM_ID}=" in msg and self._sid is None: self.update_received(xmldoc) # direct the event message cntrl = value_from_xml(xmldoc, ATTR_CONTROL) if not cntrl: return if cntrl == "_0": # ISY HEARTBEAT self._lasthb = now() self._hbwait = int(value_from_xml(xmldoc, ATTR_ACTION)) _LOGGER.debug("ISY HEARTBEAT: %s", self._lasthb.isoformat()) self.isy.connection_events.notify(self._status) elif cntrl == PROP_STATUS: # NODE UPDATE self.isy.nodes.update_received(xmldoc) elif cntrl[0] != "_": # NODE CONTROL EVENT self.isy.nodes.control_message_received(xmldoc) elif cntrl == "_1": # Trigger Update if f"<{ATTR_VAR}" in msg: # VARIABLE (action=6 or 7) self.isy.variables.update_received(xmldoc) elif f"<{ATTR_ID}>" in msg: # PROGRAM (action=0) self.isy.programs.update_received(xmldoc) elif f"<{TAG_NODE}>" in msg and "[" in msg: # Node Server Update pass # This is most likely a duplicate node update. elif f"<{ATTR_ACTION}>" in msg: action = value_from_xml(xmldoc, ATTR_ACTION) if action == ACTION_KEY: self._program_key = value_from_xml(xmldoc, TAG_EVENT_INFO) return if action == ACTION_KEY_CHANGED: self._program_key = value_from_xml(xmldoc, TAG_NODE) # Need to reload programs await self.isy.programs.update() elif cntrl == "_3": # Node Changed/Updated self.isy.nodes.node_changed_received(xmldoc) elif cntrl == "_5": # System Status Changed self.isy.system_status_changed_received(xmldoc) elif cntrl == "_7": # Progress report, device programming event self.isy.nodes.progress_report_received(xmldoc) def update_received(self, xmldoc: minidom.Element) -> None: """Set the socket ID.""" self._sid = attr_from_xml(xmldoc, "Event", ATTR_STREAM_ID) _LOGGER.debug("ISY Updated Events Stream ID: %s", self._sid) async def websocket(self, retries: int = 0) -> None: """Start websocket connection.""" try: async with self.req_session.ws_connect( self._url, auth=self._auth, heartbeat=WS_HEARTBEAT, headers=WS_HEADERS, timeout=WS_TIMEOUT, receive_timeout=self._hbwait + WS_HB_GRACE, ssl=self.sslcontext, ) as ws: self.status = ES_CONNECTED retries = 0 _LOGGER.debug("Successfully connected to websocket.") async for msg in ws: msg_type = msg.type if msg_type is aiohttp.WSMsgType.TEXT: await self._route_message(msg.data) elif msg_type is aiohttp.WSMsgType.BINARY: _LOGGER.warning("Unexpected binary message received.") elif msg_type is aiohttp.WSMsgType.ERROR: _LOGGER.error("Error during receive %s", ws.exception()) break except asyncio.CancelledError: self.status = ES_DISCONNECTED return except asyncio.TimeoutError: _LOGGER.debug("Websocket Timeout.") except aiohttp.ClientConnectorError as err: _LOGGER.error("Websocket Client Connector Error: %s", err) # noqa: TRY400 except ( aiohttp.ClientOSError, aiohttp.client_exceptions.ServerDisconnectedError, ): _LOGGER.debug("Websocket Server Not Ready.") except aiohttp.client_exceptions.WSServerHandshakeError as err: _LOGGER.warning("Web socket server response error: %s", err.message) # pylint: disable=broad-except except Exception: _LOGGER.exception("Unexpected websocket error") else: if isinstance(ws.exception(), asyncio.TimeoutError): _LOGGER.debug("Websocket Timeout.") elif isinstance(ws.exception(), aiohttp.streams.EofStream): _LOGGER.warning("Websocket disconnected unexpectedly. Check network connection.") else: _LOGGER.warning("Websocket disconnected unexpectedly with code: %s", ws.close_code) if self.status != ES_STOP_UPDATES: self.status = ES_LOST_STREAM_CONNECTION self._reconnect(retries=retries) PyISY-3.4.0/pyisy/exceptions.py000066400000000000000000000015351477231106100164110ustar00rootroot00000000000000"""Exceptions used by the PyISY module.""" from __future__ import annotations from xml.parsers.expat import ExpatError XML_ERRORS = (AttributeError, KeyError, ValueError, TypeError, IndexError, ExpatError) XML_PARSE_ERROR = "ISY Could not parse response, poorly formatted XML." class ISYInvalidAuthError(Exception): """Invalid authorization credentials provided.""" class ISYConnectionError(Exception): """Invalid connection parameters provided.""" class ISYResponseParseError(Exception): """Error parsing a response provided by the ISY.""" class ISYStreamDataError(Exception): """Invalid data in the isy event stream.""" class ISYStreamDisconnected(ISYStreamDataError): """The isy has disconnected.""" class ISYMaxConnections(ISYStreamDisconnected): """The isy has disconnected because it reached maximum connections.""" PyISY-3.4.0/pyisy/helpers.py000066400000000000000000000204411477231106100156670ustar00rootroot00000000000000"""Helper functions for the PyISY Module.""" from __future__ import annotations import datetime import time from collections.abc import Callable from dataclasses import dataclass, is_dataclass from xml.dom import minidom from .constants import ( ATTR_FORMATTED, ATTR_ID, ATTR_PRECISION, ATTR_UNIT_OF_MEASURE, ATTR_VALUE, DEFAULT_PRECISION, DEFAULT_UNIT_OF_MEASURE, INSTEON_RAMP_RATES, ISY_EPOCH_OFFSET, ISY_PROP_NOT_SET, ISY_VALUE_UNKNOWN, PROP_BATTERY_LEVEL, PROP_RAMP_RATE, PROP_STATUS, TAG_CATEGORY, TAG_GENERIC, TAG_MFG, TAG_PROPERTY, UOM_SECONDS, ) from .exceptions import XML_ERRORS from .logging import _LOGGER def parse_xml_properties(xmldoc: minidom.Document) -> tuple[NodeProperty, dict[str, NodeProperty], bool]: """ Parse the xml properties string. Args: xmldoc: xml document to parse Returns: (state, aux_props, state_set) """ aux_props: dict[str, NodeProperty] = {} state_set = False state = NodeProperty(PROP_STATUS, uom=ISY_PROP_NOT_SET) props = xmldoc.getElementsByTagName(TAG_PROPERTY) if not props: return state, aux_props, state_set for prop in props: prop_id = attr_from_element(prop, ATTR_ID) uom = attr_from_element(prop, ATTR_UNIT_OF_MEASURE, DEFAULT_UNIT_OF_MEASURE) value = attr_from_element(prop, ATTR_VALUE, "").strip() prec = attr_from_element(prop, ATTR_PRECISION, DEFAULT_PRECISION) formatted = attr_from_element(prop, ATTR_FORMATTED, value) # ISY firmwares < 5 return a list of possible units. # ISYv5+ returns a UOM string which is checked against the SDK. # Only return a list if the UOM should be a list. if "/" in uom and uom != "n/a": uom = uom.split("/") value = int(value) if value.strip() != "" else ISY_VALUE_UNKNOWN result = NodeProperty(prop_id, value, prec, uom, formatted) if prop_id == PROP_STATUS: state = result state_set = True elif prop_id == PROP_BATTERY_LEVEL and not state_set: state = result else: if prop_id == PROP_RAMP_RATE: result.value = INSTEON_RAMP_RATES.get(value, value) result.uom = UOM_SECONDS aux_props[prop_id] = result return state, aux_props, state_set def value_from_xml(xml: minidom.Element, tag_name: str, default: object | None = None) -> object | None: """Extract a value from the XML element.""" value = default try: value = xml.getElementsByTagName(tag_name)[0].firstChild.toxml() except XML_ERRORS: pass return value def attr_from_xml( xml: minidom.Element, tag_name: str, attr_name: str, default: object | None = None ) -> object | None: """Extract an attribute value from the raw XML.""" value = default try: root = xml.getElementsByTagName(tag_name)[0] value = attr_from_element(root, attr_name, default) except XML_ERRORS: pass return value def attr_from_element( element: minidom.Element, attr_name: str, default: object | None = None ) -> object | None: """Extract an attribute value from an XML element.""" value = default if attr_name in element.attributes: value = element.attributes[attr_name].value return value def value_from_nested_xml(base: minidom.Element, chain, default: object | None = None) -> object | None: """Extract a value from multiple nested tags.""" value = default result = None try: result = base.getElementsByTagName(chain[0])[0] if len(chain) > 1: result = result.getElementsByTagName(chain[1])[0] if len(chain) > 2: result = result.getElementsByTagName(chain[2])[0] if len(chain) > 3: result = result.getElementsByTagName(chain[3])[0] value = result.firstChild.toxml() except XML_ERRORS: pass return value def ntp_to_system_time(timestamp): """Convert a ISY NTP time to system UTC time. Adapted from Python ntplib module. https://pypi.org/project/ntplib/ Parameters: timestamp -- timestamp in NTP time Returns: corresponding system time Note: The ISY uses a EPOCH_OFFSET in addition to standard NTP. """ _system_epoch = datetime.date(*time.gmtime(0)[0:3]) _ntp_epoch = datetime.date(1900, 1, 1) ntp_delta = ((_system_epoch - _ntp_epoch).days * 24 * 3600) - ISY_EPOCH_OFFSET return datetime.datetime.fromtimestamp(timestamp - ntp_delta) def now() -> datetime.datetime: """Get the current system time. Note: this module uses naive datetimes because the ISY is highly inconsistent with time conventions and does not present enough information to accurately manage DST without significant guessing and effort. """ return datetime.datetime.now() class EventEmitter: """Event Emitter class.""" _subscribers: list[EventListener] def __init__(self) -> None: """Initialize a new Event Emitter class.""" self._subscribers: list[EventListener] = [] def subscribe(self, callback: Callable, event_filter: dict | str | None = None, key: str | None = None): """Subscribe to the events.""" listener = EventListener(emitter=self, callback=callback, event_filter=event_filter, key=key) self._subscribers.append(listener) return listener def unsubscribe(self, listener: EventListener): """Unsubscribe from the events.""" self._subscribers.remove(listener) def notify(self, event): """Notify a listener.""" for subscriber in self._subscribers: # Guard against downstream errors interrupting the socket connection (#249) try: if e_filter := subscriber.event_filter: if is_dataclass(event) and isinstance(e_filter, dict): if not (e_filter.items() <= event.__dict__.items()): continue elif event != e_filter: continue if subscriber.key: subscriber.callback(event, subscriber.key) continue subscriber.callback(event) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during callback of %s", event) @dataclass class EventListener: """Event Listener class.""" emitter: EventEmitter callback: Callable event_filter: dict | str key: str def unsubscribe(self) -> None: """Unsubscribe from the events.""" self.emitter.unsubscribe(self) @dataclass class NodeProperty: """Class to hold result of a control event or node aux property.""" control: str value: int | float = ISY_VALUE_UNKNOWN prec: str = DEFAULT_PRECISION uom: str = DEFAULT_UNIT_OF_MEASURE formatted: str = None address: str = None @dataclass class ZWaveProperties: """Class to hold Z-Wave Product Details from a Z-Wave Node.""" category: str = "0" devtype_mfg: str = "0.0.0" devtype_gen: str = "0.0.0" basic_type: str = "0" generic_type: str = "0" specific_type: str = "0" mfr_id: str = "0" prod_type_id: str = "0" product_id: str = "0" raw: str = "" @classmethod def from_xml(cls, xml: minidom.Element) -> ZWaveProperties: """Return a Z-Wave Properties class from an xml DOM object.""" category = value_from_xml(xml, TAG_CATEGORY) devtype_mfg = value_from_xml(xml, TAG_MFG) devtype_gen = value_from_xml(xml, TAG_GENERIC) raw = xml.toxml() basic_type = "0" generic_type = "0" specific_type = "0" mfr_id = "0" prod_type_id = "0" product_id = "0" if devtype_gen: (basic_type, generic_type, specific_type) = devtype_gen.split(".") if devtype_mfg: (mfr_id, prod_type_id, product_id) = devtype_mfg.split(".") return ZWaveProperties( category=category, devtype_mfg=devtype_mfg, devtype_gen=devtype_gen, basic_type=basic_type, generic_type=generic_type, specific_type=specific_type, mfr_id=mfr_id, prod_type_id=prod_type_id, product_id=product_id, raw=raw, ) PyISY-3.4.0/pyisy/isy.py000066400000000000000000000247471477231106100150460ustar00rootroot00000000000000"""Module for connecting to and interacting with the ISY.""" from __future__ import annotations import asyncio from threading import Thread from xml.dom import minidom import aiohttp from .clock import Clock from .configuration import Configuration from .connection import Connection from .constants import ( ATTR_ACTION, CMD_X10, CONFIG_NETWORKING, CONFIG_PORTAL, ES_CONNECTED, ES_RECONNECT_FAILED, ES_RECONNECTING, ES_START_UPDATES, ES_STOP_UPDATES, PROTO_ISY, SYSTEM_BUSY, SYSTEM_STATUS, URL_QUERY, X10_COMMANDS, ) from .events.tcpsocket import EventStream from .events.websocket import WebSocketClient from .helpers import EventEmitter, value_from_xml from .logging import _LOGGER, enable_logging from .networking import NetworkResources from .node_servers import NodeServers from .nodes import Nodes from .programs import Programs from .variables import Variables class ISY: """ This is the main class that handles interaction with the ISY device. | address: String of the IP address of the ISY device | port: String of the port over which the ISY is serving its API | username: String of the administrator username for the ISY | password: String of the administrator password for the ISY | use_https: [optional] Boolean of whether secured HTTP should be used | tls_ver: [optional] Number indicating the version of TLS encryption to use. Valid options are 1.1 or 1.2. :ivar auto_reconnect: Boolean value that indicates if the class should auto-reconnect to the event stream if the connection is lost. :ivar auto_update: Boolean value that controls the class's subscription to the event stream that allows node, program values to be updated automatically. :ivar connected: Read only boolean value indicating if the class is connected to the controller. :ivar nodes: :class:`pyisy.nodes.Nodes` manager that interacts with Insteon nodes and groups. :ivar programs: Program manager that interacts with ISY programs and i folders. :ivar variables: Variable manager that interacts with ISY variables. """ auto_reconnect = True def __init__( self, address: str, port: int, username: str, password: str, use_https: bool = False, tls_ver: float = 1.1, webroot: str = "", websession: aiohttp.ClientSession | None = None, use_websocket: bool = False, ) -> None: """Initialize the primary ISY Class.""" self._events: EventStream | None = None # create this JIT so no socket reuse self._reconnect_thread = None self._connected: bool = False if len(_LOGGER.handlers) == 0: enable_logging(add_null_handler=True) self.conn = Connection( address=address, port=port, username=username, password=password, use_https=use_https, tls_ver=tls_ver, webroot=webroot, websession=websession, ) self.websocket: WebSocketClient | None = None if use_websocket: self.websocket = WebSocketClient( isy=self, address=address, port=port, username=username, password=password, use_https=use_https, tls_ver=tls_ver, webroot=webroot, websession=websession, ) self.configuration: Configuration | None = None self.clock: Clock | None = None self.nodes: Nodes | None = None self.node_servers: NodeServers | None = None self.programs: Programs | None = None self.variables: Variables | None = None self.networking: NetworkResources | None = None self._hostname = address self.connection_events = EventEmitter() self.status_events = EventEmitter() self.system_status = SYSTEM_BUSY self.loop = asyncio.get_running_loop() self._uuid: str | None = None async def initialize(self, with_node_servers=False): """Initialize the connection with the ISY.""" config_xml = await self.conn.test_connection() self.configuration = Configuration(xml=config_xml) self._uuid = self.configuration["uuid"] if not self.configuration["model"].startswith("ISY 994"): self.conn.increase_available_connections() isy_setup_tasks = [ self.conn.get_status(), self.conn.get_time(), self.conn.get_nodes(), self.conn.get_programs(), self.conn.get_variable_defs(), self.conn.get_variables(), ] if self.configuration[CONFIG_NETWORKING] or self.configuration.get(CONFIG_PORTAL): isy_setup_tasks.append(asyncio.create_task(self.conn.get_network())) isy_setup_results = await asyncio.gather(*isy_setup_tasks) self.clock = Clock(self, xml=isy_setup_results[1]) self.nodes = Nodes(self, xml=isy_setup_results[2]) self.programs = Programs(self, xml=isy_setup_results[3]) self.variables = Variables( self, def_xml=isy_setup_results[4], var_xml=isy_setup_results[5], ) if self.configuration[CONFIG_NETWORKING] or self.configuration.get(CONFIG_PORTAL): self.networking = NetworkResources(self, xml=isy_setup_results[6]) await self.nodes.update(xml=isy_setup_results[0]) if self.node_servers and with_node_servers: await self.node_servers.load_node_servers() self._connected = True async def shutdown(self) -> None: """Cleanup connections and prepare for exit.""" if self.websocket is not None: self.websocket.stop() if self._events is not None and self._events.running: self.connection_events.notify(ES_STOP_UPDATES) self._events.running = False await self.conn.close() @property def conf(self) -> Configuration: """Return the status of the connection (shortcut property).""" return self.configuration @property def connected(self) -> bool: """Return the status of the connection.""" return self._connected @property def auto_update(self) -> bool: """Return the auto_update property.""" if self.websocket is not None: return self.websocket.status == ES_CONNECTED if self._events is not None: return self._events.running return False @auto_update.setter def auto_update(self, val: bool) -> None: """Set the auto_update property.""" if self.websocket is not None: _LOGGER.warning("Websockets are enabled. Use isy.websocket.start() or .stop() instead.") return if val and not self.auto_update: # create new event stream socket self._events = EventStream(self, self.conn.connection_info, self._on_lost_event_stream) if self._events is not None: self.connection_events.notify(ES_START_UPDATES if val else ES_STOP_UPDATES) self._events.running = val @property def hostname(self) -> str: """Return the hostname.""" return self._hostname @property def protocol(self) -> str: """Return the protocol for this entity.""" return PROTO_ISY @property def uuid(self) -> str: """Return the ISY's uuid.""" return self._uuid def _on_lost_event_stream(self) -> None: """Handle lost connection to event stream.""" del self._events self._events = None if self.auto_reconnect and self._reconnect_thread is None: # attempt to reconnect self._reconnect_thread = Thread(target=self._auto_reconnecter) self._reconnect_thread.daemon = True self._reconnect_thread.start() def _auto_reconnecter(self) -> None: """Auto-reconnect to the event stream.""" while self.auto_reconnect and not self.auto_update: _LOGGER.warning("PyISY attempting stream reconnect.") del self._events self._events = EventStream(self, self.conn.connection_info, self._on_lost_event_stream) self._events.running = True self.connection_events.notify(ES_RECONNECTING) if not self.auto_update: del self._events self._events = None _LOGGER.warning("PyISY could not reconnect to the event stream.") self.connection_events.notify(ES_RECONNECT_FAILED) else: _LOGGER.warning("PyISY reconnected to the event stream.") self._reconnect_thread = None async def query(self, address: str | None = None) -> bool: """Query all the nodes or a specific node if an address is provided . Args: address (string, optional): Node Address to query. Defaults to None. Returns: boolean: Returns `True` on successful command, `False` on error. """ req_path = [URL_QUERY] if address is not None: req_path.append(address) req_url = self.conn.compile_url(req_path) if not await self.conn.request(req_url): _LOGGER.warning("Error performing query.") return False _LOGGER.debug("ISY Query requested successfully.") return True async def send_x10_cmd(self, address: str, cmd: str) -> None: """ Send an X10 command. address: String of X10 device address (Ex: A10) cmd: String of command to execute. Any key of x10_commands can be used """ if cmd in X10_COMMANDS: command = X10_COMMANDS.get(cmd) req_url = self.conn.compile_url([CMD_X10, address, str(command)]) result = await self.conn.request(req_url) if result is not None: _LOGGER.info("ISY Sent X10 Command: %s To: %s", cmd, address) else: _LOGGER.error("ISY Failed to send X10 Command: %s To: %s", cmd, address) def system_status_changed_received(self, xmldoc: minidom.Element) -> None: """Handle System Status events from an event stream message.""" action = value_from_xml(xmldoc, ATTR_ACTION) if not action or action not in SYSTEM_STATUS: return self.system_status = action self.status_events.notify(action) PyISY-3.4.0/pyisy/logging.py000066400000000000000000000041101477231106100156460ustar00rootroot00000000000000"""Logging helper functions.""" from __future__ import annotations import logging _LOGGER = logging.getLogger(__package__) LOG_LEVEL = logging.DEBUG LOG_VERBOSE = 5 LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" def enable_logging( level=LOG_LEVEL, add_null_handler: bool = False, log_no_color: bool = False, ) -> None: """Set up the logging.""" # Adapted from home-assistant/core/homeassistant/bootstrap.py if not log_no_color and not add_null_handler: try: # pylint: disable=import-outside-toplevel from colorlog import ColoredFormatter # basicConfig must be called after importing colorlog in order to # ensure that the handlers it sets up wraps the correct streams. logging.basicConfig(level=level) logging.addLevelName(LOG_VERBOSE, "VERBOSE") colorfmt = f"%(log_color)s{LOG_FORMAT}%(reset)s" logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, datefmt=LOG_DATE_FORMAT, reset=True, log_colors={ "VERBOSE": "blue", "DEBUG": "cyan", "INFO": "green", "WARNING": "yellow", "ERROR": "red", "CRITICAL": "red", }, ) ) except ImportError: pass # If the above initialization failed for any reason, setup the default # formatting. If the above succeeds, this will result in a no-op. logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=level) if add_null_handler: _LOGGER.addHandler(logging.NullHandler()) # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("aiohttp.access").setLevel(logging.WARNING) PyISY-3.4.0/pyisy/networking.py000077500000000000000000000131631477231106100164220ustar00rootroot00000000000000"""ISY Network Resources Module.""" from __future__ import annotations from asyncio import sleep from typing import TYPE_CHECKING from xml.dom import minidom from .constants import ( ATTR_ID, PROTO_NETWORK, TAG_NAME, TAG_NET_RULE, URL_NETWORK, URL_RESOURCES, ) from .exceptions import XML_ERRORS, XML_PARSE_ERROR from .helpers import value_from_xml from .logging import _LOGGER if TYPE_CHECKING: from .isy import ISY class NetworkResources: """ Network Resources class cobject. DESCRIPTION: This class handles the ISY networking module. USAGE: This object may be used in a similar way as a dictionary with the either networking command names or ids being used as keys and the ISY networking command class will be returned. EXAMPLE: # a = networking['test function'] # a.run() ATTRIBUTES: isy: The ISY device class addresses: List of net command ids nnames: List of net command names nobjs: List of net command objects """ def __init__(self, isy: ISY, xml: str | None = None) -> None: """ Initialize the network resources class. isy: ISY class xml: String of xml data containing the configuration data """ self.isy = isy self.addresses: list[int] = [] self._address_index: dict[int, int] = {} self.nnames: list[str] = [] self._nnames_index: dict[str, int] = {} self.nobjs: list[NetworkCommand] = [] if xml is not None: self.parse(xml) def parse(self, xml: str) -> None: """ Parse the xml data. xml: String of the xml data """ try: xmldoc = minidom.parseString(xml) except XML_ERRORS: _LOGGER.error("%s: NetworkResources, resources not loaded", XML_PARSE_ERROR) return features = xmldoc.getElementsByTagName(TAG_NET_RULE) for feature in features: address = int(value_from_xml(feature, ATTR_ID)) if address in self._address_index: continue nname = value_from_xml(feature, TAG_NAME) nobj = NetworkCommand(self, address, nname) self.addresses.append(address) self._address_index[address] = len(self.addresses) - 1 self.nnames.append(nname) self._nnames_index[nname] = len(self.nnames) - 1 self.nobjs.append(nobj) _LOGGER.info("ISY Loaded Network Resources Commands") async def update(self, wait_time: int = 0) -> None: """ Update the contents of the networking class. wait_time: [optional] Amount of seconds to wait before updating """ await sleep(wait_time) xml = await self.isy.conn.get_network() self.parse(xml) async def update_threaded(self, interval: int) -> None: """ Continually update the class until it is told to stop. Should be run in a thread. """ while self.isy.auto_update: await self.update(interval) def __getitem__(self, val: str | int) -> NetworkCommand | None: """Return the item from the collection.""" try: val = int(val) return self.get_by_id(val) except (ValueError, KeyError): return self.get_by_name(val) def __setitem__(self, val, value): """Set the item value.""" return def get_by_id(self, val: int) -> NetworkCommand | None: """ Return command object being given a command id. val: Integer representing command id """ ind = self._address_index.get(val) return None if ind is None else self.get_by_index(ind) def get_by_name(self, val: str) -> NetworkCommand | None: """ Return command object being given a command name. val: String representing command name """ ind = self._nnames_index.get(val) return None if ind is None else self.get_by_index(ind) def get_by_index(self, val: int) -> NetworkCommand | None: """ Return command object being given a command index. val: Integer representing command index in List """ return self.nobjs[val] class NetworkCommand: """ Network Command Class. DESCRIPTION: This class handles individual networking commands. ATTRIBUTES: network_resources: The networkin resources class """ def __init__(self, network_resources: NetworkResources, address: int, name: str) -> None: """Initialize network command class. network_resources: NetworkResources class address: Integer of the command id """ self._network_resources = network_resources self.isy = network_resources.isy self._id = address self._name = name @property def address(self) -> str: """Return the Resource ID for the Network Resource.""" return str(self._id) @property def name(self) -> str: """Return the name of this entity.""" return self._name @property def protocol(self) -> str: """Return the Protocol for this node.""" return PROTO_NETWORK async def run(self) -> None: """Execute the networking command.""" address = self.address req_url = self.isy.conn.compile_url([URL_NETWORK, URL_RESOURCES, address]) if not await self.isy.conn.request(req_url, ok404=True): _LOGGER.warning("ISY could not run networking command: %s", address) return _LOGGER.debug("ISY ran networking command: %s", address) PyISY-3.4.0/pyisy/node_servers.py000066400000000000000000000257461477231106100167400ustar00rootroot00000000000000"""ISY Node Server Information.""" from __future__ import annotations import asyncio import re from dataclasses import dataclass from typing import TYPE_CHECKING from xml.dom import getDOMImplementation, minidom from .constants import ( ATTR_ID, ATTR_UNIT_OF_MEASURE, TAG_ENABLED, TAG_NAME, TAG_ROOT, URL_PROFILE_NS, ) from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from .helpers import attr_from_element, value_from_xml from .logging import _LOGGER if TYPE_CHECKING: from .isy import ISY ATTR_DIR = "dir" ATTR_EDITOR = "editor" ATTR_NLS = "nls" ATTR_SUBSET = "subset" ATTR_PROFILE = "profile" TAG_ACCEPTS = "accepts" TAG_CMD = "cmd" TAG_CONNECTION = "connection" TAG_FILE = "file" TAG_FILES = "files" TAG_IP = "ip" TAG_BASE_URL = "baseurl" TAG_ISY_USER_NUM = "isyusernum" TAG_NODE_DEF = "nodeDef" TAG_NS_USER = "nsuser" TAG_PORT = "port" TAG_RANGE = "range" TAG_SENDS = "sends" TAG_SNI = "sni" TAG_SSL = "ssl" TAG_ST = "st" TAG_TIMEOUT = "timeout" class NodeServers: """ ISY NodeServers class object. DESCRIPTION: This class handles the ISY Node Servers info. ATTRIBUTES: isy: The ISY device class """ def __init__(self, isy: ISY, slots: list[str]) -> None: """ Initialize the NodeServers class. isy: ISY class slots: List of slot numbers """ self.isy = isy self._slots = slots self._connections = [] self._profiles = {} self._node_server_node_definitions = [] self._node_server_node_editors = [] self._node_server_nls = [] self.loaded: bool = False async def load_node_servers(self) -> None: """Load information about node servers from the ISY.""" await self.get_connection_info() await self.get_node_server_profiles() for slot in self._slots: await self.parse_node_server_defs(slot) self.loaded = True _LOGGER.info("ISY updated node servers") # _LOGGER.debug(self._node_server_node_definitions) # _LOGGER.debug(self._node_server_node_editors) async def get_connection_info(self) -> None: """Fetch the node server connections from the ISY.""" result = await self.isy.conn.request( self.isy.conn.compile_url([URL_PROFILE_NS, "0", "connection"]), ok404=False, ) if result is None: return try: connections_xml = minidom.parseString(result) except XML_ERRORS as exc: _LOGGER.error("%s while parsing Node Server connections", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc connections = connections_xml.getElementsByTagName(TAG_CONNECTION) for connection in connections: self._connections.append( NodeServerConnection( slot=attr_from_element(connection, ATTR_PROFILE), enabled=attr_from_element(connection, TAG_ENABLED), name=value_from_xml(connection, TAG_NAME), ssl=value_from_xml(connection, TAG_SSL), sni=value_from_xml(connection, TAG_SNI), port=value_from_xml(connection, TAG_PORT), timeout=value_from_xml(connection, TAG_TIMEOUT), isy_user_num=value_from_xml(connection, TAG_ISY_USER_NUM), ip=value_from_xml(connection, TAG_IP), base_url=value_from_xml(connection, TAG_BASE_URL), ns_user=value_from_xml(connection, TAG_NS_USER), ) ) _LOGGER.info("ISY updated node server connection info") async def get_node_server_profiles(self) -> None: """Retrieve the node server definition files from the ISY.""" node_server_file_list = await self.isy.conn.request( self.isy.conn.compile_url([URL_PROFILE_NS, "0", "files"]), ok404=False ) if node_server_file_list is None: return _LOGGER.debug("Parsing node server file list") try: file_list_xml = minidom.parseString(node_server_file_list) except XML_ERRORS as exc: _LOGGER.error("%s while parsing Node Server files", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc file_list: list[str] = [] profiles = file_list_xml.getElementsByTagName(ATTR_PROFILE) for profile in profiles: slot = attr_from_element(profile, ATTR_ID) directories = profile.getElementsByTagName(TAG_FILES) for directory in directories: dir_name = attr_from_element(directory, ATTR_DIR) files = directory.getElementsByTagName(TAG_FILE) for file in files: file_name = attr_from_element(file, TAG_NAME) file_list.append(f"{slot}/download/{dir_name}/{file_name}") file_tasks = [ self.isy.conn.request(self.isy.conn.compile_url([URL_PROFILE_NS, file])) for file in file_list ] file_contents: list[str] = await asyncio.gather(*file_tasks) self._profiles: dict[str, str] = dict(zip(file_list, file_contents)) _LOGGER.info("ISY downloaded node server files") async def parse_node_server_defs(self, slot: str) -> None: """Retrieve and parse the node server definitions.""" _LOGGER.info("Parsing node server slot %s", slot) node_server_profile = {key: value for (key, value) in self._profiles.items() if key.startswith(slot)} node_defs_impl = getDOMImplementation() editors_impl = getDOMImplementation() node_defs_xml = node_defs_impl.createDocument(None, TAG_ROOT, None) editors_xml = editors_impl.createDocument(None, TAG_ROOT, None) nls_lookup: dict = {} for file_raw, contents in node_server_profile.items(): contents_xml = "" file = file_raw.lower() if file.endswith(".xml"): try: contents_xml = minidom.parseString(contents).firstChild except XML_ERRORS: _LOGGER.error( "%s while parsing Node Server %s file %s", XML_PARSE_ERROR, slot, file, ) continue if "nodedef" in file: node_defs_xml.firstChild.appendChild(contents_xml) if "editors" in file: editors_xml.firstChild.appendChild(contents_xml) if "nls" in file and "en_us" in file: nls_list = [line for line in contents.split("\n") if not line.startswith("#") and line != ""] if nls_list: nls_lookup = dict(re.split(r"\s?=\s?", line) for line in nls_list) self._node_server_nls.append( NodeServerNLS( slot=slot, nls=nls_lookup, ) ) # Process Node Def Files node_defs = node_defs_xml.getElementsByTagName(TAG_NODE_DEF) for node_def in node_defs: node_def_id = attr_from_element(node_def, ATTR_ID) nls_prefix = attr_from_element(node_def, ATTR_NLS) sts = node_def.getElementsByTagName(TAG_ST) statuses = {} for st in sts: status_id = attr_from_element(st, ATTR_ID) editor = attr_from_element(st, ATTR_EDITOR) statuses.update({status_id: editor}) cmds_sends = node_def.getElementsByTagName(TAG_SENDS)[0] cmds_accepts = node_def.getElementsByTagName(TAG_ACCEPTS)[0] cmds_sends_cmd = cmds_sends.getElementsByTagName(TAG_CMD) cmds_accepts_cmd = cmds_accepts.getElementsByTagName(TAG_CMD) sends_commands = [attr_from_element(cmd, ATTR_ID) for cmd in cmds_sends_cmd] accepts_commands = [attr_from_element(cmd, ATTR_ID) for cmd in cmds_accepts_cmd] status_names = {} name = node_def_id if nls_lookup: if (name_key := f"ND-{node_def_id}-NAME") in nls_lookup: name = nls_lookup[name_key] for st in statuses: if (key := f"ST-{nls_prefix}-{st}-NAME") in nls_lookup: status_names.update({st: nls_lookup[key]}) self._node_server_node_definitions.append( NodeServerNodeDefinition( node_def_id=node_def_id, name=name, nls_prefix=nls_prefix, slot=slot, statuses=statuses, status_names=status_names, sends_commands=sends_commands, accepts_commands=accepts_commands, ) ) # Process Editor Files editors = editors_xml.getElementsByTagName(ATTR_EDITOR) for editor in editors: editor_id = attr_from_element(editor, ATTR_ID) editor_range = editor.getElementsByTagName(TAG_RANGE)[0] uom = attr_from_element(editor_range, ATTR_UNIT_OF_MEASURE) subset = attr_from_element(editor_range, ATTR_SUBSET) nls = attr_from_element(editor_range, ATTR_NLS) values = None if nls_lookup and uom == "25": values = { key.partition("-")[2]: value for (key, value) in nls_lookup.items() if key.startswith(nls) } self._node_server_node_editors.append( NodeServerNodeEditor( editor_id=editor_id, unit_of_measurement=uom, subset=subset, nls=nls, slot=slot, values=values, ) ) _LOGGER.debug("ISY parsed node server profiles") @dataclass class NodeServerNodeDefinition: """Node Server Node Definition parsed from the ISY/IoX.""" node_def_id: str name: str nls_prefix: str slot: str statuses: dict[str, str] status_names: dict[str, str] sends_commands: list[str] accepts_commands: list[str] @dataclass class NodeServerNodeEditor: """Node Server Editor definition.""" editor_id: str unit_of_measurement: str subset: str nls: str slot: str values: dict[str, str] @dataclass class NodeServerNLS: """Node Server Natural Language Selection definition.""" slot: str nls: dict[str, str] @dataclass class NodeServerConnection: """Node Server Connection details.""" slot: str enabled: str name: str ssl: str sni: str port: str timeout: str isy_user_num: str ip: str base_url: str ns_user: str def configuration_url(self) -> str: """Compile a configuration url from the connection data.""" protocol: str = "https://" if self.ssl else "http://" return f"{protocol}{self.ip}:{self.port}" PyISY-3.4.0/pyisy/nodes/000077500000000000000000000000001477231106100147625ustar00rootroot00000000000000PyISY-3.4.0/pyisy/nodes/__init__.py000077500000000000000000000630471477231106100171100ustar00rootroot00000000000000"""Representation of ISY Nodes.""" from __future__ import annotations import re from asyncio import sleep from dataclasses import dataclass from operator import itemgetter from typing import TYPE_CHECKING from xml.dom import minidom from ..constants import ( ATTR_ACTION, ATTR_CONTROL, ATTR_FLAG, ATTR_ID, ATTR_INSTANCE, ATTR_NODE_DEF_ID, ATTR_PRECISION, ATTR_UNIT_OF_MEASURE, DEFAULT_PRECISION, DEFAULT_UNIT_OF_MEASURE, DEV_MEMORY, DEV_WRITING, EVENT_PROPS_IGNORED, FAMILY_BRULTECH, FAMILY_NODESERVER, FAMILY_RCS, FAMILY_ZMATTER_ZWAVE, FAMILY_ZWAVE, INSTEON_RAMP_RATES, ISY_VALUE_UNKNOWN, NC_NODE_ENABLED, NC_NODE_ERROR, NODE_CHANGED_ACTIONS, NODE_IS_CONTROLLER, NODE_IS_ROOT, PROP_BATTERY_LEVEL, PROP_COMMS_ERROR, PROP_RAMP_RATE, PROP_STATUS, PROTO_INSTEON, PROTO_NODE_SERVER, PROTO_ZIGBEE, PROTO_ZWAVE, TAG_ADDRESS, TAG_DEVICE_TYPE, TAG_ENABLED, TAG_EVENT_INFO, TAG_FAMILY, TAG_FOLDER, TAG_FORMATTED, TAG_GROUP, TAG_LINK, TAG_NAME, TAG_NODE, TAG_PARENT, TAG_PRIMARY_NODE, TAG_TYPE, UOM_SECONDS, XML_TRUE, ) from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from ..helpers import ( EventEmitter, NodeProperty, ZWaveProperties, attr_from_element, attr_from_xml, parse_xml_properties, value_from_xml, ) from ..logging import _LOGGER from ..node_servers import NodeServers from .group import Group from .node import Node if TYPE_CHECKING: from ..isy import ISY MEMORY_REGEX = ( r".*dbAddr=(?P[A-F0-9x]*) \[(?P[A-F0-9]{2})\] " r"cmd1=(?P[A-F0-9x]{4}) cmd2=(?P[A-F0-9x]{4})" ) SINGLE_NODE_TYPES = {TAG_GROUP, TAG_NODE} class Nodes: """ This class handles the ISY nodes. This class can be used as a dictionary to navigate through the controller's structure to objects of type :class:`pyisy.nodes.Node` and :class:`pyisy.nodes.Group` that represent objects on the controller. | isy: ISY class | root: [optional] String representing the current navigation level's ID | addresses: [optional] list of node ids | nnames: [optional] list of node names | nparents: [optional] list of node parents | nobjs: [optional] list of node objects | ntypes: [optional] list of node types | xml: [optional] String of xml data containing the configuration data :ivar all_lower_nodes: Return all nodes beneath current level :ivar children: A list of the object's children. :ivar has_children: Indicates if object has children :ivar name: The name of the current folder in navigation. """ def __init__( self, isy: ISY, root: str | None = None, addresses: list[str] | None = None, nnames: list[str] | None = None, nparents: list[str] | None = None, nobjs: list[Node] | None = None, ntypes: list[str] | None = None, xml: str | None = None, _address_index: dict[str, int] | None = None, # Internal use only _nnames_index: dict[str, int] | None = None, # Internal use only ) -> None: """Initialize the Nodes ISY Node Manager class.""" self.isy = isy self.root = root self.addresses: list[str] = [] self._address_index: dict[str, int] = {} self.nnames: list[str] = [] self._nnames_index: dict[str, int] = {} self.nparents: list[str] = [] self.nobjs: list[Node] = [] self.ntypes: list[str] = [] self.status_events = EventEmitter() if xml is not None: self.parse(xml) return if addresses is not None: self.addresses = addresses self._address_index = _address_index or {address: i for i, address in enumerate(addresses)} if nnames is not None: self.nnames = nnames self._nnames_index = _nnames_index or {name: i for i, name in enumerate(nnames)} if nparents is not None: self.nparents = nparents if nobjs is not None: self.nobjs = nobjs if ntypes is not None: self.ntypes = ntypes def __str__(self) -> str: """Return string representation of the nodes/folders/groups.""" if self.root is None: return "Folder " ind = self._address_index[self.root] type_ = self.ntypes[ind] if type_ == TAG_FOLDER: return f"Folder ({self.root})" if type_ == TAG_GROUP: return f"Group ({self.root})" return f"Node ({self.root})" def __repr__(self) -> str: """Create a pretty representation of the nodes/folders/groups.""" # get and sort children folders: list[tuple[str, str, str]] = [] groups: list[tuple[str, str, str]] = [] nodes: list[tuple[str, str, str]] = [] for child in self.children: child_type = child[0] if child_type == TAG_FOLDER: folders.append(child) elif child_type == TAG_GROUP: groups.append(child) elif child_type == TAG_NODE: nodes.append(child) # initialize data folders.sort(key=itemgetter(1)) groups.sort(key=itemgetter(1)) nodes.sort(key=itemgetter(1)) return ( f"{self}\n" f"{self.__repr_folders__(folders)}" f"{self.__repr_groups__(groups)}" f"{self.__repr_nodes__(nodes)}" ) def __repr_folders__(self, folders: list[tuple[str, str, str]]) -> str: """Return a representation of the folder structure.""" out = "" for fold in folders: fold_obj = self[fold[2]] out += f" + {fold[1]}: Folder({fold[2]})\n" for line in repr(fold_obj).split("\n")[1:]: out += f" | {line}\n" out += " -\n" return out def __repr_groups__(self, groups: list[tuple[str, str, str]]) -> str: """Return a representation of the groups structure.""" out = "" for group in groups: out += f" + {group[1]}: Group({group[2]})\n" for member in self[group[2]].members: out += f" | {self[member].name}: Node({member})\n" out += " |\n -\n" return out def __repr_nodes__(self, nodes: list[tuple[str, str, str]]) -> str: """Return a representation of the nodes structure.""" out = "" for node in nodes: has_children = node[2] in self.nparents out += f" {'+ ' if has_children else ''}{node[1]}: Node({node[2]})\n" if has_children: for child in self.get_children(node[2]): out += f" | {child[1]}: Node({child[2]})\n" out += " |\n -\n" return out def __iter__(self) -> NodeIterator: """Return an iterator for each node below the current nav level.""" iter_data = self.all_lower_nodes return NodeIterator(self, iter_data, delta=1) def __reversed__(self) -> NodeIterator: """Return the iterator in reverse order.""" iter_data = self.all_lower_nodes return NodeIterator(self, iter_data, delta=-1) def update_received(self, xmldoc: minidom.Element) -> None: """Update nodes from event stream message.""" address = value_from_xml(xmldoc, TAG_NODE) node = self.get_by_id(address) if not node: _LOGGER.debug( "Received a node update for node %s but could not find a record of this " "node. Please try restarting the module if the problem persists, this " "may be due to a new node being added to the ISY since last restart.", address, ) return value = value_from_xml(xmldoc, ATTR_ACTION, "") value = int(value) if value != "" else ISY_VALUE_UNKNOWN prec = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_PRECISION, DEFAULT_PRECISION) uom = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_UNIT_OF_MEASURE, DEFAULT_UNIT_OF_MEASURE) formatted = value_from_xml(xmldoc, TAG_FORMATTED) # Process the action and value if provided in event data. node.update_state(NodeProperty(PROP_STATUS, value, prec, uom, formatted, address)) _LOGGER.debug("ISY Updated Node: %s", address) def control_message_received(self, xmldoc: minidom.Element) -> None: """ Pass Control events from an event stream message to nodes. Used for sending out to subscribers. """ address = value_from_xml(xmldoc, TAG_NODE) cntrl = value_from_xml(xmldoc, ATTR_CONTROL) if not (address and cntrl): # If there is no node associated with the control message ignore it return node = self.get_by_id(address) if not node: _LOGGER.debug( "Received a node update for node %s but could not find a record of this " "node. Please try restarting the module if the problem persists, this " "may be due to a new node being added to the ISY since last restart.", address, ) return # Process the action and value if provided in event data. node.update_last_update() value = value_from_xml(xmldoc, ATTR_ACTION, 0) value = int(value) if value != "" else ISY_VALUE_UNKNOWN prec = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_PRECISION, DEFAULT_PRECISION) uom = attr_from_xml(xmldoc, ATTR_ACTION, ATTR_UNIT_OF_MEASURE, DEFAULT_UNIT_OF_MEASURE) formatted = value_from_xml(xmldoc, TAG_FORMATTED) if cntrl == PROP_RAMP_RATE: value = INSTEON_RAMP_RATES.get(value, value) uom = UOM_SECONDS node_property = NodeProperty(cntrl, value, prec, uom, formatted, address) if cntrl == PROP_COMMS_ERROR and value == 0 and PROP_COMMS_ERROR in node.aux_properties: # Clear a previous comms error del node.aux_properties[PROP_COMMS_ERROR] if cntrl == PROP_BATTERY_LEVEL and node.is_battery_node: # Update the state if this is a battery node node.update_state(NodeProperty(PROP_STATUS, value, prec, uom, formatted, address)) _LOGGER.debug("ISY Updated Node: %s", address) elif cntrl not in EVENT_PROPS_IGNORED: node.update_property(node_property) node.control_events.notify(node_property) _LOGGER.debug("ISY Node Control Event: %s", node_property) def node_changed_received(self, xmldoc: minidom.Element) -> None: """Handle Node Change/Update events from an event stream message.""" action = value_from_xml(xmldoc, ATTR_ACTION) if not action or action not in NODE_CHANGED_ACTIONS: return (event_desc, e_i_keys) = NODE_CHANGED_ACTIONS[action] node = value_from_xml(xmldoc, TAG_NODE) detail = {} if e_i_keys and xmldoc.getElementsByTagName(TAG_EVENT_INFO): detail = {key: value_from_xml(xmldoc, key) for key in e_i_keys} if action == NC_NODE_ERROR: _LOGGER.error("ISY Could not communicate with device: %s", node) elif action == NC_NODE_ENABLED and node in self.addresses: node_obj: Node = self.get_by_id(node) # pylint: disable=attribute-defined-outside-init node_obj.enabled = detail[TAG_ENABLED] == XML_TRUE self.status_events.notify(event=NodeChangedEvent(node, action, detail)) _LOGGER.debug( "ISY received a %s event for node %s %s", event_desc, node, detail if detail else "", ) # FUTURE: Handle additional node change actions to force updates. def progress_report_received(self, xmldoc: minidom.Element) -> None: """Handle Progress Report '_7' events from an event stream message.""" event_info = value_from_xml(xmldoc, TAG_EVENT_INFO) address, _, message = event_info.partition("]") address = address.strip("[ ") message = message.strip() action = DEV_WRITING detail = {"message": message} if address != "All" and message.startswith("Memory"): action = DEV_MEMORY regex = re.compile(MEMORY_REGEX) if event := regex.search(event_info): detail = { "memory": event.group("dbAddr"), "cmd1": event.group("cmd1"), "cmd2": event.group("cmd2"), "value": int(event.group("value"), 16), } self.status_events.notify(event=NodeChangedEvent(address, action, detail)) _LOGGER.debug( "ISY received a progress report %s event for node %s %s", action, address, detail if detail else "", ) def parse(self, xml: str) -> None: """ Parse the xml data. | xml: String of the xml data """ try: xmldoc = minidom.parseString(xml) except XML_ERRORS as exc: _LOGGER.error("%s: Nodes", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc # get nodes ntypes = [TAG_FOLDER, TAG_NODE, TAG_GROUP] node_servers = [] for ntype in ntypes: features = xmldoc.getElementsByTagName(ntype) for feature in features: # Get Node Information address = value_from_xml(feature, TAG_ADDRESS) nname = value_from_xml(feature, TAG_NAME) _LOGGER.debug("Parsing %s: %s [%s]", ntype, nname, address) nparent = value_from_xml(feature, TAG_PARENT) pnode = value_from_xml(feature, TAG_PRIMARY_NODE) family = value_from_xml(feature, TAG_FAMILY) device_type = value_from_xml(feature, TAG_TYPE) node_def_id = attr_from_element(feature, ATTR_NODE_DEF_ID) flag = int(attr_from_element(feature, ATTR_FLAG, 0)) enabled = value_from_xml(feature, TAG_ENABLED) == XML_TRUE # Assume Insteon, update as confirmed otherwise protocol = PROTO_INSTEON zwave_props = None node_server = None if family is not None: if family in (FAMILY_ZWAVE, FAMILY_ZMATTER_ZWAVE): protocol = PROTO_ZWAVE zwave_prop_xml = feature.getElementsByTagName(TAG_DEVICE_TYPE) if zwave_prop_xml: zwave_props = ZWaveProperties.from_xml(zwave_prop_xml[0]) else: ZWaveProperties() elif family in (FAMILY_BRULTECH, FAMILY_RCS): protocol = PROTO_ZIGBEE elif family == FAMILY_NODESERVER: # Node Server Slot is stored with family as text: node_server = attr_from_xml(feature, TAG_FAMILY, ATTR_INSTANCE) if node_server: protocol = f"{PROTO_NODE_SERVER}_{node_server}" node_servers.append(node_server) # Process the different node types if ntype == TAG_FOLDER and address not in self.addresses: self.insert(address, nname, nparent, None, ntype) elif ntype == TAG_NODE: if address in self.addresses: self.get_by_id(address).update(xmldoc=feature) continue state, aux_props, state_set = parse_xml_properties(feature) self.insert( address, nname, nparent, Node( self, address=address, name=nname, state=state, aux_properties=aux_props, zwave_props=zwave_props, node_def_id=node_def_id, pnode=pnode, device_type=device_type, enabled=enabled, node_server=node_server, protocol=protocol, family_id=family, state_set=state_set, flag=flag, ), ntype, ) elif ntype == TAG_GROUP and address not in self.addresses: # Ignore groups that contain 0x08 in the flag since # that is a ISY scene that contains every device/ # scene so it will contain some scenes we have not # seen yet so they are not defined and it includes # the ISY MAC address in newer versions of # ISY firmwares > 5.0.6+ .. if flag & NODE_IS_ROOT: _LOGGER.debug("Skipping root group flag=%s %s", flag, address) continue mems = feature.getElementsByTagName(TAG_LINK) # Build list of members members = [mem.firstChild.nodeValue for mem in mems] # Build list of controllers controllers = [ mem.firstChild.nodeValue for mem in mems if int(attr_from_element(mem, TAG_TYPE, 0)) == NODE_IS_CONTROLLER ] self.insert( address, nname, nparent, Group( self, address=address, name=nname, members=members, controllers=controllers, family_id=family, pnode=pnode, flag=flag, ), ntype, ) _LOGGER.debug("ISY Loaded %s", ntype) if self.isy.node_servers is None: self.isy.node_servers = NodeServers(self.isy, set(node_servers)) async def update(self, wait_time: float = 0.0, xml: str | None = None) -> None: """ Update the status and properties of the nodes in the class. This calls the "/rest/status" endpoint. | wait_time: [optional] Amount of seconds to wait before updating """ if wait_time: await sleep(wait_time) if xml is None: xml = await self.isy.conn.get_status() if xml is None: _LOGGER.warning("ISY Failed to update nodes.") return None try: xmldoc = minidom.parseString(xml) except XML_ERRORS: _LOGGER.error("%s: Nodes", XML_PARSE_ERROR) return False for feature in xmldoc.getElementsByTagName(TAG_NODE): address = feature.attributes[ATTR_ID].value if address in self.addresses: await self.get_by_id(address).update(xmldoc=feature) continue _LOGGER.info("ISY Updated Node Statuses.") return None async def update_nodes(self, wait_time: float = 0.0) -> None: """ Update the contents of the class. This calls the "/rest/nodes" endpoint. | wait_time: [optional] Amount of seconds to wait before updating """ if wait_time: await sleep(wait_time) xml = await self.isy.conn.get_nodes() if xml is None: _LOGGER.warning("ISY Failed to update nodes.") return self.parse(xml) def insert(self, address: str, nname: str, nparent: str, nobj: Node, ntype: str) -> None: """ Insert a new node into the lists. | address: node id | nname: node name | nparent: node parent | nobj: node object | ntype: node type """ self.addresses.append(address) self._address_index[address] = len(self.addresses) - 1 self.nnames.append(nname) self._nnames_index[nname] = len(self.nnames) - 1 self.nparents.append(nparent) self.ntypes.append(ntype) self.nobjs.append(nobj) def __getitem__(self, val: str) -> Node | Nodes: """Navigate through the node tree. Can take names or IDs.""" if val in self._address_index: fun = self.get_by_id elif val in self._nnames_index: fun = self.get_by_name else: try: val = int(val) fun = self.get_by_index except ValueError: fun = None if fun: output = None try: output = fun(val) except ValueError: pass if output: return output raise KeyError(f"Unrecognized Key: [{val}]") def __setitem__(self, item: object, value: object) -> None: """Set item value.""" return def get_by_name(self, val: str) -> Node | Nodes | None: """ Get child object with the given name. | val: String representing name to look for. """ i = self._nnames_index.get(val) if i is not None and (self.root is None or self.nparents[i] == self.root): return self.get_by_index(i) return None def get_by_id(self, address: str) -> Node | Nodes | None: """ Get object with the given ID. | address: Integer representing node/group/folder id. """ if (i := self._address_index.get(address)) is None: return None return self.get_by_index(i) def get_by_index(self, i: int) -> Node | Nodes: """ Return the object at the given index in the list. | i: Integer representing index of node/group/folder. """ if self.ntypes[i] in SINGLE_NODE_TYPES: return self.nobjs[i] return Nodes( isy=self.isy, root=self.addresses[i], addresses=self.addresses, nnames=self.nnames, nparents=self.nparents, nobjs=self.nobjs, ntypes=self.ntypes, _address_index=self._address_index, _nnames_index=self._nnames_index, ) def get_folder(self, address: str) -> str | None: """Return the folder of a given node address.""" parent = self.nparents[self._address_index[address]] if parent is None: # Node is in the root folder. return None parent_index = self._address_index[parent] if self.ntypes[parent_index] != TAG_FOLDER: return self.get_folder(parent) return self.nnames[parent_index] @property def children(self) -> list[tuple[str, str, str]]: """Return the children of the class.""" return self.get_children() def get_children(self, ident: str | None = None) -> list[tuple[str, str, str]]: """Return the children of the class.""" if ident is None: ident = self.root return [ (self.ntypes[i], self.nnames[i], self.addresses[i]) for i in [index for index, parent in enumerate(self.nparents) if parent == ident] ] @property def has_children(self) -> bool: """Return if the root has children.""" return self.root in self.nparents @property def name(self) -> str: """Return the name of the root.""" if self.root is None: return "" return self.nnames[self._address_index[self.root]] @property def all_lower_nodes(self) -> list[tuple[str, str, str]]: """Return all nodes below the current root.""" output: list[tuple[str, str, str]] = [] myname = self.name + "/" for dtype, name, ident in self.children: if dtype in SINGLE_NODE_TYPES: output.append((dtype, myname + name, ident)) if dtype == TAG_NODE and ident in self.nparents: output += [ (child[0], f"{myname}{name}/{child[1]}", child[2]) for child in self.get_children(ident) ] if dtype == TAG_FOLDER: output += [ (dtype2, myname + name2, ident2) for (dtype2, name2, ident2) in self[ident].all_lower_nodes ] return output class NodeIterator: """Iterate through a list of nodes, returning node objects.""" def __init__(self, nodes: Nodes, iter_data, delta: int = 1) -> None: """Initialize a NodeIterator class.""" self._nodes = nodes self._iterdata = iter_data self._len = len(iter_data) self._delta = delta if delta > 0: self._ind = 0 else: self._ind = self._len - 1 def __next__(self): """Get the next element in the iteration.""" if self._ind >= self._len or self._ind < 0: raise StopIteration _, path, ident = self._iterdata[self._ind] self._ind += self._delta return (path, self._nodes[ident]) def __len__(self): """Return the number of elements.""" return self._len @dataclass class NodeChangedEvent: """Class representation of a node change event.""" address: str action: str event_info: dict PyISY-3.4.0/pyisy/nodes/group.py000077500000000000000000000102021477231106100164660ustar00rootroot00000000000000"""Representation of groups (scenes) from an ISY.""" from __future__ import annotations from typing import TYPE_CHECKING from ..constants import ( FAMILY_GENERIC, INSTEON_STATELESS_NODEDEFID, ISY_VALUE_UNKNOWN, PROTO_GROUP, ) from ..helpers import now from .node import Node from .nodebase import NodeBase if TYPE_CHECKING: from . import Nodes class Group(NodeBase): """ Interact with ISY groups (scenes). | nodes: The node manager object. | address: The node ID. | name: The node name. | members: List of the members in this group. | controllers: List of the controllers in this group. | spoken: The string of the Notes Spoken field. :ivar has_children: Boolean value indicating that group has no children. :ivar members: List of the members of this group. :ivar controllers: List of the controllers of this group. :ivar name: The name of this group. :ivar status: Watched property indicating the status of the group. :ivar group_all_on: Watched property indicating if all devices in group are on. """ def __init__( self, nodes: Nodes, address: str, name: str, members: list[str] | None = None, controllers: list[str] | None = None, family_id: str = FAMILY_GENERIC, pnode: str | None = None, flag: int = 0, ) -> None: """Initialize a Group class.""" self._all_on: bool = False self._controllers: list[str] = controllers or [] self._members: list[str] = members or [] super().__init__(nodes, address, name, 0, family_id=family_id, pnode=pnode, flag=flag) # listen for changes in children self._members_handlers = [ self._nodes[m].status_events.subscribe(self.update_callback) for m in self.members ] # get and update the status self._update() def __del__(self) -> None: """Cleanup event handlers before deleting.""" for handler in self._members_handlers: handler.unsubscribe() @property def controllers(self) -> list[str]: """Get the controller nodes of the scene/group.""" return self._controllers @property def group_all_on(self) -> bool: """Return the current node state.""" return self._all_on @group_all_on.setter def group_all_on(self, value: bool) -> bool: """Set the current node state and notify listeners.""" if self._all_on != value: self._all_on = value self._last_changed = now() # Re-publish the current status. Let users pick up the all on change. self.status_events.notify(self._status) return self._all_on @property def members(self) -> list[str]: """Get the members of the scene/group.""" return self._members @property def protocol(self) -> str: """Return the protocol for this entity.""" return PROTO_GROUP async def update(self, event=None, wait_time: float = 0.0, xmldoc=None): """Update the group with values from the controller.""" return self._update(event, wait_time, xmldoc) def _update(self, event=None, wait_time: float = 0.0, xmldoc=None): """Update the group with values from the controller.""" self._last_update = now() address_to_node: dict[str, Node] = {address: self._nodes[address] for address in self.members} valid_nodes = [ address for address, node_obj in address_to_node.items() if ( node_obj.status is not None and node_obj.status != ISY_VALUE_UNKNOWN and node_obj.node_def_id not in INSTEON_STATELESS_NODEDEFID ) ] on_nodes = [node for node in valid_nodes if int(address_to_node[node].status) > 0] if on_nodes: self.group_all_on = len(on_nodes) == len(valid_nodes) self.status = 255 return self.status = 0 self.group_all_on = False def update_callback(self, event=None): """Handle synchronous callbacks for subscriber events.""" self._update(event) PyISY-3.4.0/pyisy/nodes/node.py000077500000000000000000000523601477231106100162720ustar00rootroot00000000000000"""Representation of a node from an ISY.""" from __future__ import annotations import asyncio from math import isnan from typing import TYPE_CHECKING from xml.dom import minidom from ..constants import ( BACKLIGHT_SUPPORT, CLIMATE_SETPOINT_MIN_GAP, CMD_CLIMATE_FAN_SETTING, CMD_CLIMATE_MODE, CMD_MANUAL_DIM_BEGIN, CMD_MANUAL_DIM_STOP, CMD_SECURE, FAMILY_ZMATTER_ZWAVE, INSTEON_SUBNODE_DIMMABLE, INSTEON_TYPE_DIMMABLE_TUP, INSTEON_TYPE_LOCK_TUP, INSTEON_TYPE_THERMOSTAT_TUP, METHOD_GET, METHOD_SET, PROP_ON_LEVEL, PROP_RAMP_RATE, PROP_SETPOINT_COOL, PROP_SETPOINT_HEAT, PROP_STATUS, PROP_ZWAVE_PREFIX, PROTO_INSTEON, PROTO_ZWAVE, TAG_CONFIG, TAG_GROUP, TAG_PARAMETER, TAG_SIZE, TAG_VALUE, UOM_CLIMATE_MODES, UOM_FAN_MODES, UOM_TO_STATES, URL_CONFIG, URL_NODE, URL_NODES, URL_QUERY, URL_ZMATTER_ZWAVE, URL_ZWAVE, ZWAVE_CAT_DIMMABLE, ZWAVE_CAT_LOCK, ZWAVE_CAT_THERMOSTAT, ) from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from ..helpers import ( EventEmitter, NodeProperty, ZWaveProperties, attr_from_xml, now, parse_xml_properties, ) from ..logging import _LOGGER from .nodebase import NodeBase if TYPE_CHECKING: from . import Nodes class Node(NodeBase): """ This class handles ISY nodes. | parent: The node manager object. | address: The Node ID. | value: The current Node value. | name: The node name. | spoken: The string of the Notes Spoken field. | notes: Notes from the ISY | uom: Unit of Measure returned by the ISY | prec: Precision of the Node (10^-prec) | aux_properties: Additional Properties for the node | zwave_props: Z-Wave Properties from the devtype tag (used for Z-Wave Nodes.) | node_def_id: Node Definition ID (used for ISY firmwares >=v5) | pnode: Node ID of the primary node | device_type: device type. | node_server: the parent node server slot used | protocol: the device protocol used (z-wave, zigbee, insteon, node server) :ivar status: A watched property that indicates the current status of the node. :ivar has_children: Property indicating that there are no more children. """ def __init__( self, nodes: Nodes, address: str, name: str, state: NodeProperty, aux_properties: dict[str, NodeProperty] | None = None, zwave_props: ZWaveProperties | None = None, node_def_id: str | None = None, pnode: str | None = None, device_type: str | None = None, enabled: bool | None = None, node_server: object | None = None, protocol: str | None = None, family_id: str | None = None, state_set: bool = True, flag: int = 0, ) -> None: """Initialize a Node class.""" self._enabled = enabled if enabled is not None else True self._formatted = state.formatted self._node_def_id = node_def_id self._node_server = node_server self._parent_node = pnode if pnode != address else None self._prec = state.prec self._protocol = protocol self._type = device_type self._uom = state.uom self._zwave_props = zwave_props self.control_events = EventEmitter() self._is_battery_node = not state_set super().__init__( nodes, address, name, state.value, family_id=family_id, aux_properties=aux_properties, pnode=pnode, flag=flag, ) @property def dimmable(self) -> bool: """ Return the best guess if this is a dimmable node. DEPRECIATED: USE is_dimmable INSTEAD. Will be removed in future release. """ _LOGGER.info("Node.dimmable is depreciated. Use Node.is_dimmable instead.") return self.is_dimmable @property def enabled(self) -> bool: """Return if the device is enabled or not in the ISY.""" return self._enabled @enabled.setter def enabled(self, value: bool) -> None: """Set if the device is enabled or not in the ISY.""" if self._enabled != value: self._enabled = value @property def formatted(self) -> str | None: """Return the formatted value with units, if provided.""" return self._formatted @property def is_battery_node(self) -> bool: """ Confirm if this is a battery node or a normal node. Battery nodes do not provide a 'ST' property, only 'BATLVL'. """ return self._is_battery_node @property def is_backlight_supported(self) -> bool: """Confirm if this node supports setting backlight.""" return ( (self.protocol == PROTO_INSTEON) and self.node_def_id is not None and (self.node_def_id in BACKLIGHT_SUPPORT) ) @property def is_dimmable(self) -> bool: """ Return the best guess if this is a dimmable node. Check ISYv4 UOM, then Insteon and Z-Wave Types for dimmable types. """ return ( "%" in str(self._uom) or ( self._protocol == PROTO_INSTEON and self.type and self.type.startswith(INSTEON_TYPE_DIMMABLE_TUP) and self._id.endswith(INSTEON_SUBNODE_DIMMABLE) ) or ( self._protocol == PROTO_ZWAVE and self._zwave_props is not None and self._zwave_props.category in ZWAVE_CAT_DIMMABLE ) ) @property def is_lock(self) -> bool: """Determine if this device is a door lock type.""" return (self.type and self.type.startswith(INSTEON_TYPE_LOCK_TUP)) or ( self.protocol == PROTO_ZWAVE and self.zwave_props.category and self.zwave_props.category in ZWAVE_CAT_LOCK ) @property def is_thermostat(self) -> bool: """Determine if this device is a thermostat/climate control device.""" return (self.type and self.type.startswith(INSTEON_TYPE_THERMOSTAT_TUP)) or ( self._protocol == PROTO_ZWAVE and self.zwave_props.category and self.zwave_props.category in ZWAVE_CAT_THERMOSTAT ) @property def node_def_id(self) -> str | None: """Return the node definition id (used for ISYv5).""" return self._node_def_id @property def node_server(self) -> object | None: """Return the node server parent slot (used for v5 Node Server devices).""" return self._node_server @property def parent_node(self) -> Node | None: """ Return the parent node object of this node. Typically this is for devices that are represented as multiple nodes in the ISY, such as door and leak sensors. Return None if there is no parent. """ if self._parent_node: return self._nodes.get_by_id(self._parent_node) return None @property def prec(self) -> int: """Return the precision of the raw device value.""" return self._prec @property def protocol(self) -> str | None: """Return the device standard used (Z-Wave, Zigbee, Insteon, Node Server).""" return self._protocol @property def type(self) -> str | None: """Return the device typecode (Used for Insteon).""" return self._type @property def uom(self) -> str | None: """Return the unit of measurement for the device.""" return self._uom @property def zwave_props(self) -> ZWaveProperties | None: """Return the Z-Wave Properties (used for Z-Wave devices).""" return self._zwave_props async def get_zwave_parameter(self, parameter: int) -> dict[str, str] | bool | None: """Retrieve a Z-Wave Parameter from the ISY.""" if self.protocol != PROTO_ZWAVE: _LOGGER.warning("Cannot retrieve parameters of non-Z-Wave device") return None if not isinstance(parameter, int): _LOGGER.error("Parameter must be an integer") return None # /rest/zwave/node//config/query/ # returns something like: # parameter_xml = await self.isy.conn.request( self.isy.conn.compile_url( [ URL_ZMATTER_ZWAVE if self.family == FAMILY_ZMATTER_ZWAVE else URL_ZWAVE, URL_NODE, self._id, URL_CONFIG, URL_QUERY, str(parameter), ] ) ) if parameter_xml is None or parameter_xml == "": _LOGGER.warning("Error fetching parameter from ISY") return False try: parameter_dom = minidom.parseString(parameter_xml) except XML_ERRORS as exc: _LOGGER.error("%s: Node Parameter %s", XML_PARSE_ERROR, parameter_xml) raise ISYResponseParseError from exc size = int(attr_from_xml(parameter_dom, TAG_CONFIG, TAG_SIZE)) value = attr_from_xml(parameter_dom, TAG_CONFIG, TAG_VALUE) # Add/update the aux_properties to include the parameter. node_prop = NodeProperty( f"{PROP_ZWAVE_PREFIX}{parameter}", value, uom=f"{PROP_ZWAVE_PREFIX}{size}", address=self._id, ) self.update_property(node_prop) return {TAG_PARAMETER: parameter, TAG_SIZE: size, TAG_VALUE: value} async def set_zwave_parameter(self, parameter: int, value: int | str, size: int) -> bool: """Set a Z-Wave Parameter on an end device via the ISY.""" if self.protocol != PROTO_ZWAVE: _LOGGER.warning("Cannot set parameters of non-Z-Wave device") return False try: int(parameter) except ValueError: _LOGGER.error("Parameter must be an integer") return False if size not in [1, "1", 2, "2", 4, "4"]: _LOGGER.error("Size must either 1, 2, or 4 (bytes)") return False if str(value).startswith("0x"): try: int(value, base=16) except ValueError: _LOGGER.error("Value must be valid hex byte string or integer.") return False else: try: int(value) except ValueError: _LOGGER.error("Value must be valid hex byte string or integer.") return False # /rest/zwave/node//config/set/// req_url = self.isy.conn.compile_url( [ URL_ZMATTER_ZWAVE if self.family == FAMILY_ZMATTER_ZWAVE else URL_ZWAVE, URL_NODE, self._id, URL_CONFIG, METHOD_SET, str(parameter), str(value), str(size), ] ) if not await self.isy.conn.request(req_url): _LOGGER.warning( "ISY could not set parameter %s on %s.", parameter, self._id, ) return False _LOGGER.debug("ISY set parameter %s sent to %s.", parameter, self._id) # Add/update the aux_properties to include the parameter. node_prop = NodeProperty( f"{PROP_ZWAVE_PREFIX}{parameter}", value, uom=f"{PROP_ZWAVE_PREFIX}{size}", address=self._id, ) self.update_property(node_prop) return True async def set_zwave_lock_code(self, user_num: int, code: int) -> bool: """Set a Z-Wave Lock User Code via the ISY.""" if self.protocol != PROTO_ZWAVE: raise TypeError("Cannot set parameters of non-Z-Wave device") # /rest/zwave/node//security/user//set/code/ req_url = self.isy.conn.compile_url( [ URL_ZMATTER_ZWAVE if self.family == FAMILY_ZMATTER_ZWAVE else URL_ZWAVE, URL_NODE, self.address, "security", "user", str(user_num), "set/code", str(code), ] ) if not await self.isy.conn.request(req_url): _LOGGER.warning( "Could not set user code %s on %s.", user_num, self.address, ) return False _LOGGER.debug("Set user code %s sent to %s.", user_num, self.address) return True async def delete_zwave_lock_code(self, user_num: int) -> bool: """Delete a Z-Wave Lock User Code via the ISY.""" if self.protocol != PROTO_ZWAVE: raise TypeError("Cannot set parameters of non-Z-Wave device") # /rest/zwave/node//security/user//delete req_url = self.isy.conn.compile_url( [ URL_ZMATTER_ZWAVE if self.family == FAMILY_ZMATTER_ZWAVE else URL_ZWAVE, URL_NODE, self.address, "security", "user", str(user_num), "delete", ] ) if not await self.isy.conn.request(req_url): _LOGGER.warning( "Could not delete user code %s on %s.", user_num, self.address, ) return False _LOGGER.debug("Deleted user code %s sent to %s.", user_num, self.address) return True async def update(self, event=None, wait_time: int = 0, xmldoc: minidom.Document | None = None) -> None: """Update the value of the node from the controller.""" if not self.isy.auto_update and not xmldoc: await asyncio.sleep(wait_time) req_url = self.isy.conn.compile_url([URL_NODES, self._id, METHOD_GET, PROP_STATUS]) xml = await self.isy.conn.request(req_url) try: xmldoc = minidom.parseString(xml) except XML_ERRORS as exc: _LOGGER.error("%s: Nodes", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc if xmldoc is None: _LOGGER.warning("ISY could not update node: %s", self._id) return self._last_update = now() state, aux_props, _ = parse_xml_properties(xmldoc) self._aux_properties.update(aux_props) self.update_state(state) _LOGGER.debug("ISY updated node: %s", self._id) def update_state(self, state: NodeProperty) -> None: """Update the various state properties when received.""" if not isinstance(state, NodeProperty): _LOGGER.error("Could not update state values. Invalid type provided.") return changed = False self._last_update = now() if state.prec != self._prec: self._prec = state.prec changed = True if state.uom not in (self._uom, ""): self._uom = state.uom changed = True if state.formatted != self._formatted: self._formatted = state.formatted changed = True if state.value != self.status: self.status = state.value # Let Status setter throw event return if changed: self._last_changed = now() self.status_events.notify(self.status_feedback) def get_command_value(self, uom: str, cmd: str) -> str | None: """Check against the list of UOM States if this is a valid command.""" if cmd not in UOM_TO_STATES[uom].values(): _LOGGER.warning("Failed to call %s on %s, invalid command.", cmd, self.address) return None return list(UOM_TO_STATES[uom].keys())[list(UOM_TO_STATES[uom].values()).index(cmd)] def get_groups(self, controller=True, responder=True) -> list[str]: """ Return the groups (scenes) of which this node is a member. If controller is True, then the scene it controls is added to the list If responder is True, then the scenes it is a responder of are added to the list. """ groups: list[str] = [] for child in self._nodes.all_lower_nodes: if child[0] == TAG_GROUP: if responder: if self._id in self._nodes[child[2]].members: groups.append(child[2]) elif controller and self._id in self._nodes[child[2]].controllers: groups.append(child[2]) return groups def get_property_uom(self, prop: str) -> str | None: """Get the Unit of Measurement an aux property.""" if aux_prop := self._aux_properties.get(prop): return aux_prop.uom return None async def secure_lock(self) -> bool: """Send a command to securely lock a lock device.""" if not self.is_lock: _LOGGER.warning("Failed to lock %s, it is not a lock node.", self.address) return None return await self.send_cmd(CMD_SECURE, "1") async def secure_unlock(self) -> bool: """Send a command to securely lock a lock device.""" if not self.is_lock: _LOGGER.warning("Failed to unlock %s, it is not a lock node.", self.address) return None return await self.send_cmd(CMD_SECURE, "0") async def set_climate_mode(self, cmd: str) -> bool: """Send a command to the device to set the climate mode.""" if not self.is_thermostat: _LOGGER.warning( "Failed to set setpoint on %s, it is not a thermostat node.", self.address, ) if cmd_value := self.get_command_value(UOM_CLIMATE_MODES, cmd): return await self.send_cmd(CMD_CLIMATE_MODE, cmd_value) return False async def set_climate_setpoint(self, val: int) -> bool: """Send a command to the device to set the system setpoints.""" if not self.is_thermostat: _LOGGER.warning( "Failed to set setpoint on %s, it is not a thermostat node.", self.address, ) return None adjustment = int(CLIMATE_SETPOINT_MIN_GAP / 2.0) commands = [ self.set_climate_setpoint_heat(val - adjustment), self.set_climate_setpoint_cool(val + adjustment), ] result = await asyncio.gather(*commands, return_exceptions=True) return all(result) async def set_climate_setpoint_heat(self, val: int) -> bool: """Send a command to the device to set the system heat setpoint.""" return await self._set_climate_setpoint(val, "heat", PROP_SETPOINT_HEAT) async def set_climate_setpoint_cool(self, val: int) -> bool: """Send a command to the device to set the system heat setpoint.""" return await self._set_climate_setpoint(val, "cool", PROP_SETPOINT_COOL) async def _set_climate_setpoint(self, val: int, setpoint_name: str, setpoint_prop: str) -> bool: """Send a command to the device to set the system heat setpoint.""" if not self.is_thermostat: _LOGGER.warning( "Failed to set %s setpoint on %s, it is not a thermostat node.", setpoint_name, self.address, ) return None # ISY wants 2 times the temperature for Insteon in order to not lose precision if self._uom in ["101", "degrees"]: val = 2 * val return await self.send_cmd(setpoint_prop, str(val), self.get_property_uom(setpoint_prop)) async def set_fan_mode(self, cmd: str) -> bool: """Send a command to the device to set the fan mode setting.""" cmd_value = self.get_command_value(UOM_FAN_MODES, cmd) if cmd_value: return await self.send_cmd(CMD_CLIMATE_FAN_SETTING, cmd_value) return False async def set_on_level(self, val: int) -> bool: """Set the ON Level for a device.""" if not val or isnan(val) or int(val) not in range(256): _LOGGER.warning("Invalid value for On Level for %s. Valid values are 0-255.", self._id) return False return await self.send_cmd(PROP_ON_LEVEL, str(val)) async def set_ramp_rate(self, val: int) -> bool: """Set the Ramp Rate for a device.""" if not val or isnan(val) or int(val) not in range(32): _LOGGER.warning( "Invalid value for Ramp Rate for %s. " "Valid values are 0-31. See 'INSTEON_RAMP_RATES' in constants.py for values.", self._id, ) return False return await self.send_cmd(PROP_RAMP_RATE, str(val)) async def start_manual_dimming(self) -> bool: """Begin manually dimming a device.""" _LOGGER.warning("'%s' is depreciated, use FADE__ commands instead", CMD_MANUAL_DIM_BEGIN) return await self.send_cmd(CMD_MANUAL_DIM_BEGIN) async def stop_manual_dimming(self) -> bool: """Stop manually dimming a device.""" _LOGGER.warning("'%s' is depreciated, use FADE__ commands instead", CMD_MANUAL_DIM_STOP) return await self.send_cmd(CMD_MANUAL_DIM_STOP) PyISY-3.4.0/pyisy/nodes/nodebase.py000077500000000000000000000273711477231106100171310ustar00rootroot00000000000000"""Base object for nodes and groups.""" from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from xml.dom import minidom from ..constants import ( ATTR_LAST_CHANGED, ATTR_LAST_UPDATE, ATTR_STATUS, CMD_BEEP, CMD_BRIGHTEN, CMD_DIM, CMD_DISABLE, CMD_ENABLE, CMD_FADE_DOWN, CMD_FADE_STOP, CMD_FADE_UP, CMD_OFF, CMD_OFF_FAST, CMD_ON, CMD_ON_FAST, COMMAND_FRIENDLY_NAME, METHOD_COMMAND, NODE_FAMILY_ID, TAG_ADDRESS, TAG_DESCRIPTION, TAG_IS_LOAD, TAG_LOCATION, TAG_NAME, TAG_SPOKEN, URL_CHANGE, URL_NODES, URL_NOTES, XML_TRUE, ) from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from ..helpers import EventEmitter, NodeProperty, now, value_from_xml from ..logging import _LOGGER if TYPE_CHECKING: from . import Nodes class NodeBase: """Base Object for Nodes and Groups/Scenes.""" has_children = False def __init__( self, nodes: Nodes, address: str, name: str, status: float, family_id: str | None = None, aux_properties: dict[str, NodeProperty] | None = None, pnode: str | None = None, flag: int = 0, ) -> None: """Initialize a Node Base class.""" self._aux_properties = aux_properties if aux_properties is not None else {} self._family = NODE_FAMILY_ID.get(family_id) self._id: str = address self._name = name self._nodes = nodes self._notes: str | None = None self._primary_node = pnode self._flag = flag self._status = status self._last_update = now() self._last_changed = self._last_update self.isy = nodes.isy self.status_events = EventEmitter() def __str__(self): """Return a string representation of the node.""" return f"{type(self).__name__}({self._id})" @property def aux_properties(self) -> dict[str, NodeProperty]: """Return the aux properties that were in the Node Definition.""" return self._aux_properties @property def address(self) -> str: """Return the Node ID.""" return self._id @property def description(self) -> str | None: """Return the description of the node from it's notes.""" if self._notes is None: _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") return self._notes[TAG_DESCRIPTION] @property def family(self) -> str | None: """Return the ISY Family category.""" return self._family @property def flag(self) -> int: """Return the flag of the current node as a property.""" return self._flag @property def folder(self) -> str | None: """Return the folder of the current node as a property.""" return self._nodes.get_folder(self.address) @property def is_load(self) -> bool | None: """Return the isLoad property of the node from it's notes.""" if self._notes is None: _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") return self._notes[TAG_IS_LOAD] @property def last_changed(self) -> datetime: """Return the UTC Time of the last status change for this node.""" return self._last_changed @property def last_update(self) -> datetime: """Return the UTC Time of the last update for this node.""" return self._last_update @property def location(self) -> str | None: """Return the location of the node from it's notes.""" if self._notes is None: _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") return self._notes[TAG_LOCATION] @property def name(self) -> str: """Return the name of the Node.""" return self._name @property def primary_node(self) -> str | None: """Return just the parent/primary node address. This is similar to Node.parent_node but does not return the whole Node class, and will return itself if it is the primary node/group. """ return self._primary_node @property def spoken(self) -> str | None: """Return the text of the Spoken property inside the group notes.""" if self._notes is None: _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") return self._notes[TAG_SPOKEN] @property def status(self) -> float: """Return the current node state.""" return self._status @status.setter def status(self, value: float) -> float: """Set the current node state and notify listeners.""" if self._status != value: self._status = value self._last_changed = now() self.status_events.notify(self.status_feedback) return self._status @property def status_feedback(self) -> dict[str, str | datetime]: """Return information for a status change event.""" return { TAG_ADDRESS: self.address, ATTR_STATUS: self._status, ATTR_LAST_CHANGED: self._last_changed, ATTR_LAST_UPDATE: self._last_update, } async def get_notes(self) -> dict[str, str | None]: """Retrieve and parse the notes for a given node. Notes are not retrieved unless explicitly requested by a call to this function. """ notes_xml = await self.isy.conn.request( self.isy.conn.compile_url([URL_NODES, self._id, URL_NOTES]), ok404=True ) spoken = None is_load = None description = None location = None if notes_xml is not None and notes_xml != "": try: notes_dom = minidom.parseString(notes_xml) except XML_ERRORS as exc: _LOGGER.error("%s: Node Notes %s", XML_PARSE_ERROR, notes_xml) raise ISYResponseParseError from exc spoken = value_from_xml(notes_dom, TAG_SPOKEN) location = value_from_xml(notes_dom, TAG_LOCATION) description = value_from_xml(notes_dom, TAG_DESCRIPTION) is_load = value_from_xml(notes_dom, TAG_IS_LOAD) return { TAG_SPOKEN: spoken, TAG_IS_LOAD: is_load == XML_TRUE, TAG_DESCRIPTION: description, TAG_LOCATION: location, } async def update(self, event=None, wait_time=0, xmldoc=None): """Update the group with values from the controller.""" self.update_last_update() def update_property(self, prop: NodeProperty) -> None: """Update an aux property for the node when received.""" if not isinstance(prop, NodeProperty): _LOGGER.error("Could not update property value. Invalid type provided.") return self.update_last_update() aux_prop = self.aux_properties.get(prop.control) if aux_prop: if prop.uom == "" and aux_prop.uom != "": # Guard against overwriting known UOM with blank UOM (ISYv4). prop.uom = aux_prop.uom if aux_prop == prop: return self.aux_properties[prop.control] = prop self.update_last_changed() self.status_events.notify(self.status_feedback) def update_last_changed(self, timestamp: datetime | None = None) -> None: """Set the UTC Time of the last status change for this node.""" if timestamp is None: timestamp = now() self._last_changed = timestamp def update_last_update(self, timestamp: datetime | None = None) -> None: """Set the UTC Time of the last update for this node.""" if timestamp is None: timestamp = now() self._last_update = timestamp async def send_cmd(self, cmd: str, val=None, uom=None, query=None) -> bool: """Send a command to the device.""" value = str(val) if val is not None else None _uom = str(uom) if uom is not None else None req = [URL_NODES, str(self._id), METHOD_COMMAND, cmd] if value: req.append(value) if _uom: req.append(_uom) req_url = self.isy.conn.compile_url(req, query) if not await self.isy.conn.request(req_url): _LOGGER.warning( "ISY could not send %s command to %s.", COMMAND_FRIENDLY_NAME.get(cmd), self._id, ) return False _LOGGER.debug("ISY command %s sent to %s.", COMMAND_FRIENDLY_NAME.get(cmd), self._id) return True async def beep(self) -> bool: """Identify physical device by sound (if supported).""" return await self.send_cmd(CMD_BEEP) async def brighten(self) -> bool: """Increase brightness of a device by ~3%.""" return await self.send_cmd(CMD_BRIGHTEN) async def dim(self) -> bool: """Decrease brightness of a device by ~3%.""" return await self.send_cmd(CMD_DIM) async def disable(self) -> bool: """Send command to the node to disable it.""" if not await self.isy.conn.request( self.isy.conn.compile_url([URL_NODES, str(self._id), CMD_DISABLE]) ): _LOGGER.warning("ISY could not %s %s.", CMD_DISABLE, self._id) return False return True async def enable(self) -> bool: """Send command to the node to enable it.""" if not await self.isy.conn.request(self.isy.conn.compile_url([URL_NODES, str(self._id), CMD_ENABLE])): _LOGGER.warning("ISY could not %s %s.", CMD_ENABLE, self._id) return False return True async def fade_down(self) -> bool: """Begin fading down (dim) a device.""" return await self.send_cmd(CMD_FADE_DOWN) async def fade_stop(self) -> bool: """Stop fading a device.""" return await self.send_cmd(CMD_FADE_STOP) async def fade_up(self) -> bool: """Begin fading up (dim) a device.""" return await self.send_cmd(CMD_FADE_UP) async def fast_off(self) -> bool: """Start manually brightening a device.""" return await self.send_cmd(CMD_OFF_FAST) async def fast_on(self) -> bool: """Start manually brightening a device.""" return await self.send_cmd(CMD_ON_FAST) async def query(self) -> bool: """Request the ISY query this node.""" return await self.isy.query(address=self.address) async def turn_off(self) -> bool: """Turn off the nodes/group in the ISY.""" return await self.send_cmd(CMD_OFF) async def turn_on(self, val: int | None = None) -> bool: """ Turn the node on. | [optional] val: The value brightness value (0-255) for the node. """ if val is None or type(self).__name__ == "Group": cmd = CMD_ON elif int(val) > 0: cmd = CMD_ON val = str(val) if int(val) <= 255 else None else: cmd = CMD_OFF val = None return await self.send_cmd(cmd, val) async def rename(self, new_name: str) -> bool: """ Rename the node or group in the ISY. Note: Feature was added in ISY v5.2.0, this will fail on earlier versions. """ # /rest/nodes//change?name= req_url = self.isy.conn.compile_url( [URL_NODES, self._id, URL_CHANGE], query={TAG_NAME: new_name}, ) if not await self.isy.conn.request(req_url): _LOGGER.warning( "ISY could not update name for %s.", self._id, ) return False _LOGGER.debug("ISY renamed %s to %s.", self._id, new_name) self._name = new_name return True PyISY-3.4.0/pyisy/programs/000077500000000000000000000000001477231106100155045ustar00rootroot00000000000000PyISY-3.4.0/pyisy/programs/__init__.py000077500000000000000000000344031477231106100176240ustar00rootroot00000000000000"""Init for management of ISY Programs.""" from __future__ import annotations import asyncio from operator import itemgetter from typing import TYPE_CHECKING from xml.dom import minidom from dateutil import parser from ..constants import ( ATTR_ID, ATTR_PARENT, ATTR_STATUS, EMPTY_TIME, TAG_ENABLED, TAG_FOLDER, TAG_NAME, TAG_PRGM_FINISH, TAG_PRGM_RUN, TAG_PRGM_RUNNING, TAG_PRGM_STATUS, TAG_PROGRAM, UPDATE_INTERVAL, XML_OFF, XML_ON, XML_TRUE, ) from ..exceptions import XML_ERRORS, XML_PARSE_ERROR from ..helpers import attr_from_element, now, value_from_xml from ..logging import _LOGGER from ..nodes import NodeIterator as ProgramIterator from .folder import Folder from .program import Program if TYPE_CHECKING: from ..isy import ISY class Programs: """ This class handles the ISY programs. This class can be used as a dictionary to navigate through the controller's structure to objects of type :class:`pyisy.programs.Program` and :class:`pyisy.programs.Folder` (when requested) that represent objects on the controller. | isy: The ISY device class | root: Program/Folder ID representing the current level of navigation. | addresses: List of program and folder IDs. | pnames: List of the program and folder names. | pparents: List of the program and folder parent IDs. | pobjs: List of program and folder objects. | ptypes: List of the program and folder types. | xml: XML string from the controller detailing the programs and folders. :ivar all_lower_programs: A list of all programs below the current navigation level. Does not return folders. :ivar children: A list of the children immediately below the current navigation level. :ivar leaf: The child object representing the current item in navigation. This is useful for getting a folder to act as a program. :ivar name: The name of the program at the current level of navigation. """ def __init__( self, isy: ISY, root: str | None = None, addresses: list[str] | None = None, pnames: list[str] | None = None, pparents: list[str] | None = None, pobjs: list[Program | Folder] | None = None, ptypes: list[str] | None = None, xml: str | None = None, _address_index: dict[str, int] | None = None, _pnames_index: dict[str, int] | None = None, ) -> None: """Initialize the Programs ISY programs manager class.""" self.isy = isy self.root = root self.addresses: list[str] = [] self._address_index: dict[str, int] = {} self.pnames: list[str] = [] self._pnames_index: dict[str, int] = {} self.pparents: list[str] = [] self.pobjs: list[Program | Folder] = [] self.ptypes: list[str] = [] if xml is not None: self.parse(xml) return if addresses is not None: self.addresses = addresses self._address_index = _address_index or {address: i for i, address in enumerate(addresses)} if pnames is not None: self.pnames = pnames self._pnames_index = _pnames_index or {name: i for i, name in enumerate(pnames)} if pparents is not None: self.pparents = pparents if pobjs is not None: self.pobjs = pobjs if ptypes is not None: self.ptypes = ptypes def __str__(self) -> str: """Return a string representation of the program manager.""" if self.root is None: return "Folder " ind = self._address_index[self.root] if self.ptypes[ind] == TAG_FOLDER: return f"Folder ({self.root})" if self.ptypes[ind] == TAG_PROGRAM: return f"Program ({self.root})" return "" def __repr__(self) -> str: """Return a string showing the hierarchy of the program manager.""" # get and sort children folders: list[tuple[str, str, str]] = [] programs: list[tuple[str, str, str]] = [] for child in self.children: if child[0] == TAG_FOLDER: folders.append(child) elif child[0] == TAG_PROGRAM: programs.append(child) # initialize data folders.sort(key=itemgetter(1)) programs.sort(key=itemgetter(1)) out = str(self) + "\n" # format folders for fold in folders: fold_obj = self[fold[2]] out += f" + {fold[1]}: Folder({fold[2]})\n" for line in repr(fold_obj).split("\n")[1:]: out += f" | {line}\n" out += " -\n" # format programs for prog in programs: out += f" {prog[1]}: {self[prog[2]]}\n" return out def __iter__(self) -> ProgramIterator: """ Return an iterator that iterates through all the programs. Does not iterate folders. Only Programs that are beneath the current folder in navigation. """ iter_data = self.all_lower_programs return ProgramIterator(self, iter_data, delta=1) def __reversed__(self) -> ProgramIterator: """Return an iterator that goes in reverse order.""" iter_data = self.all_lower_programs return ProgramIterator(self, iter_data, delta=-1) def update_received(self, xmldoc: minidom.Document) -> None: """Update programs from EventStream message.""" # pylint: disable=attribute-defined-outside-init xml = xmldoc.toxml() address = value_from_xml(xmldoc, ATTR_ID).zfill(4) try: pobj = self.get_by_id(address).leaf except (KeyError, ValueError): _LOGGER.warning("ISY received program update for new program; reload the module to update") return # this is a new program that hasn't been registered if not isinstance(pobj, Program): return new_status = False if f"<{TAG_PRGM_STATUS}>" in xml: status = value_from_xml(xmldoc, TAG_PRGM_STATUS) if status == "21": pobj.ran_then += 1 new_status = True elif status == "31": pobj.ran_else += 1 if f"<{TAG_PRGM_RUN}>" in xml: pobj.last_run = parser.parse(value_from_xml(xmldoc, TAG_PRGM_RUN)) if f"<{TAG_PRGM_FINISH}>" in xml: pobj.last_finished = parser.parse(value_from_xml(xmldoc, TAG_PRGM_FINISH)) if XML_ON in xml or XML_OFF in xml: pobj.enabled = XML_ON in xml # Update Status last and make sure the change event fires, but only once. if pobj.status != new_status: pobj.status = new_status else: # Status didn't change, but something did, so fire the event. pobj.status_events.notify(new_status) _LOGGER.debug("ISY Updated Program: %s", address) def parse(self, xml: str) -> None: """ Parse the XML from the controller and updates the state of the manager. xml: XML string from the controller. """ try: xmldoc = minidom.parseString(xml) except XML_ERRORS: _LOGGER.error("%s: Programs, programs not loaded", XML_PARSE_ERROR) return plastup = now() # get nodes features = xmldoc.getElementsByTagName(TAG_PROGRAM) for feature in features: # id, name, and status address = attr_from_element(feature, ATTR_ID) pname = value_from_xml(feature, TAG_NAME) _LOGGER.debug("Parsing Program/Folder: %s [%s]", pname, address) pparent = attr_from_element(feature, ATTR_PARENT) pstatus = attr_from_element(feature, ATTR_STATUS) == XML_TRUE if attr_from_element(feature, TAG_FOLDER) == XML_TRUE: # folder specific parsing ptype = TAG_FOLDER data = {"pstatus": pstatus, "plastup": plastup} else: # program specific parsing ptype = TAG_PROGRAM # last run time plastrun = value_from_xml(feature, "lastRunTime", EMPTY_TIME) if plastrun != EMPTY_TIME: plastrun = parser.parse(plastrun) # last finish time plastfin = value_from_xml(feature, "lastFinishTime", EMPTY_TIME) if plastfin != EMPTY_TIME: plastfin = parser.parse(plastfin) # enabled, run at startup, running penabled = bool(attr_from_element(feature, TAG_ENABLED) == XML_TRUE) pstartrun = bool(attr_from_element(feature, "runAtStartup") == XML_TRUE) prunning = bool(attr_from_element(feature, TAG_PRGM_RUNNING) != "idle") # create data dictionary data = { "pstatus": pstatus, "plastrun": plastrun, "plastfin": plastfin, "penabled": penabled, "pstartrun": pstartrun, "prunning": prunning, "plastup": plastup, } # add or update object if it already exists if address not in self.addresses: if ptype == TAG_FOLDER: pobj = Folder(self, address, pname, **data) else: pobj = Program(self, address, pname, **data) self.insert(address, pname, pparent, pobj, ptype) else: pobj = self.get_by_id(address).leaf pobj._update(data=data) _LOGGER.info("ISY Loaded/Updated Programs") async def update(self, wait_time=UPDATE_INTERVAL, address=None): """ Update the status of the programs and folders. | wait_time: How long to wait before updating. | address: The program ID to update. """ await asyncio.sleep(wait_time) xml = await self.isy.conn.get_programs(address) if xml is not None: self.parse(xml) else: _LOGGER.warning("ISY Failed to update programs.") def insert(self, address: str, pname: str, pparent: str, pobj: Program | Programs, ptype: str) -> None: """ Insert a new program or folder into the manager. | address: The ID of the program or folder. | pname: The name of the program or folder. | pparent: The parent of the program or folder. | pobj: The object representing the program or folder. | ptype: The type of the item being added (program/folder). """ self.addresses.append(address) self._address_index[address] = len(self.addresses) - 1 self.pnames.append(pname) self._pnames_index[pname] = len(self.pnames) - 1 self.pparents.append(pparent) self.ptypes.append(ptype) self.pobjs.append(pobj) def __getitem__(self, val: str) -> Program | Folder | None: """ Navigate through the hierarchy using names or IDs. | val: Name or ID to navigate to. """ if val in self._address_index: fun = self.get_by_id elif val in self._pnames_index: fun = self.get_by_name else: try: val = int(val) fun = self.get_by_index except (TypeError, ValueError) as err: raise KeyError("Unrecognized Key: " + str(val)) from err try: return fun(val) except (ValueError, KeyError, IndexError): return None def __setitem__(self, val, value): """Set the item value.""" return def get_by_name(self, val: str) -> Program | Folder | Programs | None: """ Get a child program/folder with the given name. | val: The name of the child program/folder to look for. """ i = self._pnames_index.get(val) if i is not None and (self.root is None or self.pparents[i] == self.root): return self.get_by_index(i) return None def get_by_id(self, address: str) -> Program | Folder | Programs: """ Get a program/folder with the given ID. | address: The program/folder ID to look for. """ return self.get_by_index(self._address_index[address]) def get_by_index(self, i: int) -> Program | Folder | Programs: """ Get the program/folder at the given index. | i: The program/folder index. """ if self.ptypes[i] == TAG_FOLDER: return Programs( isy=self.isy, root=self.addresses[i], addresses=self.addresses, pnames=self.pnames, pparents=self.pparents, pobjs=self.pobjs, ptypes=self.ptypes, _address_index=self._address_index, _pnames_index=self._pnames_index, ) return self.pobjs[i] @property def children(self) -> list[tuple[str, str, str]]: """Return the children of the class.""" return [ (self.ptypes[ind], self.pnames[ind], self.addresses[ind]) for ind in range(len(self.pnames)) if self.pparents[ind] == self.root ] @property def leaf(self) -> Program | Folder: """Return the leaf property.""" if self.root is not None: ind = self._address_index[self.root] if self.pobjs[ind] is not None: return self.pobjs[ind] return self @property def name(self) -> str: """Return the name of the path.""" if self.root is not None: return self.pnames[self._address_index[self.root]] return "" @property def all_lower_programs(self) -> list[tuple[str, str, str]]: """Return all lower programs in a path.""" output = [] myname = self.name + "/" for dtype, name, ident in self.children: if dtype == TAG_PROGRAM: output.append((dtype, myname + name, ident)) else: output += [ (dtype2, myname + name2, ident2) for (dtype2, name2, ident2) in self[ident].all_lower_programs ] return output PyISY-3.4.0/pyisy/programs/folder.py000066400000000000000000000126631477231106100173410ustar00rootroot00000000000000"""ISY Program Folders.""" from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any from ..constants import ( ATTR_LAST_CHANGED, ATTR_LAST_UPDATE, ATTR_STATUS, CMD_DISABLE, CMD_ENABLE, CMD_RUN, CMD_RUN_ELSE, CMD_RUN_THEN, CMD_STOP, PROTO_FOLDER, TAG_ADDRESS, TAG_FOLDER, UPDATE_INTERVAL, URL_PROGRAMS, ) from ..helpers import EventEmitter, now from ..logging import _LOGGER if TYPE_CHECKING: from . import Programs class Folder: """ Object representing a program folder on the ISY device. | programs: The folder manager object. | address: The folder ID. | pname: The folder name. | pstatus: The current folder status. :ivar dtype: Returns the type of the object (folder). :ivar status: Watched property representing the current status of the folder. """ dtype = TAG_FOLDER def __init__(self, programs: Programs, address: str, pname: str, pstatus: int, plastup: datetime) -> None: """Initialize the Folder class.""" self._id = address self._last_update = plastup self._last_changed = now() self._name = pname self._programs = programs self._status = pstatus self.isy = programs.isy self.status_events = EventEmitter() def __str__(self) -> str: """Return a string representation of the node.""" return f"{type(self).__name__}({self._id})" @property def address(self) -> str: """Return the program or folder ID.""" return self._id @property def last_changed(self) -> datetime: """Return the last time the program was changed in this module.""" return self._last_changed @last_changed.setter def last_changed(self, value: datetime) -> datetime: """Set the last time the program was changed.""" if self._last_changed != value: self._last_changed = value return self._last_changed @property def last_update(self) -> datetime: """Return the last time the program was updated.""" return self._last_update @last_update.setter def last_update(self, value: datetime) -> datetime: """Set the last time the program was updated.""" if self._last_update != value: self._last_update = value return self._last_update @property def leaf(self) -> Folder: """Get the leaf property.""" return self @property def name(self) -> str: """Return the name of the Node.""" return self._name @property def protocol(self) -> str: """Return the protocol for this entity.""" return PROTO_FOLDER @property def status(self) -> int: """Return the current node state.""" return self._status @status.setter def status(self, value: int) -> int: """Set the current node state and notify listeners.""" if self._status != value: self._status = value self.status_events.notify(self._status) return self._status @property def status_feedback(self) -> dict[str, Any]: """Return information for a status change event.""" return { TAG_ADDRESS: self.address, ATTR_STATUS: self._status, ATTR_LAST_CHANGED: self._last_changed, ATTR_LAST_UPDATE: self._last_update, } def _update(self, data: dict[str, Any]) -> None: """Update the folder with values from the controller.""" self._last_changed = now() self.status = data["pstatus"] async def update(self, wait_time: float = UPDATE_INTERVAL, data: dict[str, Any] | None = None) -> None: """ Update the status of the program. | data: [optional] The data to update the folder with. | wait_time: [optional] Seconds to wait before updating. """ if data is not None: self._update(data) return await self._programs.update(wait_time=wait_time, address=self._id) async def send_cmd(self, command: str) -> bool: """Run the appropriate clause of the object.""" req_url = self.isy.conn.compile_url([URL_PROGRAMS, str(self._id), command]) result = await self.isy.conn.request(req_url) if not result: _LOGGER.warning('ISY could not call "%s" on program: %s', command, self._id) return False _LOGGER.debug('ISY ran "%s" on program: %s', command, self._id) if not self.isy.auto_update: await self.update() return True async def enable(self) -> bool: """Send command to the program/folder to enable it.""" return await self.send_cmd(CMD_ENABLE) async def disable(self) -> bool: """Send command to the program/folder to enable it.""" return await self.send_cmd(CMD_DISABLE) async def run(self) -> bool: """Send a run command to the program/folder.""" return await self.send_cmd(CMD_RUN) async def run_then(self) -> bool: """Send a runThen command to the program/folder.""" return await self.send_cmd(CMD_RUN_THEN) async def run_else(self) -> bool: """Send a runElse command to the program/folder.""" return await self.send_cmd(CMD_RUN_ELSE) async def stop(self) -> bool: """Send a stop command to the program/folder.""" return await self.send_cmd(CMD_STOP) PyISY-3.4.0/pyisy/programs/program.py000066400000000000000000000140301477231106100175230ustar00rootroot00000000000000"""Representation of a program from the ISY.""" from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any from ..constants import ( CMD_DISABLE_RUN_AT_STARTUP, CMD_ENABLE_RUN_AT_STARTUP, PROTO_PROGRAM, TAG_PROGRAM, UPDATE_INTERVAL, ) from .folder import Folder if TYPE_CHECKING: from . import Programs class Program(Folder): """ Class representing a program on the ISY controller. | programs: The program manager object. | address: The ID of the program. | pname: The name of the program. | pstatus: The current status of the program. | plastup: The last time the program was updated. | plastrun: The last time the program was run. | plastfin: The last time the program finished running. | penabled: Boolean value showing if the program is enabled on the controller. | pstartrun: Boolean value showing if the if the program runs on controller start up. | prunning: Boolean value showing if the current program is running on the controller. """ dtype = TAG_PROGRAM def __init__( self, programs: Programs, address: str, pname: str, pstatus: bool, plastup: datetime, plastrun: datetime, plastfin: datetime, penabled: bool, pstartrun: bool, prunning: bool, ) -> None: """Initialize a Program class.""" super().__init__(programs, address, pname, pstatus, plastup) self._enabled = penabled self._last_finished = plastfin self._last_run = plastrun self._ran_else: int = 0 self._ran_then: int = 0 self._run_at_startup = pstartrun self._running = prunning @property def enabled(self) -> bool: """Return if the program is enabled on the controller.""" return self._enabled @enabled.setter def enabled(self, value: bool) -> bool: """Set if the program is enabled on the controller.""" if self._enabled != value: self._enabled = value return self._enabled @property def last_finished(self) -> datetime: """Return the last time the program finished running.""" return self._last_finished @last_finished.setter def last_finished(self, value: datetime) -> datetime: """Set the last time the program finished running.""" if self._last_finished != value: self._last_finished = value return self._last_finished @property def last_run(self) -> datetime: """Return the last time the program was run.""" return self._last_run @last_run.setter def last_run(self, value: datetime) -> datetime: """Set the last time the program was run.""" if self._last_run != value: self._last_run = value return self._last_run @property def protocol(self) -> str: """Return the protocol for this entity.""" return PROTO_PROGRAM @property def ran_else(self) -> int: """Return the Ran Else property for this program.""" return self._ran_else @ran_else.setter def ran_else(self, value: int) -> int: """Set the Ran Else property for this program.""" if self._ran_else != value: self._ran_else = value return self._ran_else @property def ran_then(self) -> int: """Return the Ran Then property for this program.""" return self._ran_then @ran_then.setter def ran_then(self, value: int) -> int: """Set the Ran Then property for this program.""" if self._ran_then != value: self._ran_then = value return self._ran_then @property def run_at_startup(self) -> bool: """Return if the program runs on controller start up.""" return self._run_at_startup @run_at_startup.setter def run_at_startup(self, value: bool) -> bool: """Set if the program runs on controller start up.""" if self._run_at_startup != value: self._run_at_startup = value return self._run_at_startup @property def running(self) -> bool: """Return if the current program is running on the controller.""" return self._running @running.setter def running(self, value: bool) -> bool: """Set if the current program is running on the controller.""" if self._running != value: self._running = value return self._running def _update(self, data: dict[str, Any]) -> None: """Update the program with values on the controller.""" self._enabled = data["penabled"] self._last_finished = data["plastfin"] self._last_run = data["plastrun"] self._last_update = data["plastup"] self._run_at_startup = data["pstartrun"] self._running = (data["plastrun"] >= data["plastup"]) or data["prunning"] # Update Status last and make sure the change event fires, but only once. if self.status != data["pstatus"]: self.status = data["pstatus"] else: # Status didn't change, but something did, so fire the event. self.status_events.notify(self.status) async def update(self, wait_time=UPDATE_INTERVAL, data: dict[str, Any] | None = None) -> None: """ Update the program with values on the controller. | wait_time: [optional] Seconds to wait before updating. | data: [optional] Data to update the object with. """ if data is not None: self._update(data) return await self._programs.update(wait_time, address=self._id) async def enable_run_at_startup(self) -> bool: """Send command to the program to enable it to run at startup.""" return await self.send_cmd(CMD_ENABLE_RUN_AT_STARTUP) async def disable_run_at_startup(self) -> bool: """Send command to the program to enable it to run at startup.""" return await self.send_cmd(CMD_DISABLE_RUN_AT_STARTUP) PyISY-3.4.0/pyisy/variables/000077500000000000000000000000001477231106100156225ustar00rootroot00000000000000PyISY-3.4.0/pyisy/variables/__init__.py000066400000000000000000000204011477231106100177300ustar00rootroot00000000000000"""ISY Variables.""" from __future__ import annotations from asyncio import sleep from typing import TYPE_CHECKING from xml.dom import minidom from dateutil import parser from ..constants import ( ATTR_ID, ATTR_INIT, ATTR_PRECISION, ATTR_TS, ATTR_VAL, ATTR_VAR, TAG_NAME, TAG_TYPE, TAG_VARIABLE, ) from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError from ..helpers import attr_from_element, attr_from_xml, now, value_from_xml from ..logging import _LOGGER from .variable import Variable if TYPE_CHECKING: from ..isy import ISY EMPTY_VARIABLE_RESPONSES = [ "/CONF/INTEGER.VAR not found", "/CONF/STATE.VAR not found", '', ] class Variables: """ This class handles the ISY variables. This class can be used as a dictionary to navigate through the controller's structure to objects of type :class:`pyisy.variables.Variable` that represent objects on the controller. | isy: The ISY object. | root: The ID of the current level of navigation. | vids: List of variable IDs from the controller. | vnames: List of variable names form the controller. | vobjs: List of variable objects. | xml: XML string from the controller detailing the device's variables. :ivar children: List of the children below the current level of navigation. """ def __init__( self, isy: ISY, root=None, vids: dict[int, list[int]] | None = None, vnames: dict[int, dict[int, str]] | None = None, vobjs: dict[int, dict[int, Variable]] | None = None, def_xml: list[str] | None = None, var_xml: str | None = None, ) -> None: """Initialize a Variables ISY Variable Manager class.""" self.isy = isy self.root = root self.vids: dict[int, list[int]] = {1: [], 2: []} self.vobjs: dict[int, dict[int, Variable]] = {1: {}, 2: {}} self.vnames: dict[int, dict[int, str]] = {1: {}, 2: {}} if vids is not None and vnames is not None and vobjs is not None: self.vids = vids self.vnames = vnames self.vobjs = vobjs return valid_definitions: bool = False if def_xml is not None: valid_definitions = self.parse_definitions(def_xml) if valid_definitions and var_xml is not None: self.parse(var_xml) else: _LOGGER.warning("No valid variables defined") def __str__(self) -> str: """Return a string representation of the variable manager.""" if self.root is None: return "Variable Collection" return f"Variable Collection (Type: {self.root})" def __repr__(self) -> str: """Return a string representing the children variables.""" if self.root is None: return repr(self[1]) + repr(self[2]) out = str(self) + "\n" for child in self.children: out += f" {child[1]}: Variable({child[2]})\n" return out def parse_definitions(self, xmls: list[str]) -> bool: """Parse the XML Variable Definitions from the ISY.""" valid_definitions = False for ind in range(2): # parse definitions if xmls[ind] is None or xmls[ind] in EMPTY_VARIABLE_RESPONSES: # No variables of this type defined. _LOGGER.info("No Type %s variables defined", ind + 1) continue try: xmldoc = minidom.parseString(xmls[ind]) except XML_ERRORS: _LOGGER.error("%s: Type %s Variables", XML_PARSE_ERROR, ind + 1) continue else: features = xmldoc.getElementsByTagName(TAG_VARIABLE) for feature in features: vid = int(attr_from_element(feature, ATTR_ID)) self.vnames[ind + 1][vid] = attr_from_element(feature, TAG_NAME) valid_definitions = True return valid_definitions def parse(self, xml: str) -> None: """Parse XML from the controller with details about the variables.""" try: xmldoc = minidom.parseString(xml) except XML_ERRORS as exc: _LOGGER.error("%s: Variables", XML_PARSE_ERROR) raise ISYResponseParseError(XML_PARSE_ERROR) from exc features = xmldoc.getElementsByTagName(ATTR_VAR) for feature in features: vid = int(attr_from_element(feature, ATTR_ID)) vtype = int(attr_from_element(feature, TAG_TYPE)) init = value_from_xml(feature, ATTR_INIT) prec = int(value_from_xml(feature, ATTR_PRECISION, 0)) val = value_from_xml(feature, ATTR_VAL) ts_raw = value_from_xml(feature, ATTR_TS) timestamp = parser.parse(ts_raw) vname = self.vnames[vtype].get(vid, "") vobj = self.vobjs[vtype].get(vid) if vobj is None: vobj = Variable(self, vid, vtype, vname, init, val, timestamp, prec) self.vids[vtype].append(vid) self.vobjs[vtype][vid] = vobj else: vobj.init = init vobj.status = val vobj.prec = prec vobj.last_edited = timestamp _LOGGER.info("ISY Loaded Variables") async def update(self, wait_time: int = 0) -> None: """ Update the variable objects with data from the controller. | wait_time: Seconds to wait before updating. """ await sleep(wait_time) xml = await self.isy.conn.get_variables() if xml is not None: self.parse(xml) else: _LOGGER.warning("ISY Failed to update variables.") def update_received(self, xmldoc: minidom.Document) -> None: """Process an update received from the event stream.""" xml = xmldoc.toxml() vtype = int(attr_from_xml(xmldoc, ATTR_VAR, TAG_TYPE)) vid = int(attr_from_xml(xmldoc, ATTR_VAR, ATTR_ID)) try: vobj = self.vobjs[vtype][vid] except KeyError: return # this is a new variable that hasn't been loaded vobj.last_update = now() if f"<{ATTR_INIT}>" in xml: vobj.init = int(value_from_xml(xmldoc, ATTR_INIT)) else: vobj.status = int(value_from_xml(xmldoc, ATTR_VAL)) vobj.prec = int(value_from_xml(xmldoc, ATTR_PRECISION, 0)) vobj.last_edited = parser.parse(value_from_xml(xmldoc, ATTR_TS)) _LOGGER.debug("ISY Updated Variable: %s.%s", str(vtype), str(vid)) def __getitem__(self, val: int | str) -> Variable: """ Navigate through the variables by ID or name. | val: Name or ID for navigation. """ if self.root is None: if val in [1, 2]: return Variables(self.isy, val, self.vids, self.vnames, self.vobjs) raise KeyError(f"Unknown variable type: {val}") if isinstance(val, int): try: return self.vobjs[self.root][val] except (ValueError, KeyError) as err: raise KeyError(f"Unrecognized variable id: {val}") from err for vid, vname in self.vnames[self.root]: if vname == val: return self.vobjs[self.root][vid] raise KeyError(f"Unrecognized variable name: {val}") def __setitem__(self, val, value): """Handle the setitem function for the Class.""" return def get_by_name(self, val: str) -> Variable | None: """ Get a variable with the given name. | val: The name of the variable to look for. """ vtype, _, vid = next(item for item in self.children if val in item) if not vid and vtype: raise KeyError(f"Unrecognized variable name: {val}") return self.vobjs[vtype].get(vid) @property def children(self) -> list[tuple[int, str, int]]: """Get the children of the class.""" types = [1, 2] if self.root is None else [self.root] return [ ( vtype, self.vnames[vtype].get(self.vids[vtype][ind], ""), self.vids[vtype][ind], ) for vtype in types for ind in range(len(self.vids[vtype])) ] PyISY-3.4.0/pyisy/variables/variable.py000066400000000000000000000155671477231106100177770ustar00rootroot00000000000000"""Manage variables from the ISY.""" from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from ..constants import ( ATTR_INIT, ATTR_LAST_CHANGED, ATTR_LAST_UPDATE, ATTR_PRECISION, ATTR_SET, ATTR_STATUS, ATTR_TS, PROTO_INT_VAR, PROTO_STATE_VAR, TAG_ADDRESS, URL_VARIABLES, VAR_INTEGER, ) from ..helpers import EventEmitter, now from ..logging import _LOGGER if TYPE_CHECKING: from . import Variables class Variable: """ Object representing a variable on the controller. | variables: The variable manager object. | vid: List of variable IDs. | vtype: List of variable types. | init: List of values that variables initialize to when the controller starts. | val: The current variable value. | ts: The timestamp for the last time the variable was edited. :ivar init: Watched property that represents the value the variable initializes to when the controller boots. :ivar lastEdit: Watched property that indicates the last time the variable was edited. :ivar val: Watched property that represents the value of the variable. """ def __init__( self, variables: Variables, vid: int, vtype: int, vname: str, init: object | None, status: object | None, timestamp: datetime, prec: int, ) -> None: """Initialize a Variable class.""" super().__init__() self._id = vid self._init = init self._last_edited = timestamp self._last_update = now() self._last_changed = self._last_update self._name = vname self._prec = prec self._status = status self._type = vtype self._variables = variables self.isy = variables.isy self.status_events = EventEmitter() def __str__(self) -> str: """Return a string representation of the variable.""" return f"Variable(type={self._type}, id={self._id}, value={self.status}, init={self.init})" def __repr__(self) -> str: """Return a string representation of the variable.""" return str(self) @property def address(self) -> str: """Return the formatted Variable Type and ID.""" return f"{self._type}.{self._id}" @property def init(self) -> object | None: """Return the initial state.""" return self._init @init.setter def init(self, value: object | None) -> object | None: """Set the initial state and notify listeners.""" if self._init != value: self._init = value self._last_changed = now() self.status_events.notify(self.status_feedback) return self._init @property def last_changed(self) -> datetime: """Return the UTC Time of the last status change for this node.""" return self._last_changed @property def last_edited(self) -> datetime: """Return the last edit time.""" return self._last_edited @last_edited.setter def last_edited(self, value: datetime) -> datetime: """Set the last edited time.""" if self._last_edited != value: self._last_edited = value return self._last_edited @property def last_update(self) -> datetime: """Return the UTC Time of the last update for this node.""" return self._last_update @last_update.setter def last_update(self, value: datetime) -> datetime: """Set the last update time.""" if self._last_update != value: self._last_update = value return self._last_update @property def protocol(self) -> str: """Return the protocol for this entity.""" return PROTO_INT_VAR if self._type == VAR_INTEGER else PROTO_STATE_VAR @property def name(self) -> str: """Return the Variable Name.""" return self._name @property def prec(self) -> int: """Return the Variable Precision.""" return self._prec @prec.setter def prec(self, value: int) -> int: """Set the current node state and notify listeners.""" if self._prec != value: self._prec = value self._last_changed = now() self.status_events.notify(self.status_feedback) return self._prec @property def status(self) -> object | None: """Return the current node state.""" return self._status @status.setter def status(self, value: object | None) -> object | None: """Set the current node state and notify listeners.""" if self._status != value: self._status = value self._last_changed = now() self.status_events.notify(self.status_feedback) return self._status @property def status_feedback(self) -> dict[str, object | None]: """Return information for a status change event.""" return { TAG_ADDRESS: self.address, ATTR_STATUS: self._status, ATTR_INIT: self._init, ATTR_PRECISION: self._prec, ATTR_TS: self._last_edited, ATTR_LAST_CHANGED: self._last_changed, ATTR_LAST_UPDATE: self._last_update, } @property def vid(self) -> int: """Return the Variable ID.""" return self._id async def update(self, wait_time: int = 0) -> None: """ Update the object with the variable's parameters from the controller. | wait_time: Seconds to wait before updating. """ self._last_update = now() await self._variables.update(wait_time) async def set_init(self, value: float) -> bool: """ Set the initial value for the variable after the controller boots. | val: The value to have the variable initialize to. """ return await self.set_value(value, True) async def set_value(self, value: float, init: bool = False) -> bool: """ Set the value of the variable. | val: The value to set the variable to. ISY Version 5 firmware will automatically convert float back to int. """ req_url = self.isy.conn.compile_url( [ URL_VARIABLES, ATTR_INIT if init else ATTR_SET, str(self._type), str(self._id), str(value), ] ) if not await self.isy.conn.request(req_url): _LOGGER.warning( "ISY could not set variable%s: %s.%s", " init value" if init else "", str(self._type), str(self._id), ) return False _LOGGER.debug( "ISY set variable%s: %s.%s", " init value" if init else "", str(self._type), str(self._id), ) return True PyISY-3.4.0/pyproject.toml000066400000000000000000000074351477231106100154220ustar00rootroot00000000000000[build-system] requires = ["setuptools>=62.3,<79.0", "wheel","setuptools_scm[toml]>=6.2",] build-backend = "setuptools.build_meta" [project] name = "pyisy" description = "Python module to talk to ISY devices from UDI." license = {text = "Apache-2.0"} keywords = ["home", "automation", "isy", "isy994", "isy-994", "UDI", "polisy", "eisy"] authors = [ {name = "Ryan Kraus", email = "automicus@gmail.com"}, {name = "shbatm", email = "support@shbatm.com"} ] readme = "README.md" classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Home Automation", ] dynamic = ["version"] requires-python = ">=3.9" dependencies = [ "aiohttp>=3.8.1", "python-dateutil>=2.8.1", "requests>=2.28.1", "colorlog>=6.6.0", ] [project.urls] "Source Code" = "https://github.com/automicus/PyISY" "Homepage" = "https://github.com/automicus/PyISY" [tool.setuptools_scm] [tool.ruff] target-version = "py39" line-length = 110 [tool.ruff.lint] ignore = [ "S101", # use of assert "TID252", # skip "SLF001", # design choice "SIM110", # this is slower "S318", # intentional "TRY003", # nice to have "PLR2004", # to many to fix right now "PLR0913", # ship has sail on this one "PLR0911", # ship has sail on this one "PLR0912", # hard to fix without a major refactor "PLR0915", # hard to fix without a major refactor "PYI034", # enable when we drop Py3.10 "RUF003", # probably not worth fixing "SIM105", # this is slower ] select = [ "ASYNC", # async rules "B", # flake8-bugbear "C4", # flake8-comprehensions "S", # flake8-bandit "F", # pyflake "E", # pycodestyle "W", # pycodestyle "UP", # pyupgrade "I", # isort "RUF", # ruff specific "FLY", # flynt "G", # flake8-logging-format , "PERF", # Perflint "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "PTH", # flake8-pathlib "PYI", # flake8-pyi "RET", # flake8-return "RSE", # flake8-raise , "SIM", # flake8-simplify "SLF", # flake8-self "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print "TID", # Tidy imports "TRY", # tryceratops ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", "SLF001", "PLR2004", # too many to fix right now "PT011", # too many to fix right now "PT006", # too many to fix right now "PGH003", # too many to fix right now "PT007", # too many to fix right now "PT027", # too many to fix right now "PLW0603" , # too many to fix right now "PLR0915", # too many to fix right now "FLY002", # too many to fix right now "PT018", # too many to fix right now "PLR0124", # too many to fix right now "SIM202" , # too many to fix right now "PT012" , # too many to fix right now "TID252", # too many to fix right now "PLR0913", # skip this one "SIM102" , # too many to fix right now "SIM108", # too many to fix right now "T201", # too many to fix right now "PT004", # nice to have ] "bench/**/*" = [ "T201", # intended ] "examples/**/*" = [ "T201", # intended ] "setup.py" = ["D100"] "conftest.py" = ["D100"] "docs/conf.py" = [ "D100", "PTH100" ] [tool.pytest.ini_options] testpaths = [ "tests", ] norecursedirs = [ ".git", ] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" PyISY-3.4.0/requirements-dev.txt000066400000000000000000000003351477231106100165360ustar00rootroot00000000000000pylint>=2.15.10 pylint-strict-informational>=0.1 black>=22.12.0 codespell>=2.2.2 flake8-docstrings>=1.6.0 flake8>=6.0.0 isort>=5.12.0 pydocstyle>=6.2.3 pyupgrade>=3.3.1 pre-commit>=2.4.0 sphinx>=3.4.3 recommonmark>=0.7.1 PyISY-3.4.0/requirements.txt000066400000000000000000000001071477231106100157570ustar00rootroot00000000000000aiohttp>=3.8.1 python-dateutil>=2.8.1 requests>=2.28.1 colorlog>=6.6.0 PyISY-3.4.0/setup.cfg000066400000000000000000000007011477231106100143140ustar00rootroot00000000000000[tool:pytest] testpaths = tests norecursedirs = .git testing_config [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build doctests = True # To work with Black max-line-length = 88 # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring # W504 line break after binary operator ignore = E501, W503, E203, D202, W504 PyISY-3.4.0/setup.py000077500000000000000000000022731477231106100142160ustar00rootroot00000000000000"""Module Setup File for PIP Installation.""" import pathlib from setuptools import find_packages, setup HERE = pathlib.Path(__file__).parent README = (HERE / "README.md").read_text() setup( name="pyisy", version_format="{tag}", license="Apache License 2.0", url="https://github.com/automicus/PyISY", author="Ryan Kraus", author_email="automicus@gmail.com", description="Python module to talk to ISY994 from UDI.", long_description=README, long_description_content_type="text/markdown", packages=find_packages(), zip_safe=False, include_package_data=True, platforms="any", use_scm_version=True, setup_requires=["setuptools_scm"], install_requires=[ "aiohttp>=3.8.1", "python-dateutil>=2.8.1", "requests>=2.28.1", "colorlog>=6.6.0", ], keywords=["home automation", "isy", "isy994", "isy-994", "UDI"], classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Home Automation", ], )