├── .devcontainer ├── Dockerfile ├── devcontainer.json └── postCreate.sh ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── pythonpublish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── extensions.json └── settings.json ├── .yamllint ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── docs ├── Makefile ├── conf.py ├── constants.rst ├── index.rst ├── library.rst ├── make.bat ├── quickstart.rst └── requirements.txt ├── pyisy ├── __init__.py ├── __main__.py ├── clock.py ├── configuration.py ├── connection.py ├── constants.py ├── events │ ├── __init__.py │ ├── eventreader.py │ ├── strings.py │ ├── tcpsocket.py │ └── websocket.py ├── exceptions.py ├── helpers.py ├── isy.py ├── logging.py ├── networking.py ├── node_servers.py ├── nodes │ ├── __init__.py │ ├── group.py │ ├── node.py │ └── nodebase.py ├── programs │ ├── __init__.py │ ├── folder.py │ └── program.py └── variables │ ├── __init__.py │ └── variable.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg └── setup.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT=3-bullseye 2 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 3 | 4 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 5 | 6 | RUN \ 7 | apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 9 | git \ 10 | && apt-get clean \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | WORKDIR /workspaces 14 | 15 | # Install Python dependencies from requirements 16 | COPY requirements.txt requirements-dev.txt ./ 17 | COPY docs/requirements.txt ./requirements-docs.txt 18 | RUN pip3 install -r requirements.txt \ 19 | -r requirements-dev.txt \ 20 | -r requirements-docs.txt \ 21 | && rm -f requirements.txt requirements-dev.txt requirements-docs.txt 22 | 23 | ENV PATH=/root/.local/bin:${PATH} 24 | 25 | # Set the default shell to bash instead of sh 26 | ENV SHELL /bin/bash 27 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PyISY Devcontainer", 3 | "build": { 4 | "context": "..", 5 | "dockerfile": "Dockerfile", 6 | "args": { 7 | "VARIANT": "3.9-bullseye" 8 | } 9 | }, 10 | "runArgs": ["-e", "GIT_EDITOR=code --wait"], 11 | "postCreateCommand": ["/bin/bash", ".devcontainer/postCreate.sh"], 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-python.vscode-pylance", 16 | "visualstudioexptteam.vscodeintellicode", 17 | "esbenp.prettier-vscode", 18 | "github.vscode-pull-request-github", 19 | "streetsidesoftware.code-spell-checker", 20 | "njpwerner.autodocstring", 21 | "ms-python.black-formatter" 22 | ], 23 | "settings": { 24 | "python.pythonPath": "/usr/local/bin/python", 25 | "python.linting.enabled": true, 26 | "python.linting.pylintEnabled": true, 27 | "python.formatting.blackPath": "/usr/local/bin/black", 28 | "python.linting.flake8Path": "/usr/local/bin/flake8", 29 | "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", 30 | "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", 31 | "python.linting.mypyPath": "/usr/local/bin/mypy", 32 | "python.linting.pylintPath": "/usr/local/bin/pylint", 33 | "python.formatting.provider": "black", 34 | "python.testing.pytestArgs": ["--no-cov"], 35 | "editor.formatOnPaste": false, 36 | "editor.formatOnSave": true, 37 | "editor.formatOnType": true, 38 | "files.trimTrailingWhitespace": true, 39 | "terminal.integrated.profiles.linux": { 40 | "zsh": { 41 | "path": "/usr/bin/zsh" 42 | } 43 | }, 44 | "terminal.integrated.defaultProfile.linux": "zsh" 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.devcontainer/postCreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /workspaces/PyISY 4 | 5 | # Setup the example folder as copy of the example. 6 | mkdir -p example 7 | cp -r pyisy/__main__.py example/example_connection.py 8 | 9 | # Install the editable local package 10 | pip3 install -e . 11 | pip3 install -r requirements-dev.txt 12 | 13 | # Install pre-commit requirements 14 | pre-commit install --install-hooks 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | github-actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: pip 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | "on": 4 | pull_request: 5 | push: 6 | branches: 7 | - v2.x.x 8 | - v3.x.x 9 | 10 | jobs: 11 | pre-commit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.x" 18 | - name: Install dependencies 19 | run: python3 -m pip install -r requirements.txt -r requirements-dev.txt 20 | - uses: pre-commit/action@v3.0.1 21 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | name: Upload Python Package 4 | 5 | "on": 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | build: 11 | name: Build distribution 📦 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python3 -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: python3 -m build 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | 34 | deploy: 35 | permissions: 36 | id-token: write 37 | runs-on: ubuntu-latest 38 | needs: 39 | - build 40 | name: >- 41 | Publish Python 🐍 distribution 📦 to PyPI 42 | environment: 43 | name: release 44 | url: https://pypi.org/p/PyISY 45 | 46 | steps: 47 | - name: Download all the dists 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: python-package-distributions 51 | path: dist/ 52 | - name: Publish package distributions to PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Emacs backup file 2 | *~ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | test_scripts/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | .mypy.cache/ 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | .AppleDouble 65 | 66 | # Example directory built by devcontainer 67 | example/ 68 | 69 | # Output when dumping to file enabled 70 | .output/* 71 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: debug-statements 6 | - id: check-builtin-literals 7 | - id: check-case-conflict 8 | - id: check-docstring-first 9 | - id: check-json 10 | - id: check-toml 11 | - id: check-xml 12 | - id: check-yaml 13 | - id: detect-private-key 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.20.0 18 | hooks: 19 | - id: pyupgrade 20 | args: [--py39-plus] 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: v0.11.12 23 | hooks: 24 | - id: ruff 25 | args: [--fix, --exit-non-zero-on-fix] 26 | # Run the formatter. 27 | - id: ruff-format 28 | - hooks: 29 | - args: 30 | [ 31 | "--ignore-words-list=aNULL,ETO,pyisy,hass,isy,nid,dof,dfof,don,dfon,tim,automic,automicus,BATLVL,homeassistant,colorlog,nd,eto", 32 | '--skip="./.*,*.json"', 33 | --quiet-level=2, 34 | ] 35 | exclude_types: [json] 36 | id: codespell 37 | repo: https://github.com/codespell-project/codespell 38 | rev: v2.4.1 39 | - repo: https://github.com/adrienverge/yamllint.git 40 | rev: v1.37.1 41 | hooks: 42 | - id: yamllint 43 | - repo: https://github.com/pre-commit/mirrors-prettier 44 | rev: v4.0.0-alpha.8 45 | hooks: 46 | - id: prettier 47 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # Optionally build your docs in additional formats such as PDF 12 | # formats: 13 | # - pdf 14 | 15 | # Optionally set the version of Python and requirements 16 | # required to build your docs 17 | python: 18 | version: 3.7 19 | install: 20 | - requirements: docs/requirements.txt 21 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "ms-python.python", 5 | "ms-python.black-formatter" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.formatting.provider": "black", 5 | "editor.formatOnPaste": false, 6 | "editor.formatOnSave": true, 7 | "editor.formatOnType": true, 8 | "files.trimTrailingWhitespace": true, 9 | "python.linting.flake8Enabled": false, 10 | "cSpell.words": ["automodule"], 11 | "editor.formatOnSaveMode": "file" 12 | } 13 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | ignore: | 2 | azure-*.yml 3 | rules: 4 | braces: 5 | level: error 6 | min-spaces-inside: 0 7 | max-spaces-inside: 1 8 | min-spaces-inside-empty: -1 9 | max-spaces-inside-empty: -1 10 | brackets: 11 | level: error 12 | min-spaces-inside: 0 13 | max-spaces-inside: 0 14 | min-spaces-inside-empty: -1 15 | max-spaces-inside-empty: -1 16 | colons: 17 | level: error 18 | max-spaces-before: 0 19 | max-spaces-after: 1 20 | commas: 21 | level: error 22 | max-spaces-before: 0 23 | min-spaces-after: 1 24 | max-spaces-after: 1 25 | comments: 26 | level: error 27 | require-starting-space: true 28 | min-spaces-from-content: 2 29 | comments-indentation: 30 | level: error 31 | document-end: 32 | level: error 33 | present: false 34 | document-start: 35 | level: error 36 | present: false 37 | empty-lines: 38 | level: error 39 | max: 1 40 | max-start: 0 41 | max-end: 1 42 | hyphens: 43 | level: error 44 | max-spaces-after: 1 45 | indentation: 46 | level: error 47 | spaces: 2 48 | indent-sequences: true 49 | check-multi-line-strings: false 50 | key-duplicates: 51 | level: error 52 | line-length: disable 53 | new-line-at-end-of-file: 54 | level: error 55 | new-lines: 56 | level: error 57 | type: unix 58 | trailing-spaces: 59 | level: error 60 | truthy: 61 | level: error 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## CHANGELOG 2 | 3 | ### GitHub Release Versioning 4 | 5 | As of v3.0.7, this module will document all changes within the GitHub release information to avoid duplication. 6 | 7 | ### [v3.0.6] Fix Group States for Stateless Controllers 8 | 9 | - Fix Group States for Stateless Controllers (Fixes #256) 10 | - ControlLinc and RemoteLinc (v1) devices are excluded from group states to correctly reflect the state since these devices don't update 11 | the controller status unless a button is pushed. 12 | - Bump pyupgrade from 2.31.0 to 2.31.1 13 | - Bump pylint from 2.12.2 to 2.13.4 14 | - Bump black from 22.1.0 to 22.3.0 15 | 16 | ### [v3.0.5] Forced Republish of Package 17 | 18 | - No changes 19 | 20 | ### [v3.0.3] - Maintenance Release 21 | 22 | - Prevent callback exception from disconnecting websocket (#248) 23 | 24 | ### [v3.0.2] - Unsubscribe on Lost Connection 25 | 26 | - Attempt to unsubscribe instead of hard disconnect on non-critical socket errors (TCP Socket only, does not affect websockets). 27 | - Bump pyupgrade from 2.29.1 to 2.31.0 28 | - Update `.devcontainer` to latest VSCode Template 29 | - Bump black from 21.12b0 to 22.1.0 (#247) 30 | 31 | ### [v3.0.1] - Fix Unsubscribe SOAP Message and Dependency Updates 32 | 33 | - Bump codespell from 2.0.0 to 2.1.0 (#195) 34 | - Bump flake8 from 3.9.2 to 4.0.1 (#229) 35 | - Bump isort from 5.8.0 to 5.10.1 (#232) 36 | - Bump pyupgrade from 2.16.0 to 2.29.1 (#234) 37 | - Bump black from 21.5b1 to 21.12b0 (#238) 38 | - Bump pylint from 2.8.2 to 2.12.2 (#239) 39 | - Fixed Unsubscribe soap body (#240) 40 | 41 | ### [v3.0.0] - Async All the Things 42 | 43 | #### Breaking Changes 44 | 45 | - 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. 46 | - 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. 47 | - 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). 48 | - 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). 49 | 50 | #### Changed 51 | 52 | - 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`. 53 | - 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. 54 | - 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. 55 | - Add support for setting and retrieving Z-Wave Device Parameters using `node.set_zwave_parameter()` and `node.get_zwave_parameter()`. 56 | - Allow renaming of nodes and groups for ISY v5.2.0 or later using the `node.rename()` method (#157). 57 | - Add a folder property to each node and group (#159) 58 | - Force UTF-8 decoding of responses and ignore errors (#126) 59 | - Re-instate Documentation via Sphinx and ReadTheDocs (#150) (still a work in progress...) 60 | - Fix Group All On improper reporting (7a4b3b4) 61 | - Add DevContainer for development in VS Code. 62 | 63 | ### [v2.1.0] - Property Updates, Timestamps, Status Handling, and more... 64 | 65 | #### Breaking Changes 66 | 67 | - `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. 68 | - `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) 69 | - 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. 70 | 71 | #### Changed / Fixed 72 | 73 | - 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. 74 | - 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. 75 | - 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`. 76 | 77 | #### Added 78 | 79 | - Added `*.last_update` and `*.last_changed` properties which are UTC Datetime Timestamps, to allow detection of stale data. Fixes #99 80 | - 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_`. 81 | - Add a VSCode Devcontainer based on Python 3.8 82 | - Update the package requirements to explicitly include dateutil and the dev requirements for pre-commit 83 | - Add pyupgrade hook to pre-commit and run it on the whole repo. 84 | 85 | #### All PRs in this Version: 86 | 87 | - Revise Node.dimmable property to exclude non-dimmable subnodes (#122) 88 | - Logging cleanup and consolidation (#106) 89 | - Fix #109 - Update for events depreciation warning 90 | - Add Devcontainer, Update Requirements, Use PyUpgrade (#105) 91 | - Guard against overwriting known attributes with blanks (#112) 92 | - Minor code cleanups (#104) 93 | - Fix Property Updates, Add Timestamps, Unused Status Handling (#100) 94 | - Fix parameter name (#102) 95 | - Add connection events target (#101) 96 | 97 | #### Dependency Changes: 98 | 99 | - Bump black from 19.10b0 to 20.8b1 100 | - Bump pyupgrade from 2.3.0 to 2.7.2 101 | - Bump codespell from 1.16.0 to 1.17.1 102 | - Bump flake8 from 3.8.1 to 3.8.3 103 | - Bump pydocstyle from 5.0.2 to 5.1.1 104 | - Bump pylint from 2.4.4 to 2.6.0 105 | - Bump isort from 4.3.21 to 5.5.2 106 | 107 | ### [v2.0.2] - Version 2.0 Initial Release 108 | 109 | #### Summary: 110 | 111 | 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. 112 | 113 | #### Breaking Changes: 114 | 115 | - **CRITICAL** All module and folder names are now lower-case. 116 | - All `import PyISY` and `from PyISY import *` must be updated to `import pyisy` and `from pyisy import *`. 117 | - All class imports (e.g. `from PyISY.Nodes import Node` is now `from pyisy.nodes import Node`). Class names are still capitalized / CamelCase. 118 | - 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: 119 | - "`event == "DON"`" must be replaced with "`event.control == "DON"`" 120 | - 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. 121 | 122 | - 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). 123 | - 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)`. 124 | 125 | ```python 126 | uom = self._node.uom 127 | if isinstance(uom, list): 128 | uom = uom[0] 129 | ``` 130 | 131 | - Functions and properties have been renamed to snake_case from CamelCase. 132 | - Property `node.hasChildren` has been renamed to `node.has_children`. 133 | - Node Parent property has been renamed. Internal property is `node._parent_nid`, but externally accessible property is `node.parent_node`. 134 | - `node.controlEvents` has been renamed to `node.control_events`. 135 | - `variable.setInit` and `variable.set_value` have been renamed to `variable.set_init` and `variable.set_value`. 136 | - `ISY.sendX10` has been renamed to `ISY.send_x10_cmd`. 137 | - Network Resources `updateThread` function has been renamed to `update_threaded`. 138 | - 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`. 139 | - Node Functions `on()` and `off()` have been renamed to `turn_on()` and `turn_off()` 140 | - Node.lock() and Node.unlock() methods are now Node.secure_lock() and Node.secure_unlock(). 141 | - Node climate and fan speed functions have been reduced and require a proper command from UOM 98/99 (see `constants.py`): 142 | - For example to activate PROGRAM AUTO mode, call `node.set_climate_mode("program_auto")` 143 | - Program functions have been renamed: 144 | - `runThen` -> `run_then` 145 | - `runElse` -> `run_else` 146 | - `enableRunAtStartup` -> `enable_run_at_startup` 147 | - `disableRunAtStartup` -> `disable_run_at_startup` 148 | - Climate Module Retired as per [UDI Announcement](https://www.universal-devices.com/byebyeclimatemodule/) 149 | - Remove dependency on VarEvents library 150 | - 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. 151 | - Variables `val` property is now `status` for consistency. 152 | - 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`. 153 | - Group All On property no longer first its own event. Subscribe to the status events for changes. 154 | - Subscriptions for status changes need to be updated: 155 | ```python 156 | # Old: 157 | node.status.subscribe("changed", self.on_update) 158 | # New: 159 | node.status_events.subscribe(self.on_update) 160 | ``` 161 | - Program properties no longer fire their own events, but will fire the main status_event when something is changed. 162 | - Program property changes to conform to snake_case. 163 | - `lastUpdate` -> `last_update` 164 | - `lastRun` -> `last_run` 165 | - `lastFinished` -> `last_finished` 166 | - `runAtStartup` -> `run_at_startup` 167 | 168 | #### New: 169 | 170 | - Major code refactoring to consolidate nested function calls, remove redundant code. 171 | - Black Formatting and Linting to PEP8. 172 | - 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. 173 | - Adding retries for failed REST calls to the ISY #46 174 | - Add support for ISY Portal (incl. multiple ISYs): 175 | - Initialize the connection with: 176 | ```python 177 | isy = ISY( 178 | address="my.isy.io", 179 | port=443, 180 | username="your@portal.email", 181 | password="yourpassword", 182 | use_https=True, 183 | tls_ver=1.1, 184 | log=None, 185 | webroot="/isy/unique_isy_url_code_from_portal", 186 | ) 187 | # Unique URL can be found in ISY Portal under 188 | # Tools > Information > ISY Information 189 | ``` 190 | - Adds increased Z-Wave support by returning Z-Wave Properties under the `Node.zwave_props` property: 191 | - `category` 192 | - `devtype_mfg` 193 | - `devtype_gen` 194 | - `basic_type` 195 | - `generic_type` 196 | - `specific_type` 197 | - `mfr_id` 198 | - `prod_type_id` 199 | - `product_id` 200 | - Expose UUID, Firmware, and Hostname properties for referencing inside the `isy` object. 201 | - Various node commands have been renamed / newly exposed: 202 | - `start_manual_dimming` 203 | - `stop_manual_dimming` 204 | - `set_climate_setpoint` 205 | - `set_climate_setpoint_heat` 206 | - `set_climate_setpoint_cool` 207 | - `set_fan_speed` 208 | - `set_climate_mode` 209 | - `beep` 210 | - `brighten` 211 | - `dim` 212 | - `fade_down` 213 | - `fade_up` 214 | - `fade_stop` 215 | - `fast_on` 216 | - `fast_off` 217 | - 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`). 218 | - Expose the ISY Query Function (`/rest/query`) as `isy.query()` 219 | 220 | #### Fixes: 221 | 222 | - #11, #19, #22, #23, #31, #32, #41, #43, #45, #46, #51, #55, #59, #60, #82, #83 223 | - Malformed climate control commands 224 | - 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. 225 | - They are sending \*2 for the temperature for ALL thermostats instead of just Insteon/UOM 101. 226 | - Several modes were missing for the Insteon Thermostats. 227 | - Fix Node.aux_properties inconsistent typing #43 and now updates the existing aux_props instead of re-writing the entire dict. 228 | - Zwave multisensor support #31 -- Partial Fix. [Forum Thread is here](https://community.home-assistant.io/t/isy994-z-wave-sensor-enhancements-testers-wanted/124188) 229 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2014 Automicus 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## PyISY 2 | 3 | ### Python Library for the ISY Controller 4 | 5 | This library allows for easy interaction with ISY nodes, programs, variables, and the network module. This class also allows for functions to be 6 | assigned as handlers when ISY parameters are changed. ISY parameters can be 7 | monitored automatically as changes are reported from the device. 8 | 9 | **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. 10 | 11 | ### Examples 12 | 13 | See the [examples](examples/) folder for connection examples. 14 | 15 | The full documentation is available at https://pyisy.readthedocs.io. 16 | 17 | ### Development Team 18 | 19 | - Greg Laabs ([@overloadut]) - Maintainer 20 | - Ryan Kraus ([@rmkraus]) - Creator 21 | - Tim ([@shbatm]) - Version 2 Contributor 22 | 23 | ### Contributing 24 | 25 | 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. 26 | 27 | To use [pre-commit](https://pre-commit.com/#installation), see the installation instructions for more details. 28 | 29 | Short version: 30 | 31 | ```shell 32 | # From your copy of the pyisy repo folder: 33 | pip install pre-commit 34 | pre-commit install 35 | ``` 36 | 37 | A [VSCode DevContainer](https://code.visualstudio.com/docs/remote/containers#_getting-started) is also available to provide a consistent development environment. 38 | 39 | Assuming you have the pre-requisites installed from the link above (VSCode, Docker, & Remote-Containers Extension), to get started: 40 | 41 | 1. Fork the repository. 42 | 2. Clone the repository to your computer. 43 | 3. Open the repository using Visual Studio code. 44 | 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. 45 | - If you don't see this notification, open the command palette and select Remote-Containers: Reopen Folder in Container. 46 | 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. 47 | 48 | [@overloadut]: https://github.com/overloadut 49 | [@rmkraus]: https://github.com/rmkraus 50 | [@shbatm]: https://github.com/shbatm 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | from unittest import mock 16 | 17 | import sphinx_rtd_theme 18 | 19 | sys.path.insert(0, os.path.abspath("..")) 20 | 21 | MOCK_MODULES = [ 22 | "dateutil", 23 | "aiohttp", 24 | ] 25 | for mod_name in MOCK_MODULES: 26 | sys.modules[mod_name] = mock.Mock() 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = "PyISY" 31 | copyright = "2021, rmkraus, overloadut, shbatm" 32 | author = "rmkraus, overloadut, shbatm" 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | # "recommonmark", 42 | "sphinx.ext.todo", 43 | "sphinx.ext.viewcode", 44 | "sphinx.ext.autodoc", 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 54 | 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = "sphinx_rtd_theme" 62 | 63 | # Add any paths that contain custom themes here, relative to this directory. 64 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 65 | 66 | # Add any paths that contain custom static files (such as style sheets) here, 67 | # relative to this directory. They are copied after the builtin static files, 68 | # so a file named "default.css" will overwrite the builtin "default.css". 69 | html_static_path = ["_static"] 70 | 71 | # The short X.Y version. 72 | # version = '1.0' 73 | # The full version, including alpha/beta/rc tags. 74 | # release = '1.0.5' 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = "sphinx" 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | -------------------------------------------------------------------------------- /docs/constants.rst: -------------------------------------------------------------------------------- 1 | PyISY Constants 2 | =============== 3 | 4 | Constants used by the PyISY module are derived from `The ISY994 Developer Cookbook `_. 5 | 6 | .. automodule:: pyisy.constants 7 | :members: 8 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyISY 2 | ===== 3 | 4 | A Python Library for the ISY994 Controller 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | This module was developed to communicate with the `UDI ISY994 `_ 8 | home automation hub via the hub's REST interface and Websocket/SOAP event streams. It provides 9 | near real-time updates from the device and allows control of all devices that 10 | are supported within the ISY. 11 | 12 | This module also allows for functions to be assigned as handlers when ISY parameters are changed. 13 | ISY parameters can be monitored automatically as changes are reported from the device. 14 | 15 | .. warning:: 16 | 17 | THIS DOCUMENTATION IS STILL A WORK-IN-PROGRESS. Some of the details have not yet been updated 18 | for Version 2 or Version 3 of the PyISY Module. If you would like to help, please contribute 19 | on GitHub. 20 | 21 | 22 | Project Information 23 | ~~~~~~~~~~~~~~~~~~~ 24 | 25 | .. note:: 26 | 27 | This documentation is specific to PyISY Version 3.x.x, which uses asynchronous 28 | communications and the asyncio module. If you need threaded (synchronous) support 29 | please use Version 2.x.x. 30 | 31 | | Docs: `ReadTheDocs `_ 32 | | Source: `GitHub `_ 33 | 34 | 35 | Installation 36 | ~~~~~~~~~~~~ 37 | 38 | The easiest way to install this package is using pip with the command: 39 | 40 | .. code-block:: bash 41 | 42 | pip3 install pyisy 43 | 44 | See the :ref:`PyISY Tutorial` for guidance on how to use the module. 45 | 46 | Requirements 47 | ~~~~~~~~~~~~ 48 | 49 | This package requires three other packages, also available from pip. They are 50 | installed automatically when PyISY is installed using pip. 51 | 52 | * `requests `_ 53 | * `dateutil `_ 54 | * `aiohttp `_ 55 | 56 | Contents 57 | ~~~~~~~~ 58 | .. toctree:: 59 | :maxdepth: 1 60 | 61 | quickstart 62 | library 63 | constants 64 | 65 | Indices and Tables 66 | ~~~~~~~~~~~~~~~~~~ 67 | * :ref:`genindex` 68 | * :ref:`search` 69 | -------------------------------------------------------------------------------- /docs/library.rst: -------------------------------------------------------------------------------- 1 | PyISY Library Reference 2 | ======================= 3 | 4 | ISY Class 5 | --------- 6 | .. autoclass:: pyisy.isy.ISY 7 | :members: 8 | 9 | 10 | Node Manager Class 11 | ------------------ 12 | .. autoclass:: pyisy.nodes.Nodes 13 | :members: 14 | :special-members: 15 | 16 | Node Base Class 17 | --------------- 18 | .. autoclass:: pyisy.nodes.nodebase.NodeBase 19 | :members: 20 | :special-members: 21 | 22 | Node Class 23 | ---------- 24 | .. autoclass:: pyisy.nodes.Node 25 | :members: 26 | :special-members: 27 | 28 | Group Class 29 | ----------- 30 | .. autoclass:: pyisy.nodes.Group 31 | :members: 32 | :special-members: 33 | 34 | Program Manager Class 35 | --------------------- 36 | .. autoclass:: pyisy.programs.Programs 37 | :members: 38 | :special-members: 39 | 40 | Folder Class 41 | ------------ 42 | .. autoclass:: pyisy.programs.Folder 43 | :members: 44 | :special-members: 45 | 46 | Program Class 47 | ------------- 48 | .. autoclass:: pyisy.programs.Program 49 | :members: 50 | :special-members: 51 | :inherited-members: 52 | 53 | Variable Manager Class 54 | ---------------------- 55 | .. autoclass:: pyisy.variables.Variables 56 | :members: 57 | :special-members: 58 | 59 | Variable Class 60 | -------------- 61 | .. autoclass:: pyisy.variables.Variable 62 | :members: 63 | :special-members: 64 | 65 | Clock Class 66 | ----------- 67 | .. autoclass:: pyisy.clock.Clock 68 | :members: 69 | :special-members: 70 | 71 | NetworkResources Class 72 | ---------------------- 73 | .. autoclass:: pyisy.networking.NetworkResources 74 | :members: 75 | :special-members: 76 | 77 | NetworkCommand Class 78 | -------------------- 79 | .. autoclass:: pyisy.networking.NetworkCommand 80 | :members: 81 | :special-members: 82 | 83 | Connection Class 84 | ---------------- 85 | .. autoclass:: pyisy.connection.Connection 86 | :members: 87 | :special-members: 88 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | PyISY Tutorial 4 | ============== 5 | 6 | This is the basic user guide for the PyISY Python module. 7 | This module was developed to communicate with the UDI ISY-994 home automation hub 8 | via the hub's REST interface and Websocket/SOAP event streams. It provides 9 | near real-time updates from the device and allows control of all devices that 10 | are supported within the ISY. 11 | 12 | The way this module works is by connecting to your device, gathering information about 13 | the configuration and nodes connected, and then creating a local shadow structure that 14 | mimics the ISY's internal structure; similar to how UDI's Admin Console works in Java. 15 | 16 | Once connected, the module can then be used by other programs or Python scripts to 17 | interact with the ISY and either a) get the status of a node, program, variable, etc., 18 | or b) run a command on those items. 19 | 20 | .. note:: 21 | 22 | This documentation is specific to PyISY Version 3.x.x, which uses asynchronous 23 | communications and the asyncio module. If you need threaded (synchronous) support 24 | please use Version 2.x.x. 25 | 26 | 27 | Environment Setup 28 | ----------------- 29 | 30 | This module can be installed via pip in any environment supporting Python 3.7 or later: 31 | 32 | .. code-block:: shell 33 | 34 | pip3 install pyisy 35 | 36 | 37 | Quick Start 38 | ~~~~~~~~~~~ 39 | 40 | Starting with Version 3, this module can connect directly from the the command line 41 | to immediately print the list of nodes, and connect to the event stream and print 42 | the events as they are sent from the ISY. 43 | 44 | After installation, you can test the connection with the following 45 | 46 | .. code-block:: shell 47 | 48 | python3 -m pyisy http://your-isy-url:80 username password 49 | 50 | A good starting point for developing your own code is to copy the `__main__.py` file 51 | from the module's source code. This walks you through how to create the connections and 52 | some simple commands to get you started. 53 | 54 | You can download it from GitHub: ``_ 55 | 56 | 57 | Basic Usage 58 | ----------- 59 | 60 | Testing Your Connection 61 | ~~~~~~~~~~~~~~~~~~~~~~~ 62 | 63 | When connecting to the ISY, it will connect and download all available information and populate 64 | the local structures. Sometimes you just want to make sure the connection works before setting 65 | everything up. This can be done using the :class:`Connection` Class. 66 | 67 | .. code-block:: python 68 | 69 | import asyncio 70 | import logging 71 | from urlparse import urlparse 72 | 73 | from pyisy import ISY 74 | from pyisy.connection import ISYConnectionError, ISYInvalidAuthError, get_new_client_session 75 | _LOGGER = logging.getLogger(__name__) 76 | 77 | """Validate the user input allows us to connect.""" 78 | user = "username" 79 | password = "password" 80 | host = urlparse("http://isy994-ip-address:port/") 81 | tls_version = "1.2" # Can be False if using HTTP 82 | 83 | if host.scheme == "http": 84 | https = False 85 | port = host.port or 80 86 | elif host.scheme == "https": 87 | https = True 88 | port = host.port or 443 89 | else: 90 | _LOGGER.error("host value in configuration is invalid.") 91 | return False 92 | 93 | # Use the helper function to get a new aiohttp.ClientSession. 94 | websession = get_new_client_session(https, tls_ver) 95 | 96 | # Connect to ISY controller. 97 | isy_conn = Connection( 98 | host.hostname, 99 | port, 100 | user, 101 | password, 102 | use_https=https, 103 | tls_ver=tls_version, 104 | webroot=host.path, 105 | websession=websession, 106 | ) 107 | 108 | try: 109 | with async_timeout.timeout(30): 110 | isy_conf_xml = await isy_conn.test_connection() 111 | except (ISYInvalidAuthError, ISYConnectionError): 112 | _LOGGER.error( 113 | "Failed to connect to the ISY, please adjust settings and try again." 114 | ) 115 | 116 | Once you have a connection class and successfully tested the configuration, you can 117 | then use the :class:`Configuration` Class to get 118 | some additional details about the ISY, including the firmware version, name, and 119 | installed options like Networking, Variables, or NodeServers. 120 | 121 | .. code-block:: python 122 | 123 | try: 124 | isy_conf = Configuration(xml=isy_conf_xml) 125 | except ISYResponseParseError as error: 126 | raise CannotConnect from error 127 | if not isy_conf or "name" not in isy_conf or not isy_conf["name"]: 128 | raise CannotConnect 129 | 130 | 131 | Connecting to the Controller 132 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 133 | 134 | Connecting to the controller is simple and will create an instance of 135 | the :class:`ISY` class. This instance is what we will use to interact with the 136 | controller. By default when connecting to the ISY, it will load all available modules; 137 | this means all of the Nodes, Scenes, Programs, and Variables. The 138 | networking module will only be loaded if it is available. 139 | 140 | As mentioned above, the best starting point for your own script is the 141 | `__main__.py` file. This includes the basic connection to the ISY and also 142 | connecting to the event stream. 143 | 144 | Looking at the main function here, you can see the general flow: 145 | 146 | 1. Validate the settings 147 | 2. Create (or provide) an `asyncio` WebSession. 148 | 3. Create an instance of the :class:`ISY` Class 149 | 4. Initialize the connection with :meth:`isy.initialize`. 150 | 5. Connect to the :class:`WebSocketClient` for real-time event updates. 151 | 6. Safely shutdown the connection when done with :meth:`isy.shutdown()`. 152 | 153 | .. literalinclude:: ../pyisy/__main__.py 154 | :language: python 155 | :pyobject: main 156 | 157 | General Structure of the ISY Class 158 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 159 | 160 | The :class:`ISY` Class holds the local "shadow" copy of the 161 | ISY's structure and status. You can access the different components just like Python a `dict`. 162 | Each category is a `dict`-like object that holds the structure, and then each 163 | element is populated within that structure. 164 | 165 | - Nodes & Groups (Scenes): :class:`isy.nodes` 166 | - Programs & Program Folders: :class:`isy.programs` 167 | - Variables: :class:`isy.variables` 168 | - Network Resources: :class:`isy.networking` 169 | - Clock Info: :class:`isy.clock` 170 | - Configuration Info: :class:`isy.configuration` 171 | 172 | 173 | Controlling a Node on the Insteon Network 174 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 175 | 176 | Let's get straight into the fun by toggling a node on the Insteon 177 | network. To interact with both Insteon nodes and scenes, the nodes 178 | subclass is used. The best way to connect to a node is by using its 179 | address directly. Nodes and folders on the ISY controller can also be 180 | called by their name. 181 | 182 | .. code:: python 183 | 184 | # interact with node using address 185 | NODE = '22 5C EB 1' 186 | node = isy.nodes[NODE] 187 | await node.turn_off() 188 | sleep(5) 189 | await node.turn_on() 190 | 191 | .. code:: python 192 | 193 | # interact with node using name 194 | node = isy.nodes['Living Room Lights'] 195 | await node.turn_off() 196 | sleep(5) 197 | await node.turn_on() 198 | 199 | Controlling a Scene (Group) on the Insteon Network 200 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 201 | 202 | Just a small point of order here. The words Group and Scene are used 203 | interchangeably on the ISY device and similarly in this library. Don't 204 | let this confuse you. 205 | 206 | Now, groups and nodes are controlled in nearly identical ways. They can 207 | be referenced by either name or address. 208 | 209 | .. code:: python 210 | 211 | # control scene by address 212 | SCENE = '28614' 213 | await isy.nodes[SCENE].turn_off() 214 | asyncio.sleep(5) 215 | await isy.nodes[SCENE].turn_on() 216 | 217 | .. code:: python 218 | 219 | # control scene by name 220 | await isy.nodes['Downstairs Dim'].turn_off() 221 | asyncio.sleep(5) 222 | await isy.nodes['Downstairs Dim'].turn_on() 223 | 224 | Controlling an ISY Program 225 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 226 | 227 | Programs work the same way. I feel like you are probably getting the 228 | hang of this now, so I'll only show an example using an address. One 229 | major difference between programs and nodes and groups is that with 230 | programs, you can also interact directly with folders. 231 | 232 | .. code:: python 233 | 234 | # controlling a program 235 | PROG = '005E' 236 | await isy.programs[PROG].run() 237 | asyncio.sleep(3) 238 | await isy.programs[PROG].run_else() 239 | asyncio.sleep(3) 240 | await isy.programs[PROG].run_then() 241 | 242 | In order to interact with a folder as if it were a program, there is one 243 | extra step involved. 244 | 245 | .. code:: python 246 | 247 | PROG_FOLDER = '0061' 248 | # the leaf property must be used to get an object that acts like program 249 | await isy.programs[PROG_FOLDER].leaf.run() 250 | 251 | Controlling ISY Variables 252 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 253 | 254 | Variables can be a little tricky. There are integer variables and state 255 | variables. Integer variables are called with a 1 and state variables are 256 | called with a 2. Below is an example of both. 257 | 258 | .. code:: python 259 | 260 | # controlling an integer variable 261 | var = isy.variables[1][3] 262 | await var.set_value(0) 263 | print(var.status) 264 | await var.set_value(6) 265 | print(var.status) 266 | 267 | 268 | .. parsed-literal:: 269 | 270 | 0 271 | 6 272 | 273 | 274 | .. code:: python 275 | 276 | # controlling a state variable (Type 2) init value 277 | var = isy.variables[2][14] 278 | await var.set_init(0) 279 | print(var.init) 280 | await var.set_init(6) 281 | print(var.init) 282 | 283 | 284 | .. parsed-literal:: 285 | 286 | 0 287 | 6 288 | 289 | 290 | Controlling the Networking Module 291 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 292 | 293 | This is in the works and coming soon. 294 | 295 | Event Updates 296 | ------------- 297 | 298 | This library can subscribe to the ISY's Event Stream to receive updates 299 | on devices as they are manipulated. This means that your program can 300 | respond to events on your controller in real time using websockets and 301 | a subscription-based event notification. 302 | 303 | Subscribing to Updates 304 | ~~~~~~~~~~~~~~~~~~~~~~ 305 | 306 | .. warning:: 307 | 308 | THIS DOCUMENTATION IS STILL A WORK-IN-PROGRESS. The details have not yet been updated 309 | for Version 2 or Version 3 of the PyISY Module. If you would like to help, please contribute 310 | on GitHub. 311 | 312 | The ISY class will not be receiving updates by default. It is, however, 313 | easy to enable, and it is done like so. 314 | 315 | .. code:: python 316 | 317 | isy.auto_update = True 318 | 319 | By default, PyISY will detect when the controller is no longer 320 | responding and attempt a reconnect. Keep in mind though, it can take up 321 | to two minutes to detect a lost connection. This means if you restart 322 | your controller, in about two minutes PyISY will detect that, reconnect, 323 | and update all the elements to their updated state. To turn off auto 324 | reconnects, the following parameter can be changed. 325 | 326 | .. code:: python 327 | 328 | isy.auto_reconnect = False 329 | 330 | Now, once the connection is lost, it will stay disconnected until it is 331 | told to reconnect. 332 | 333 | Binding Events to Updates 334 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 335 | 336 | Using the VarEvents library, we can bind functions to be called when 337 | certain events take place. Subscribing to an event will return a handler 338 | that we can use to unsubscribe later. For a full list of events, check 339 | out the VarEvents documentation. 340 | 341 | .. code:: python 342 | 343 | def notify(e): 344 | print('Notification Received') 345 | 346 | # interact with node using address 347 | NODE = '22 5C EB 1' 348 | node = isy.nodes[NODE] 349 | handler = node.status.subscribe('changed', notify) 350 | 351 | Now, when we make a change to the node, we will receive the 352 | notification... 353 | 354 | .. code:: python 355 | 356 | node.status.update(100) 357 | 358 | 359 | .. parsed-literal:: 360 | 361 | Notification Received 362 | 363 | 364 | Now we can unsubscribe from the event using the handler. 365 | 366 | .. code:: python 367 | 368 | handler.unsubscribe() 369 | node.status.update(75) 370 | 371 | More details about event handling are discussed inside the rest of the 372 | documentation, but that is the basics. 373 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme>=0.5.1 2 | mock>=4.0.3 3 | -------------------------------------------------------------------------------- /pyisy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyISY - Python Library for the ISY Controller. 3 | 4 | This module is a set of Python bindings for the ISY's REST API. The 5 | ISY is developed by Universal Devices and is a home automation 6 | controller for Insteon and X10 devices. 7 | 8 | Copyright 2015 Ryan M. Kraus 9 | rmkraus at gmail dot com 10 | 11 | Licensed under the Apache License, Version 2.0 (the "License"); 12 | you may not use this file except in compliance with the License. 13 | You may obtain a copy of the License at 14 | 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, 19 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | See the License for the specific language governing permissions and 21 | limitations under the License. 22 | """ 23 | 24 | from __future__ import annotations 25 | 26 | from importlib.metadata import PackageNotFoundError, version 27 | 28 | from .exceptions import ( 29 | ISYConnectionError, 30 | ISYInvalidAuthError, 31 | ISYMaxConnections, 32 | ISYResponseParseError, 33 | ISYStreamDataError, 34 | ISYStreamDisconnected, 35 | ) 36 | from .isy import ISY 37 | 38 | try: 39 | __version__ = version("pyisy") 40 | except PackageNotFoundError: 41 | __version__ = "unknown" 42 | 43 | __all__ = [ 44 | "ISY", 45 | "ISYConnectionError", 46 | "ISYInvalidAuthError", 47 | "ISYMaxConnections", 48 | "ISYResponseParseError", 49 | "ISYStreamDataError", 50 | "ISYStreamDisconnected", 51 | ] 52 | __author__ = "Ryan M. Kraus" 53 | __email__ = "rmkraus at gmail dot com" 54 | __date__ = "February 2020" 55 | -------------------------------------------------------------------------------- /pyisy/__main__.py: -------------------------------------------------------------------------------- 1 | """Implementation of module for command line. 2 | 3 | The module can be tested by running the following command: 4 | `python3 -m pyisy http://your-isy-url:80 username password` 5 | Use `python3 -m pyisy -h` for full usage information. 6 | 7 | This script can also be copied and used as a template for 8 | using this module. 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | import argparse 14 | import asyncio 15 | import logging 16 | import time 17 | from urllib.parse import urlparse 18 | 19 | from . import ISY 20 | from .connection import ISYConnectionError, ISYInvalidAuthError, get_new_client_session 21 | from .constants import NODE_CHANGED_ACTIONS, SYSTEM_STATUS 22 | from .logging import LOG_VERBOSE, enable_logging 23 | from .nodes import NodeChangedEvent 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def main(url, username, password, tls_ver, events, node_servers): 29 | """Execute connection to ISY and load all system info.""" 30 | _LOGGER.info("Starting PyISY...") 31 | t_0 = time.time() 32 | host = urlparse(url) 33 | if host.scheme == "http": 34 | https = False 35 | port = host.port or 80 36 | elif host.scheme == "https": 37 | https = True 38 | port = host.port or 443 39 | else: 40 | _LOGGER.error("host value in configuration is invalid.") 41 | return False 42 | 43 | # Use the helper function to get a new aiohttp.ClientSession. 44 | websession = get_new_client_session(https, tls_ver) 45 | 46 | # Connect to ISY controller. 47 | isy = ISY( 48 | host.hostname, 49 | port, 50 | username=username, 51 | password=password, 52 | use_https=https, 53 | tls_ver=tls_ver, 54 | webroot=host.path, 55 | websession=websession, 56 | use_websocket=True, 57 | ) 58 | 59 | try: 60 | await isy.initialize(node_servers) 61 | except (ISYInvalidAuthError, ISYConnectionError): 62 | _LOGGER.exception("Failed to connect to the ISY, please adjust settings and try again.") 63 | await isy.shutdown() 64 | return None 65 | except Exception as err: 66 | _LOGGER.exception("Unknown error occurred: %s", err.args[0]) 67 | await isy.shutdown() 68 | raise 69 | 70 | # Print a representation of all the Nodes 71 | _LOGGER.debug(repr(isy.nodes)) 72 | _LOGGER.info("Total Loading time: %.2fs", time.time() - t_0) 73 | 74 | node_changed_subscriber = None 75 | system_status_subscriber = None 76 | 77 | def node_changed_handler(event: NodeChangedEvent) -> None: 78 | """Handle a node changed event sent from Nodes class.""" 79 | (event_desc, _) = NODE_CHANGED_ACTIONS[event.action] 80 | _LOGGER.info( 81 | "Subscriber--Node %s Changed: %s %s", 82 | event.address, 83 | event_desc, 84 | event.event_info if event.event_info else "", 85 | ) 86 | 87 | def system_status_handler(event: str) -> None: 88 | """Handle a system status changed event sent ISY class.""" 89 | _LOGGER.info("System Status Changed: %s", SYSTEM_STATUS.get(event)) 90 | 91 | try: 92 | if events: 93 | isy.websocket.start() 94 | node_changed_subscriber = isy.nodes.status_events.subscribe(node_changed_handler) 95 | system_status_subscriber = isy.status_events.subscribe(system_status_handler) 96 | await asyncio.Event.wait() 97 | except asyncio.CancelledError: 98 | pass 99 | finally: 100 | if node_changed_subscriber: 101 | node_changed_subscriber.unsubscribe() 102 | if system_status_subscriber: 103 | system_status_subscriber.unsubscribe() 104 | await isy.shutdown() 105 | 106 | 107 | if __name__ == "__main__": 108 | parser = argparse.ArgumentParser(prog=__package__) 109 | parser.add_argument("url", type=str) 110 | parser.add_argument("username", type=str) 111 | parser.add_argument("password", type=str) 112 | parser.add_argument("-t", "--tls-ver", dest="tls_ver", type=float) 113 | parser.add_argument("-v", "--verbose", action="store_true") 114 | parser.add_argument("-q", "--no-events", dest="no_events", action="store_true") 115 | parser.add_argument("-n", "--node-servers", dest="node_servers", action="store_true") 116 | parser.set_defaults(use_https=False, tls_ver=1.1, verbose=False) 117 | args = parser.parse_args() 118 | 119 | enable_logging(LOG_VERBOSE if args.verbose else logging.DEBUG) 120 | 121 | _LOGGER.info( 122 | "ISY URL: %s, username: %s, TLS: %s", 123 | args.url, 124 | args.username, 125 | args.tls_ver, 126 | ) 127 | 128 | try: 129 | asyncio.run( 130 | main( 131 | url=args.url, 132 | username=args.username, 133 | password=args.password, 134 | tls_ver=args.tls_ver, 135 | events=(not args.no_events), 136 | node_servers=args.node_servers, 137 | ) 138 | ) 139 | except KeyboardInterrupt: 140 | _LOGGER.warning("KeyboardInterrupt received. Disconnecting!") 141 | -------------------------------------------------------------------------------- /pyisy/clock.py: -------------------------------------------------------------------------------- 1 | """ISY Clock/Location Information.""" 2 | 3 | from __future__ import annotations 4 | 5 | from asyncio import sleep 6 | from datetime import datetime 7 | from typing import TYPE_CHECKING 8 | from xml.dom import minidom 9 | 10 | from .constants import ( 11 | EMPTY_TIME, 12 | TAG_DST, 13 | TAG_LATITUDE, 14 | TAG_LONGITUDE, 15 | TAG_MILIATRY_TIME, 16 | TAG_NTP, 17 | TAG_SUNRISE, 18 | TAG_SUNSET, 19 | TAG_TZ_OFFSET, 20 | XML_TRUE, 21 | ) 22 | from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError 23 | from .helpers import ntp_to_system_time, value_from_xml 24 | from .logging import _LOGGER 25 | 26 | if TYPE_CHECKING: 27 | from .isy import ISY 28 | 29 | 30 | class Clock: 31 | """ 32 | ISY Clock class cobject. 33 | 34 | DESCRIPTION: 35 | This class handles the ISY clock/location info. 36 | 37 | ATTRIBUTES: 38 | isy: The ISY device class 39 | last_called: the time of the last call to /rest/time 40 | tz_offset: The Time Zone Offset of the ISY 41 | dst: Daylight Savings Time Enabled or not 42 | latitude: ISY Device Latitude 43 | longitude: ISY Device Longitude 44 | sunrise: ISY Calculated Sunrise 45 | sunset: ISY Calculated Sunset 46 | military: If the clock is military time or not. 47 | 48 | """ 49 | 50 | def __init__(self, isy: ISY, xml: str | None = None) -> None: 51 | """ 52 | Initialize the network resources class. 53 | 54 | isy: ISY class 55 | xml: String of xml data containing the configuration data 56 | """ 57 | self.isy = isy 58 | self._last_called = EMPTY_TIME 59 | self._tz_offset = 0 60 | self._dst = False 61 | self._latitude = 0.0 62 | self._longitude = 0.0 63 | self._sunrise = EMPTY_TIME 64 | self._sunset = EMPTY_TIME 65 | self._military = False 66 | 67 | if xml is not None: 68 | self.parse(xml) 69 | 70 | def __str__(self) -> str: 71 | """Return a string representing the clock Class.""" 72 | return f"ISY Clock (Last Updated {self.last_called})" 73 | 74 | def __repr__(self) -> str: 75 | """Return a long string showing all the clock values.""" 76 | props = [name for name, value in vars(Clock).items() if isinstance(value, property)] 77 | return f"ISY Clock: { ({prop: str(getattr(self, prop)) for prop in props})!r}" 78 | 79 | def parse(self, xml: str) -> None: 80 | """ 81 | Parse the xml data. 82 | 83 | xml: String of the xml data 84 | """ 85 | try: 86 | xmldoc = minidom.parseString(xml) 87 | except XML_ERRORS as exc: 88 | _LOGGER.error("%s: Clock", XML_PARSE_ERROR) 89 | raise ISYResponseParseError(XML_PARSE_ERROR) from exc 90 | 91 | tz_offset_sec = int(value_from_xml(xmldoc, TAG_TZ_OFFSET)) 92 | self._tz_offset = tz_offset_sec / 3600 93 | self._dst = value_from_xml(xmldoc, TAG_DST) == XML_TRUE 94 | self._latitude = float(value_from_xml(xmldoc, TAG_LATITUDE)) 95 | self._longitude = float(value_from_xml(xmldoc, TAG_LONGITUDE)) 96 | self._military = value_from_xml(xmldoc, TAG_MILIATRY_TIME) == XML_TRUE 97 | self._last_called = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_NTP))) 98 | self._sunrise = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_SUNRISE))) 99 | self._sunset = ntp_to_system_time(int(value_from_xml(xmldoc, TAG_SUNSET))) 100 | 101 | _LOGGER.info("ISY Loaded Clock Information") 102 | 103 | async def update(self, wait_time: int = 0) -> None: 104 | """ 105 | Update the contents of the networking class. 106 | 107 | wait_time: [optional] Amount of seconds to wait before updating 108 | """ 109 | await sleep(wait_time) 110 | xml = await self.isy.conn.get_time() 111 | self.parse(xml) 112 | 113 | async def update_thread(self, interval: int) -> None: 114 | """ 115 | Continually update the class until it is told to stop. 116 | 117 | Should be run as a task in the event loop. 118 | """ 119 | while self.isy.auto_update: 120 | await self.update(interval) 121 | 122 | @property 123 | def last_called(self) -> datetime: 124 | """Get the time of the last call to /rest/time in UTC.""" 125 | return self._last_called 126 | 127 | @property 128 | def tz_offset(self) -> float: 129 | """Provide the Time Zone Offset from the isy in Hours.""" 130 | return self._tz_offset 131 | 132 | @property 133 | def dst(self) -> bool: 134 | """Confirm if DST is enabled or not on the ISY.""" 135 | return self._dst 136 | 137 | @property 138 | def latitude(self) -> float: 139 | """Provide the latitude information from the isy.""" 140 | return self._latitude 141 | 142 | @property 143 | def longitude(self) -> float: 144 | """Provide the longitude information from the isy.""" 145 | return self._longitude 146 | 147 | @property 148 | def sunrise(self) -> datetime: 149 | """Provide the sunrise information from the isy (UTC).""" 150 | return self._sunrise 151 | 152 | @property 153 | def sunset(self) -> datetime: 154 | """Provide the sunset information from the isy (UTC).""" 155 | return self._sunset 156 | 157 | @property 158 | def military(self) -> bool: 159 | """Confirm if military time is in use or not on the isy.""" 160 | return self._military 161 | -------------------------------------------------------------------------------- /pyisy/configuration.py: -------------------------------------------------------------------------------- 1 | """ISY Configuration Lookup.""" 2 | 3 | from __future__ import annotations 4 | 5 | from xml.dom import minidom 6 | 7 | from .constants import ( 8 | ATTR_DESC, 9 | ATTR_ID, 10 | TAG_DESC, 11 | TAG_FEATURE, 12 | TAG_FIRMWARE, 13 | TAG_INSTALLED, 14 | TAG_NAME, 15 | TAG_NODE_DEFS, 16 | TAG_PRODUCT, 17 | TAG_ROOT, 18 | TAG_VARIABLES, 19 | XML_TRUE, 20 | ) 21 | from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError 22 | from .helpers import value_from_nested_xml, value_from_xml 23 | from .logging import _LOGGER 24 | 25 | 26 | class Configuration(dict): 27 | """ 28 | ISY Configuration class. 29 | 30 | DESCRIPTION: 31 | This class handles the ISY configuration. 32 | 33 | USAGE: 34 | This object may be used in a similar way as a 35 | dictionary with the either module names or ids 36 | being used as keys and a boolean indicating 37 | whether the module is installed will be 38 | returned. With the exception of 'firmware' and 'uuid', 39 | which will return their respective values. 40 | 41 | PARAMETERS: 42 | Portal Integration - Check-it.ca 43 | Gas Meter 44 | SEP ESP 45 | Water Meter 46 | Z-Wave 47 | RCS Zigbee Device Support 48 | Irrigation/ETo Module 49 | Electricity Monitor 50 | AMI Electricity Meter 51 | URL 52 | A10/X10 for INSTEON 53 | Portal Integration - GreenNet.com 54 | Networking Module 55 | OpenADR 56 | Current Cost Meter 57 | Weather Information 58 | Broadband SEP Device 59 | Portal Integration - BestBuy.com 60 | Elk Security System 61 | Portal Integration - MobiLinc 62 | NorthWrite NOC Module 63 | 64 | EXAMPLE: 65 | # configuration['Networking Module'] 66 | True 67 | # configuration['21040'] 68 | True 69 | 70 | """ 71 | 72 | def __init__(self, xml: str | None = None) -> None: 73 | """ 74 | Initialize configuration class. 75 | 76 | xml: String of xml data containing the configuration data 77 | """ 78 | super().__init__() 79 | if xml is not None: 80 | self.parse(xml) 81 | 82 | def parse(self, xml: str) -> None: 83 | """ 84 | Parse the xml data. 85 | 86 | xml: String of the xml data 87 | """ 88 | try: 89 | xmldoc = minidom.parseString(xml) 90 | except XML_ERRORS as exc: 91 | _LOGGER.error("%s: Configuration", XML_PARSE_ERROR) 92 | raise ISYResponseParseError(XML_PARSE_ERROR) from exc 93 | 94 | self["firmware"] = value_from_xml(xmldoc, TAG_FIRMWARE) 95 | self["uuid"] = value_from_nested_xml(xmldoc, [TAG_ROOT, ATTR_ID]) 96 | self["name"] = value_from_nested_xml(xmldoc, [TAG_ROOT, TAG_NAME]) 97 | self["model"] = value_from_nested_xml(xmldoc, [TAG_PRODUCT, TAG_DESC], "ISY") 98 | self["variables"] = bool(value_from_xml(xmldoc, TAG_VARIABLES) == XML_TRUE) 99 | self["nodedefs"] = bool(value_from_xml(xmldoc, TAG_NODE_DEFS) == XML_TRUE) 100 | 101 | features = xmldoc.getElementsByTagName(TAG_FEATURE) 102 | for feature in features: 103 | idnum = value_from_xml(feature, ATTR_ID) 104 | desc = value_from_xml(feature, ATTR_DESC) 105 | installed_raw = value_from_xml(feature, TAG_INSTALLED) 106 | installed = bool(installed_raw == XML_TRUE) 107 | self[idnum] = installed 108 | self[desc] = self[idnum] 109 | 110 | _LOGGER.info("ISY Loaded Configuration") 111 | -------------------------------------------------------------------------------- /pyisy/connection.py: -------------------------------------------------------------------------------- 1 | """Connection to the ISY.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import ssl 7 | from urllib.parse import quote, urlencode 8 | 9 | import aiohttp 10 | 11 | from .constants import ( 12 | METHOD_GET, 13 | URL_CLOCK, 14 | URL_CONFIG, 15 | URL_DEFINITIONS, 16 | URL_MEMBERS, 17 | URL_NETWORK, 18 | URL_NODES, 19 | URL_PING, 20 | URL_PROGRAMS, 21 | URL_RESOURCES, 22 | URL_STATUS, 23 | URL_SUBFOLDERS, 24 | URL_VARIABLES, 25 | VAR_INTEGER, 26 | VAR_STATE, 27 | XML_FALSE, 28 | XML_TRUE, 29 | ) 30 | from .exceptions import ISYConnectionError, ISYInvalidAuthError 31 | from .logging import _LOGGER, enable_logging 32 | 33 | MAX_HTTPS_CONNECTIONS_ISY = 2 34 | MAX_HTTP_CONNECTIONS_ISY = 5 35 | MAX_HTTPS_CONNECTIONS_IOX = 20 36 | MAX_HTTP_CONNECTIONS_IOX = 50 37 | 38 | MAX_RETRIES = 5 39 | RETRY_BACKOFF = [0.01, 0.10, 0.25, 1, 2] # Seconds 40 | 41 | HTTP_OK = 200 # Valid request received, will run it 42 | HTTP_UNAUTHORIZED = 401 # User authentication failed 43 | HTTP_NOT_FOUND = 404 # Unrecognized request received and ignored 44 | HTTP_SERVICE_UNAVAILABLE = 503 # Valid request received, system too busy to run it 45 | 46 | HTTP_TIMEOUT = 30 47 | 48 | HTTP_HEADERS = { 49 | "Connection": "keep-alive", 50 | "Keep-Alive": "5000", 51 | "Accept-Encoding": "gzip, deflate", 52 | } 53 | 54 | EMPTY_XML_RESPONSE = '' 55 | 56 | 57 | class Connection: 58 | """Connection object to manage connection to and interaction with ISY.""" 59 | 60 | def __init__( 61 | self, 62 | address: str, 63 | port: int, 64 | username: str, 65 | password: str, 66 | use_https: bool = False, 67 | tls_ver: float = 1.1, 68 | webroot: str = "", 69 | websession: aiohttp.ClientSession | None = None, 70 | ) -> None: 71 | """Initialize the Connection object.""" 72 | if len(_LOGGER.handlers) == 0: 73 | enable_logging(add_null_handler=True) 74 | 75 | self._address = address 76 | self._port = port 77 | self._username = username 78 | self._password = password 79 | self._auth = aiohttp.BasicAuth(self._username, self._password) 80 | self._webroot = webroot.rstrip("/") 81 | self.req_session = websession 82 | self._tls_ver = tls_ver 83 | self.use_https = use_https 84 | self._url = f"http{'s' if self.use_https else ''}://{self._address}:{self._port}{self._webroot}" 85 | 86 | self.semaphore = asyncio.Semaphore( 87 | MAX_HTTPS_CONNECTIONS_ISY if use_https else MAX_HTTP_CONNECTIONS_ISY 88 | ) 89 | 90 | if websession is None: 91 | websession = get_new_client_session(use_https, tls_ver) 92 | self.req_session = websession 93 | self.sslcontext = get_sslcontext(use_https, tls_ver) 94 | 95 | async def test_connection(self) -> str | None: 96 | """Test the connection and get the config for the ISY.""" 97 | config = await self.get_config(retries=None) 98 | if not config: 99 | _LOGGER.error("Could not connect to the ISY with the parameters provided.") 100 | raise ISYConnectionError 101 | return config 102 | 103 | def increase_available_connections(self) -> None: 104 | """Increase the number of allowed connections for newer hardware.""" 105 | _LOGGER.debug("Increasing available simultaneous connections") 106 | self.semaphore = asyncio.Semaphore( 107 | MAX_HTTPS_CONNECTIONS_IOX if self.use_https else MAX_HTTP_CONNECTIONS_IOX 108 | ) 109 | 110 | async def close(self) -> None: 111 | """Cleanup connections and prepare for exit.""" 112 | await self.req_session.close() 113 | 114 | @property 115 | def connection_info(self) -> dict[str, str | int | bytes | None]: 116 | """Return the connection info required to connect to the ISY.""" 117 | connection_info = {} 118 | connection_info["auth"] = self._auth.encode() 119 | connection_info["addr"] = self._address 120 | connection_info["port"] = int(self._port) 121 | connection_info["passwd"] = self._password 122 | connection_info["webroot"] = self._webroot 123 | if self.use_https and self._tls_ver: 124 | connection_info["tls"] = self._tls_ver 125 | 126 | return connection_info 127 | 128 | @property 129 | def url(self) -> str: 130 | """Return the full connection url.""" 131 | return self._url 132 | 133 | # COMMON UTILITIES 134 | def compile_url(self, path: list[str], query: str | None = None) -> str: 135 | """Compile the URL to fetch from the ISY.""" 136 | url = self.url 137 | if path is not None: 138 | url += "/rest/" + "/".join([quote(item) for item in path]) 139 | 140 | if query is not None: 141 | url += "?" + urlencode(query) 142 | 143 | return url 144 | 145 | async def request(self, url: str, retries: int = 0, ok404: bool = False, delay: int = 0) -> str | None: 146 | """Execute request to ISY REST interface.""" 147 | _LOGGER.debug("ISY Request: %s", url) 148 | if delay: 149 | await asyncio.sleep(delay) 150 | try: 151 | async with ( 152 | self.semaphore, 153 | self.req_session.get( 154 | url, 155 | auth=self._auth, 156 | headers=HTTP_HEADERS, 157 | timeout=HTTP_TIMEOUT, 158 | ssl=self.sslcontext, 159 | ) as res, 160 | ): 161 | endpoint = url.split("rest", 1)[1] 162 | if res.status == HTTP_OK: 163 | _LOGGER.debug("ISY Response Received: %s", endpoint) 164 | results = await res.text(encoding="utf-8", errors="ignore") 165 | if results != EMPTY_XML_RESPONSE: 166 | return results 167 | _LOGGER.debug("Invalid empty XML returned: %s", endpoint) 168 | res.release() 169 | if res.status == HTTP_NOT_FOUND: 170 | if ok404: 171 | _LOGGER.debug("ISY Response Received %s", endpoint) 172 | res.release() 173 | return "" 174 | _LOGGER.error("ISY Reported an Invalid Command Received %s", endpoint) 175 | res.release() 176 | return None 177 | if res.status == HTTP_UNAUTHORIZED: 178 | _LOGGER.error("Invalid credentials provided for ISY connection.") 179 | res.release() 180 | raise ISYInvalidAuthError("Invalid credentials provided for ISY connection.") 181 | if res.status == HTTP_SERVICE_UNAVAILABLE: 182 | _LOGGER.warning("ISY too busy to process request %s", endpoint) 183 | res.release() 184 | 185 | except asyncio.TimeoutError: 186 | _LOGGER.warning("Timeout while trying to connect to the ISY.") 187 | except ( 188 | aiohttp.ClientOSError, 189 | aiohttp.ServerDisconnectedError, 190 | ): 191 | _LOGGER.debug("ISY not ready or closed connection.") 192 | except aiohttp.ClientResponseError as err: 193 | _LOGGER.error("Client Response Error from ISY: %s %s.", err.status, err.message) 194 | except aiohttp.ClientError as err: 195 | _LOGGER.error( 196 | "ISY Could not receive response from device because of a network issue: %s", 197 | type(err), 198 | ) 199 | 200 | if retries is None: 201 | raise ISYConnectionError 202 | if retries < MAX_RETRIES: 203 | _LOGGER.debug( 204 | "Retrying ISY Request in %ss, retry %s.", 205 | RETRY_BACKOFF[retries], 206 | retries + 1, 207 | ) 208 | # sleep to allow the ISY to catch up 209 | await asyncio.sleep(RETRY_BACKOFF[retries]) 210 | # recurse to try again 211 | return await self.request(url, retries + 1, ok404=ok404) 212 | # fail for good 213 | _LOGGER.error( 214 | "Bad ISY Request: (%s) Failed after %s retries.", 215 | url, 216 | retries, 217 | ) 218 | return None 219 | 220 | async def ping(self) -> bool: 221 | """Test connection to the ISY and return True if alive.""" 222 | req_url = self.compile_url([URL_PING]) 223 | result = await self.request(req_url, ok404=True) 224 | return result is not None 225 | 226 | async def get_description(self) -> str | None: 227 | """Fetch the services description from the ISY.""" 228 | url = "https://" if self.use_https else "http://" 229 | url += f"{self._address}:{self._port}{self._webroot}/desc" 230 | return await self.request(url) 231 | 232 | async def get_config(self, retries: int = 0) -> str | None: 233 | """Fetch the configuration from the ISY.""" 234 | req_url = self.compile_url([URL_CONFIG]) 235 | return await self.request(req_url, retries=retries) 236 | 237 | async def get_programs(self, address: int | str | None = None) -> str | None: 238 | """Fetch the list of programs from the ISY.""" 239 | addr = [URL_PROGRAMS] 240 | if address is not None: 241 | addr.append(str(address)) 242 | req_url = self.compile_url(addr, {URL_SUBFOLDERS: XML_TRUE}) 243 | return await self.request(req_url) 244 | 245 | async def get_nodes(self) -> str | None: 246 | """Fetch the list of nodes/groups/scenes from the ISY.""" 247 | req_url = self.compile_url([URL_NODES], {URL_MEMBERS: XML_FALSE}) 248 | return await self.request(req_url) 249 | 250 | async def get_status(self) -> str | None: 251 | """Fetch the status of nodes/groups/scenes from the ISY.""" 252 | req_url = self.compile_url([URL_STATUS]) 253 | return await self.request(req_url) 254 | 255 | async def get_variable_defs(self) -> list[str | BaseException] | None: 256 | """Fetch the list of variables from the ISY.""" 257 | req_list = [ 258 | [URL_VARIABLES, URL_DEFINITIONS, VAR_INTEGER], 259 | [URL_VARIABLES, URL_DEFINITIONS, VAR_STATE], 260 | ] 261 | req_urls = [self.compile_url(req) for req in req_list] 262 | return await asyncio.gather(*[self.request(req_url) for req_url in req_urls], return_exceptions=True) 263 | 264 | async def get_variables(self) -> str | None: 265 | """Fetch the variable details from the ISY to update local copy.""" 266 | req_list = [ 267 | [URL_VARIABLES, METHOD_GET, VAR_INTEGER], 268 | [URL_VARIABLES, METHOD_GET, VAR_STATE], 269 | ] 270 | req_urls = [self.compile_url(req) for req in req_list] 271 | results = await asyncio.gather( 272 | *[self.request(req_url) for req_url in req_urls], return_exceptions=True 273 | ) 274 | results = [r for r in results if r is not None] # Strip any bad requests. 275 | result = "".join(results) 276 | return result.replace('', "") 277 | 278 | async def get_network(self) -> str | None: 279 | """Fetch the list of network resources from the ISY.""" 280 | req_url = self.compile_url([URL_NETWORK, URL_RESOURCES]) 281 | return await self.request(req_url) 282 | 283 | async def get_time(self) -> str | None: 284 | """Fetch the system time info from the ISY.""" 285 | req_url = self.compile_url([URL_CLOCK]) 286 | return await self.request(req_url) 287 | 288 | 289 | def get_new_client_session(use_https: bool, tls_ver: float = 1.1) -> aiohttp.ClientSession: 290 | """Create a new Client Session for Connecting.""" 291 | if use_https: 292 | if not can_https(tls_ver): 293 | raise (ValueError("PyISY could not connect to the ISY. Check log for SSL/TLS error.")) 294 | 295 | return aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) 296 | 297 | return aiohttp.ClientSession() 298 | 299 | 300 | def get_sslcontext(use_https: bool, tls_ver: float = 1.1) -> ssl.SSLContext | None: 301 | """Create an SSLContext object to use for the connections.""" 302 | if not use_https: 303 | return None 304 | if tls_ver == 1.1: 305 | context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) 306 | elif tls_ver == 1.2: 307 | context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 308 | 309 | # Allow older ciphers for older ISYs 310 | context.set_ciphers("DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK") 311 | return context 312 | 313 | 314 | def can_https(tls_ver: float) -> bool: 315 | """ 316 | Verify minimum requirements to use an HTTPS connection. 317 | 318 | Returns boolean indicating whether HTTPS is available. 319 | """ 320 | output = True 321 | 322 | # check that Python was compiled against correct OpenSSL lib 323 | if "PROTOCOL_TLSv1_1" not in dir(ssl): 324 | _LOGGER.error("PyISY cannot use HTTPS: Compiled against old OpenSSL library. See docs.") 325 | output = False 326 | 327 | # check the requested TLS version 328 | if tls_ver not in [1.1, 1.2]: 329 | _LOGGER.error("PyISY cannot use HTTPS: Only TLS 1.1 and 1.2 are supported by the ISY controller.") 330 | output = False 331 | 332 | return output 333 | -------------------------------------------------------------------------------- /pyisy/events/__init__.py: -------------------------------------------------------------------------------- 1 | """ISY Event Stream Subclasses.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /pyisy/events/eventreader.py: -------------------------------------------------------------------------------- 1 | """ISY TCP Socket Event Reader.""" 2 | 3 | from __future__ import annotations 4 | 5 | import errno 6 | import select 7 | import ssl 8 | 9 | from ..constants import SOCKET_BUFFER_SIZE 10 | from ..exceptions import ( 11 | ISYInvalidAuthError, 12 | ISYMaxConnections, 13 | ISYStreamDataError, 14 | ISYStreamDisconnected, 15 | ) 16 | 17 | 18 | class ISYEventReader: 19 | """Read in streams of ISY HTTP Events.""" 20 | 21 | HTTP_HEADER_SEPERATOR = b"\r\n" 22 | HTTP_HEADER_BODY_SEPERATOR = b"\r\n\r\n" 23 | HTTP_HEADER_BODY_SEPERATOR_LEN = 4 24 | REACHED_MAX_CONNECTIONS_RESPONSE = b"HTTP/1.1 817" 25 | HTTP_NOT_AUTHORIZED_RESPONSE = b"HTTP/1.1 401" 26 | CONTENT_LENGTH_HEADER = b"content-length" 27 | HEADER_SEPERATOR = b":" 28 | 29 | def __init__(self, isy_read_socket): 30 | """Initialize the ISYEventStream class.""" 31 | self._event_buffer = b"" 32 | self._event_content_length = None 33 | self._event_count = 0 34 | self._socket = isy_read_socket 35 | 36 | def read_events(self, timeout): 37 | """Read events from the socket.""" 38 | events = [] 39 | # poll socket for new data 40 | if not self._receive_into_buffer(timeout): 41 | return events 42 | 43 | while True: 44 | # Read the headers if we do not have content length yet 45 | if not self._event_content_length: 46 | seperator_position = self._event_buffer.find(self.HTTP_HEADER_BODY_SEPERATOR) 47 | if seperator_position == -1: 48 | return events 49 | self._parse_headers(seperator_position) 50 | 51 | # If we do not have a body yet 52 | if len(self._event_buffer) < self._event_content_length: 53 | return events 54 | 55 | # We have the body now 56 | body = self._event_buffer[0 : self._event_content_length] 57 | self._event_count += 1 58 | self._event_buffer = self._event_buffer[self._event_content_length :] 59 | self._event_content_length = None 60 | events.append(body.decode(encoding="utf-8", errors="ignore")) 61 | 62 | def _receive_into_buffer(self, timeout): 63 | """Receive data on available on the socket. 64 | 65 | If we get an empty read on the first read attempt 66 | this means the isy has disconnected. 67 | 68 | If we get an empty read on the first read attempt 69 | and we have seen only one event, the isy has reached 70 | the maximum number of event listeners. 71 | """ 72 | inready, _, _ = select.select([self._socket], [], [], timeout) 73 | if self._socket not in inready: 74 | return False 75 | 76 | try: 77 | # We have data on the wire, read as much as we can 78 | # up to 32 * SOCKET_BUFFER_SIZE 79 | for read_count in range(32): 80 | new_data = self._socket.recv(SOCKET_BUFFER_SIZE) 81 | if len(new_data) == 0: 82 | if read_count != 0: 83 | break 84 | if self._event_count <= 1: 85 | raise ISYMaxConnections(self._event_buffer) 86 | raise ISYStreamDisconnected(self._event_buffer) 87 | 88 | self._event_buffer += new_data 89 | except ssl.SSLWantReadError: 90 | pass 91 | except OSError as ex: 92 | if ex.errno != errno.EWOULDBLOCK: 93 | raise 94 | 95 | return True 96 | 97 | def _parse_headers(self, seperator_position): 98 | """Find the content-length in the headers.""" 99 | headers = self._event_buffer[0:seperator_position] 100 | if headers.startswith(self.REACHED_MAX_CONNECTIONS_RESPONSE): 101 | raise ISYMaxConnections(self._event_buffer) 102 | if headers.startswith(self.HTTP_NOT_AUTHORIZED_RESPONSE): 103 | raise ISYInvalidAuthError(self._event_buffer) 104 | self._event_buffer = self._event_buffer[seperator_position + self.HTTP_HEADER_BODY_SEPERATOR_LEN :] 105 | for header in headers.split(self.HTTP_HEADER_SEPERATOR)[1:]: 106 | header_name, header_value = header.split(self.HEADER_SEPERATOR, 1) 107 | if header_name.strip().lower() != self.CONTENT_LENGTH_HEADER: 108 | continue 109 | self._event_content_length = int(header_value.strip()) 110 | if not self._event_content_length: 111 | raise ISYStreamDataError(headers) 112 | -------------------------------------------------------------------------------- /pyisy/events/strings.py: -------------------------------------------------------------------------------- 1 | """Strings for Event Stream Requests.""" 2 | 3 | from __future__ import annotations 4 | 5 | # Subscribe Message 6 | SUB_MSG = { 7 | "head": """POST /services HTTP/1.1 8 | Host: {addr}:{port}{webroot} 9 | Authorization: {auth} 10 | Content-Length: {length} 11 | Content-Type: text/xml; charset="utf-8" 12 | SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Subscribe\r 13 | \r 14 | """, 15 | "body": """ 16 | 17 | REUSE_SOCKET 18 | infinite 19 | 20 | \r 21 | """, 22 | } 23 | 24 | # Unsubscribe Message 25 | UNSUB_MSG = { 26 | "head": """POST /services HTTP/1.1 27 | Host: {addr}:{port}{webroot} 28 | Authorization: {auth} 29 | Content-Length: {length} 30 | Content-Type: text/xml; charset="utf-8" 31 | SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Unsubscribe\r 32 | \r 33 | """, 34 | "body": """ 35 | 36 | {sid} 37 | 38 | \r 39 | """, 40 | } 41 | 42 | # Resubscribe Message 43 | RESUB_MSG = { 44 | "head": """POST /services HTTP/1.1 45 | Host: {addr}:{port}{webroot} 46 | Authorization: {auth} 47 | Content-Length: {length} 48 | Content-Type: text/xml; charset="utf-8" 49 | SOAPAction: urn:udi-com:device:X_Insteon_Lighting_Service:1#Subscribe\r 50 | \r 51 | """, 52 | "body": """ 53 | 54 | REUSE_SOCKET 55 | infinite 56 | {sid} 57 | 58 | \r 59 | """, 60 | } 61 | -------------------------------------------------------------------------------- /pyisy/events/tcpsocket.py: -------------------------------------------------------------------------------- 1 | """ISY Event Stream.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | import socket 8 | import ssl 9 | import time 10 | import xml 11 | from threading import Thread, ThreadError 12 | from xml.dom import minidom 13 | 14 | from ..constants import ( 15 | ACTION_KEY, 16 | ACTION_KEY_CHANGED, 17 | ATTR_ACTION, 18 | ATTR_CONTROL, 19 | ATTR_ID, 20 | ATTR_STREAM_ID, 21 | ATTR_VAR, 22 | ES_CONNECTED, 23 | ES_DISCONNECTED, 24 | ES_INITIALIZING, 25 | ES_LOADED, 26 | ES_LOST_STREAM_CONNECTION, 27 | POLL_TIME, 28 | PROP_STATUS, 29 | RECONNECT_DELAY, 30 | TAG_EVENT_INFO, 31 | TAG_NODE, 32 | ) 33 | from ..exceptions import ISYInvalidAuthError, ISYMaxConnections, ISYStreamDataError 34 | from ..helpers import attr_from_xml, now, value_from_xml 35 | from ..logging import LOG_VERBOSE 36 | from . import strings 37 | from .eventreader import ISYEventReader 38 | 39 | _LOGGER = logging.getLogger(__name__) # Allows targeting pyisy.events in handlers. 40 | 41 | 42 | class EventStream: 43 | """Class to represent the Event Stream from the ISY.""" 44 | 45 | def __init__(self, isy, connection_info, on_lost_func=None): 46 | """Initialize the EventStream class.""" 47 | self.isy = isy 48 | self._running = False 49 | self._writer = None 50 | self._thread = None 51 | self._subscribed = False 52 | self._connected = False 53 | self._lasthb = None 54 | self._hbwait = 0 55 | self._loaded = None 56 | self._on_lost_function = on_lost_func 57 | self._program_key = None 58 | self.cert = None 59 | self.data = connection_info 60 | 61 | # create TLS encrypted socket if we're using HTTPS 62 | if self.data.get("tls"): 63 | if self.data["tls"] == 1.1: 64 | context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_1) 65 | else: 66 | context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 67 | context.check_hostname = False 68 | self.socket = context.wrap_socket( 69 | socket.socket(socket.AF_INET, socket.SOCK_STREAM), 70 | server_hostname=f"https://{self.data['addr']}", 71 | ) 72 | else: 73 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 74 | 75 | def _create_message(self, msg): 76 | """Prepare a message for sending.""" 77 | head = msg["head"] 78 | body = msg["body"] 79 | body = body.format(**self.data) 80 | length = len(body) 81 | head = head.format(length=length, **self.data) 82 | return head + body 83 | 84 | def _route_message(self, msg): 85 | """Route a received message from the event stream.""" 86 | # check xml formatting 87 | try: 88 | xmldoc = minidom.parseString(msg) 89 | except xml.parsers.expat.ExpatError: 90 | _LOGGER.warning("ISY Received Malformed XML:\n%s", msg) 91 | return 92 | _LOGGER.log(LOG_VERBOSE, "ISY Update Received:\n%s", msg) 93 | 94 | # A wild stream id appears! 95 | if f"{ATTR_STREAM_ID}=" in msg and ATTR_STREAM_ID not in self.data: 96 | self.update_received(xmldoc) 97 | 98 | # direct the event message 99 | cntrl = value_from_xml(xmldoc, ATTR_CONTROL) 100 | if not cntrl: 101 | return 102 | if cntrl == "_0": # ISY HEARTBEAT 103 | if self._loaded is None: 104 | self._loaded = ES_INITIALIZING 105 | self.isy.connection_events.notify(ES_INITIALIZING) 106 | elif self._loaded == ES_INITIALIZING: 107 | self._loaded = ES_LOADED 108 | self.isy.connection_events.notify(ES_LOADED) 109 | self._lasthb = now() 110 | self._hbwait = int(value_from_xml(xmldoc, ATTR_ACTION)) 111 | _LOGGER.debug("ISY HEARTBEAT: %s", self._lasthb.isoformat()) 112 | elif cntrl == PROP_STATUS: # NODE UPDATE 113 | self.isy.nodes.update_received(xmldoc) 114 | elif cntrl[0] != "_": # NODE CONTROL EVENT 115 | self.isy.nodes.control_message_received(xmldoc) 116 | elif cntrl == "_1": # Trigger Update 117 | if f"<{ATTR_VAR}" in msg: # VARIABLE 118 | self.isy.variables.update_received(xmldoc) 119 | elif f"<{ATTR_ID}>" in msg: # PROGRAM 120 | self.isy.programs.update_received(xmldoc) 121 | elif f"<{TAG_NODE}>" in msg and "[" in msg: # Node Server Update 122 | pass # This is most likely a duplicate node update. 123 | elif f"<{ATTR_ACTION}>" in msg: 124 | action = value_from_xml(xmldoc, ATTR_ACTION) 125 | if action == ACTION_KEY: 126 | self.data[ACTION_KEY] = value_from_xml(xmldoc, TAG_EVENT_INFO) 127 | return 128 | if action == ACTION_KEY_CHANGED: 129 | self._program_key = value_from_xml(xmldoc, TAG_NODE) 130 | # Need to reload programs 131 | asyncio.run_coroutine_threadsafe(self.isy.programs.update(), self.isy.loop) 132 | elif cntrl == "_3": # Node Changed/Updated 133 | self.isy.nodes.node_changed_received(xmldoc) 134 | 135 | def update_received(self, xmldoc): 136 | """Set the socket ID.""" 137 | self.data[ATTR_STREAM_ID] = attr_from_xml(xmldoc, "Event", ATTR_STREAM_ID) 138 | _LOGGER.debug("ISY Updated Events Stream ID %s", self.data[ATTR_STREAM_ID]) 139 | 140 | @property 141 | def running(self): 142 | """Return the running state of the thread.""" 143 | try: 144 | return self._thread.isAlive() 145 | except (AttributeError, RuntimeError, ThreadError): 146 | return False 147 | 148 | @running.setter 149 | def running(self, val): 150 | if val and not self.running: 151 | _LOGGER.info("ISY Starting Updates") 152 | if self.connect(): 153 | self.subscribe() 154 | self._running = True 155 | self._thread = Thread(target=self.watch) 156 | self._thread.daemon = True 157 | self._thread.start() 158 | else: 159 | _LOGGER.info("ISY Stopping Updates") 160 | self._running = False 161 | self.unsubscribe() 162 | self.disconnect() 163 | 164 | def write(self, msg): 165 | """Write data back to the socket.""" 166 | if self._writer is None: 167 | raise NotImplementedError("Function not available while socket is closed.") 168 | self._writer.write(msg) 169 | self._writer.flush() 170 | 171 | def connect(self): 172 | """Connect to the event stream socket.""" 173 | if not self._connected: 174 | try: 175 | self.socket.connect((self.data["addr"], self.data["port"])) 176 | if self.data.get("tls"): 177 | self.cert = self.socket.getpeercert() 178 | except OSError: 179 | _LOGGER.exception("PyISY could not connect to ISY event stream.") 180 | if self._on_lost_function is not None: 181 | self._on_lost_function() 182 | return False 183 | self.socket.setblocking(0) 184 | self._writer = self.socket.makefile("w") 185 | self._connected = True 186 | self.isy.connection_events.notify(ES_CONNECTED) 187 | return True 188 | return True 189 | 190 | def disconnect(self): 191 | """Disconnect from the Event Stream socket.""" 192 | if self._connected: 193 | self.socket.close() 194 | self._connected = False 195 | self._subscribed = False 196 | self._running = False 197 | self.isy.connection_events.notify(ES_DISCONNECTED) 198 | 199 | def subscribe(self): 200 | """Subscribe to the Event Stream.""" 201 | if not self._subscribed and self._connected: 202 | if ATTR_STREAM_ID not in self.data: 203 | msg = self._create_message(strings.SUB_MSG) 204 | self.write(msg) 205 | else: 206 | msg = self._create_message(strings.RESUB_MSG) 207 | self.write(msg) 208 | self._subscribed = True 209 | 210 | def unsubscribe(self): 211 | """Unsubscribe from the Event Stream.""" 212 | if self._subscribed and self._connected: 213 | try: 214 | msg = self._create_message(strings.UNSUB_MSG) 215 | self.write(msg) 216 | except (OSError, KeyError): 217 | _LOGGER.exception( 218 | "PyISY encountered a socket error while writing unsubscribe message to the socket.", 219 | ) 220 | self._subscribed = False 221 | self.disconnect() 222 | 223 | @property 224 | def connected(self): 225 | """Return if the module is connected to the ISY or not.""" 226 | return self._connected 227 | 228 | @property 229 | def heartbeat_time(self): 230 | """Return the last ISY Heartbeat time.""" 231 | if self._lasthb is not None: 232 | return (now() - self._lasthb).seconds 233 | return 0.0 234 | 235 | def _lost_connection(self, delay=0): 236 | """React when the event stream connection is lost.""" 237 | _LOGGER.warning("PyISY lost connection to the ISY event stream.") 238 | self.isy.connection_events.notify(ES_LOST_STREAM_CONNECTION) 239 | self.unsubscribe() 240 | if self._on_lost_function is not None: 241 | time.sleep(delay) 242 | self._on_lost_function() 243 | 244 | def watch(self): 245 | """Watch the subscription connection and report if dead.""" 246 | if not self._subscribed: 247 | _LOGGER.debug("PyISY watch called without a subscription.") 248 | return 249 | 250 | event_reader = ISYEventReader(self.socket) 251 | 252 | while self._running and self._subscribed: 253 | # verify connection is still alive 254 | if self.heartbeat_time > self._hbwait: 255 | self._lost_connection() 256 | return 257 | 258 | try: 259 | events = event_reader.read_events(POLL_TIME) 260 | except ISYMaxConnections: 261 | _LOGGER.exception( 262 | "PyISY reached maximum connections, delaying reconnect attempt by %s seconds.", 263 | RECONNECT_DELAY, 264 | ) 265 | self._lost_connection(RECONNECT_DELAY) 266 | return 267 | except ISYInvalidAuthError: 268 | _LOGGER.exception("Invalid authentication used to connect to the event stream.") 269 | return 270 | except ISYStreamDataError as ex: 271 | _LOGGER.warning("PyISY encountered an error while reading the event stream: %s.", ex) 272 | self._lost_connection() 273 | return 274 | except OSError as ex: 275 | _LOGGER.warning( 276 | "PyISY encountered a socket error while reading the event stream: %s.", 277 | ex, 278 | ) 279 | self._lost_connection() 280 | return 281 | 282 | for message in events: 283 | try: 284 | self._route_message(message) 285 | except Exception as ex: # pylint: disable=broad-except # noqa: PERF203 286 | _LOGGER.warning("PyISY encountered while routing message '%s': %s", message, ex) 287 | raise 288 | 289 | def __del__(self): 290 | """Ensure we unsubscribe on destroy.""" 291 | self.unsubscribe() 292 | -------------------------------------------------------------------------------- /pyisy/events/websocket.py: -------------------------------------------------------------------------------- 1 | """ISY Websocket Event Stream.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | import xml 8 | from datetime import datetime 9 | from typing import TYPE_CHECKING 10 | from xml.dom import minidom 11 | 12 | import aiohttp 13 | 14 | from ..connection import get_new_client_session, get_sslcontext 15 | from ..constants import ( 16 | ACTION_KEY, 17 | ACTION_KEY_CHANGED, 18 | ATTR_ACTION, 19 | ATTR_CONTROL, 20 | ATTR_ID, 21 | ATTR_STREAM_ID, 22 | ATTR_VAR, 23 | ES_CONNECTED, 24 | ES_DISCONNECTED, 25 | ES_INITIALIZING, 26 | ES_LOST_STREAM_CONNECTION, 27 | ES_NOT_STARTED, 28 | ES_RECONNECTING, 29 | ES_STOP_UPDATES, 30 | PROP_STATUS, 31 | TAG_EVENT_INFO, 32 | TAG_NODE, 33 | ) 34 | from ..helpers import attr_from_xml, now, value_from_xml 35 | from ..logging import LOG_VERBOSE, enable_logging 36 | 37 | if TYPE_CHECKING: 38 | from ..isy import ISY 39 | 40 | 41 | _LOGGER = logging.getLogger(__name__) # Allows targeting pyisy.events in handlers. 42 | 43 | WS_HEADERS = { 44 | "Sec-WebSocket-Protocol": "ISYSUB", 45 | "Sec-WebSocket-Version": "13", 46 | "Origin": "com.universal-devices.websockets.isy", 47 | } 48 | WS_HEARTBEAT = 30 49 | WS_HB_GRACE = 2 50 | WS_TIMEOUT = 10.0 51 | WS_MAX_RETRIES = 4 52 | WS_RETRY_BACKOFF: list[float] = [0.01, 1, 10, 30, 60] # Seconds 53 | 54 | 55 | class WebSocketClient: 56 | """Class for handling web socket communications with the ISY.""" 57 | 58 | def __init__( 59 | self, 60 | isy: ISY, 61 | address: str, 62 | port: int, 63 | username: str, 64 | password: str, 65 | use_https: bool = False, 66 | tls_ver=1.1, 67 | webroot: str = "", 68 | websession: aiohttp.ClientSession | None = None, 69 | ) -> None: 70 | """Initialize a new Web Socket Client class.""" 71 | if len(_LOGGER.handlers) == 0: 72 | enable_logging(add_null_handler=True) 73 | 74 | self.isy = isy 75 | self._address = address 76 | self._port = port 77 | self._username = username 78 | self._password = password 79 | self._auth = aiohttp.BasicAuth(self._username, self._password) 80 | self._webroot = webroot.rstrip("/") 81 | self._tls_ver = tls_ver 82 | self.use_https = use_https 83 | self._status: str = ES_NOT_STARTED 84 | self._lasthb: datetime | None = None 85 | self._hbwait: int = WS_HEARTBEAT 86 | self._sid = None 87 | self._program_key = None 88 | self.websocket_task: asyncio.Task[None] = None 89 | self.guardian_task: asyncio.Task[None] = None 90 | 91 | if websession is None: 92 | websession = get_new_client_session(use_https, tls_ver) 93 | 94 | self.req_session = websession 95 | self.sslcontext = get_sslcontext(use_https, tls_ver) 96 | self._loop = asyncio.get_running_loop() 97 | self._reconnect_timer: asyncio.TimerHandle | None = None 98 | 99 | self._url = "wss://" if self.use_https else "ws://" 100 | self._url += f"{self._address}:{self._port}{self._webroot}/rest/subscribe" 101 | 102 | def start(self, retries: int = 0) -> None: 103 | """Start the websocket connection.""" 104 | if self.status != ES_CONNECTED: 105 | _LOGGER.debug("Starting websocket connection.") 106 | self.status = ES_INITIALIZING 107 | self.websocket_task = self._loop.create_task(self.websocket(retries)) 108 | self.guardian_task = self._loop.create_task(self._websocket_guardian()) 109 | 110 | def stop(self) -> None: 111 | """Close websocket connection.""" 112 | self.status = ES_STOP_UPDATES 113 | if self.websocket_task is not None: 114 | _LOGGER.debug("Stopping websocket connection.") 115 | self.websocket_task.cancel() 116 | if self.guardian_task is not None: 117 | self.guardian_task.cancel() 118 | self._lasthb = None 119 | if self._reconnect_timer is not None: 120 | self._reconnect_timer.cancel() 121 | self._reconnect_timer = None 122 | 123 | def _reconnect(self, retries: int = 0) -> None: 124 | """Reconnect to a disconnected websocket. 125 | 126 | This is a synchronous method that will be called from the event loop. 127 | 128 | Unlike the async reconnect method, this method does not use asyncio.sleep. 129 | """ 130 | if delay := self._reconnect_prepare(None, retries): 131 | self._reconnect_timer = self._loop.call_later(delay, self._reconnect_execute, retries) 132 | else: 133 | self._reconnect_execute(retries) 134 | 135 | def _reconnect_prepare(self, delay: float | None, retries: int) -> float: 136 | """Start the reconnect process.""" 137 | self.stop() 138 | self.status = ES_RECONNECTING 139 | if delay is None: 140 | delay = WS_RETRY_BACKOFF[retries] 141 | _LOGGER.info("PyISY attempting stream reconnect in %ss.", delay) 142 | return delay 143 | 144 | def _reconnect_execute(self, retries: int) -> None: 145 | """Finish the reconnect process.""" 146 | retries = (retries + 1) if retries < WS_MAX_RETRIES else WS_MAX_RETRIES 147 | self.start(retries) 148 | 149 | @property 150 | def status(self) -> str: 151 | """Return if the websocket is running or not.""" 152 | return self._status 153 | 154 | @status.setter 155 | def status(self, value): 156 | """Set the current node state and notify listeners.""" 157 | if self._status != value: 158 | self._status = value 159 | self.isy.connection_events.notify(self._status) 160 | return self._status 161 | 162 | @property 163 | def last_heartbeat(self) -> datetime | None: 164 | """Return the last received heartbeat time from the ISY.""" 165 | return self._lasthb 166 | 167 | @property 168 | def heartbeat_time(self) -> float: 169 | """Return the time since the last ISY Heartbeat.""" 170 | if self._lasthb is not None: 171 | return (now() - self._lasthb).seconds 172 | return 0.0 173 | 174 | async def _websocket_guardian(self): 175 | """Watch and reset websocket connection if no messages received.""" 176 | while self.status != ES_STOP_UPDATES: 177 | await asyncio.sleep(self._hbwait) 178 | if ( 179 | self.websocket_task.cancelled() 180 | or self.websocket_task.done() 181 | or self.heartbeat_time > self._hbwait + WS_HB_GRACE 182 | ): 183 | _LOGGER.debug("Websocket missed a heartbeat, resetting connection.") 184 | self.status = ES_LOST_STREAM_CONNECTION 185 | self._reconnect() 186 | return 187 | 188 | async def _route_message(self, msg: str) -> None: 189 | """Route a received message from the event stream.""" 190 | # check xml formatting 191 | try: 192 | xmldoc = minidom.parseString(msg) 193 | except xml.parsers.expat.ExpatError: 194 | _LOGGER.warning("ISY Received Malformed XML:\n%s", msg) 195 | return 196 | _LOGGER.log(LOG_VERBOSE, "ISY Update Received:\n%s", msg) 197 | 198 | # A wild stream id appears! 199 | if f"{ATTR_STREAM_ID}=" in msg and self._sid is None: 200 | self.update_received(xmldoc) 201 | 202 | # direct the event message 203 | cntrl = value_from_xml(xmldoc, ATTR_CONTROL) 204 | if not cntrl: 205 | return 206 | if cntrl == "_0": # ISY HEARTBEAT 207 | self._lasthb = now() 208 | self._hbwait = int(value_from_xml(xmldoc, ATTR_ACTION)) 209 | _LOGGER.debug("ISY HEARTBEAT: %s", self._lasthb.isoformat()) 210 | self.isy.connection_events.notify(self._status) 211 | elif cntrl == PROP_STATUS: # NODE UPDATE 212 | self.isy.nodes.update_received(xmldoc) 213 | elif cntrl[0] != "_": # NODE CONTROL EVENT 214 | self.isy.nodes.control_message_received(xmldoc) 215 | elif cntrl == "_1": # Trigger Update 216 | if f"<{ATTR_VAR}" in msg: # VARIABLE (action=6 or 7) 217 | self.isy.variables.update_received(xmldoc) 218 | elif f"<{ATTR_ID}>" in msg: # PROGRAM (action=0) 219 | self.isy.programs.update_received(xmldoc) 220 | elif f"<{TAG_NODE}>" in msg and "[" in msg: # Node Server Update 221 | pass # This is most likely a duplicate node update. 222 | elif f"<{ATTR_ACTION}>" in msg: 223 | action = value_from_xml(xmldoc, ATTR_ACTION) 224 | if action == ACTION_KEY: 225 | self._program_key = value_from_xml(xmldoc, TAG_EVENT_INFO) 226 | return 227 | if action == ACTION_KEY_CHANGED: 228 | self._program_key = value_from_xml(xmldoc, TAG_NODE) 229 | # Need to reload programs 230 | await self.isy.programs.update() 231 | elif cntrl == "_3": # Node Changed/Updated 232 | self.isy.nodes.node_changed_received(xmldoc) 233 | elif cntrl == "_5": # System Status Changed 234 | self.isy.system_status_changed_received(xmldoc) 235 | elif cntrl == "_7": # Progress report, device programming event 236 | self.isy.nodes.progress_report_received(xmldoc) 237 | 238 | def update_received(self, xmldoc: minidom.Element) -> None: 239 | """Set the socket ID.""" 240 | self._sid = attr_from_xml(xmldoc, "Event", ATTR_STREAM_ID) 241 | _LOGGER.debug("ISY Updated Events Stream ID: %s", self._sid) 242 | 243 | async def websocket(self, retries: int = 0) -> None: 244 | """Start websocket connection.""" 245 | try: 246 | async with self.req_session.ws_connect( 247 | self._url, 248 | auth=self._auth, 249 | heartbeat=WS_HEARTBEAT, 250 | headers=WS_HEADERS, 251 | timeout=WS_TIMEOUT, 252 | receive_timeout=self._hbwait + WS_HB_GRACE, 253 | ssl=self.sslcontext, 254 | ) as ws: 255 | self.status = ES_CONNECTED 256 | retries = 0 257 | _LOGGER.debug("Successfully connected to websocket.") 258 | 259 | async for msg in ws: 260 | msg_type = msg.type 261 | if msg_type is aiohttp.WSMsgType.TEXT: 262 | await self._route_message(msg.data) 263 | elif msg_type is aiohttp.WSMsgType.BINARY: 264 | _LOGGER.warning("Unexpected binary message received.") 265 | elif msg_type is aiohttp.WSMsgType.ERROR: 266 | _LOGGER.error("Error during receive %s", ws.exception()) 267 | break 268 | 269 | except asyncio.CancelledError: 270 | self.status = ES_DISCONNECTED 271 | return 272 | except asyncio.TimeoutError: 273 | _LOGGER.debug("Websocket Timeout.") 274 | except aiohttp.ClientConnectorError as err: 275 | _LOGGER.error("Websocket Client Connector Error: %s", err) # noqa: TRY400 276 | except ( 277 | aiohttp.ClientOSError, 278 | aiohttp.client_exceptions.ServerDisconnectedError, 279 | ): 280 | _LOGGER.debug("Websocket Server Not Ready.") 281 | except aiohttp.client_exceptions.WSServerHandshakeError as err: 282 | _LOGGER.warning("Web socket server response error: %s", err.message) 283 | # pylint: disable=broad-except 284 | except Exception: 285 | _LOGGER.exception("Unexpected websocket error") 286 | else: 287 | if isinstance(ws.exception(), asyncio.TimeoutError): 288 | _LOGGER.debug("Websocket Timeout.") 289 | elif isinstance(ws.exception(), aiohttp.streams.EofStream): 290 | _LOGGER.warning("Websocket disconnected unexpectedly. Check network connection.") 291 | else: 292 | _LOGGER.warning("Websocket disconnected unexpectedly with code: %s", ws.close_code) 293 | if self.status != ES_STOP_UPDATES: 294 | self.status = ES_LOST_STREAM_CONNECTION 295 | self._reconnect(retries=retries) 296 | -------------------------------------------------------------------------------- /pyisy/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used by the PyISY module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from xml.parsers.expat import ExpatError 6 | 7 | XML_ERRORS = (AttributeError, KeyError, ValueError, TypeError, IndexError, ExpatError) 8 | XML_PARSE_ERROR = "ISY Could not parse response, poorly formatted XML." 9 | 10 | 11 | class ISYInvalidAuthError(Exception): 12 | """Invalid authorization credentials provided.""" 13 | 14 | 15 | class ISYConnectionError(Exception): 16 | """Invalid connection parameters provided.""" 17 | 18 | 19 | class ISYResponseParseError(Exception): 20 | """Error parsing a response provided by the ISY.""" 21 | 22 | 23 | class ISYStreamDataError(Exception): 24 | """Invalid data in the isy event stream.""" 25 | 26 | 27 | class ISYStreamDisconnected(ISYStreamDataError): 28 | """The isy has disconnected.""" 29 | 30 | 31 | class ISYMaxConnections(ISYStreamDisconnected): 32 | """The isy has disconnected because it reached maximum connections.""" 33 | -------------------------------------------------------------------------------- /pyisy/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the PyISY Module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import time 7 | from collections.abc import Callable 8 | from dataclasses import dataclass, is_dataclass 9 | from xml.dom import minidom 10 | 11 | from .constants import ( 12 | ATTR_FORMATTED, 13 | ATTR_ID, 14 | ATTR_PRECISION, 15 | ATTR_UNIT_OF_MEASURE, 16 | ATTR_VALUE, 17 | DEFAULT_PRECISION, 18 | DEFAULT_UNIT_OF_MEASURE, 19 | INSTEON_RAMP_RATES, 20 | ISY_EPOCH_OFFSET, 21 | ISY_PROP_NOT_SET, 22 | ISY_VALUE_UNKNOWN, 23 | PROP_BATTERY_LEVEL, 24 | PROP_RAMP_RATE, 25 | PROP_STATUS, 26 | TAG_CATEGORY, 27 | TAG_GENERIC, 28 | TAG_MFG, 29 | TAG_PROPERTY, 30 | UOM_SECONDS, 31 | ) 32 | from .exceptions import XML_ERRORS 33 | from .logging import _LOGGER 34 | 35 | 36 | def parse_xml_properties(xmldoc: minidom.Document) -> tuple[NodeProperty, dict[str, NodeProperty], bool]: 37 | """ 38 | Parse the xml properties string. 39 | 40 | Args: 41 | xmldoc: xml document to parse 42 | 43 | Returns: 44 | (state, aux_props, state_set) 45 | 46 | """ 47 | aux_props: dict[str, NodeProperty] = {} 48 | state_set = False 49 | state = NodeProperty(PROP_STATUS, uom=ISY_PROP_NOT_SET) 50 | 51 | props = xmldoc.getElementsByTagName(TAG_PROPERTY) 52 | if not props: 53 | return state, aux_props, state_set 54 | 55 | for prop in props: 56 | prop_id = attr_from_element(prop, ATTR_ID) 57 | uom = attr_from_element(prop, ATTR_UNIT_OF_MEASURE, DEFAULT_UNIT_OF_MEASURE) 58 | value = attr_from_element(prop, ATTR_VALUE, "").strip() 59 | prec = attr_from_element(prop, ATTR_PRECISION, DEFAULT_PRECISION) 60 | formatted = attr_from_element(prop, ATTR_FORMATTED, value) 61 | 62 | # ISY firmwares < 5 return a list of possible units. 63 | # ISYv5+ returns a UOM string which is checked against the SDK. 64 | # Only return a list if the UOM should be a list. 65 | if "/" in uom and uom != "n/a": 66 | uom = uom.split("/") 67 | 68 | value = int(value) if value.strip() != "" else ISY_VALUE_UNKNOWN 69 | 70 | result = NodeProperty(prop_id, value, prec, uom, formatted) 71 | 72 | if prop_id == PROP_STATUS: 73 | state = result 74 | state_set = True 75 | elif prop_id == PROP_BATTERY_LEVEL and not state_set: 76 | state = result 77 | else: 78 | if prop_id == PROP_RAMP_RATE: 79 | result.value = INSTEON_RAMP_RATES.get(value, value) 80 | result.uom = UOM_SECONDS 81 | aux_props[prop_id] = result 82 | 83 | return state, aux_props, state_set 84 | 85 | 86 | def value_from_xml(xml: minidom.Element, tag_name: str, default: object | None = None) -> object | None: 87 | """Extract a value from the XML element.""" 88 | value = default 89 | try: 90 | value = xml.getElementsByTagName(tag_name)[0].firstChild.toxml() 91 | except XML_ERRORS: 92 | pass 93 | return value 94 | 95 | 96 | def attr_from_xml( 97 | xml: minidom.Element, tag_name: str, attr_name: str, default: object | None = None 98 | ) -> object | None: 99 | """Extract an attribute value from the raw XML.""" 100 | value = default 101 | try: 102 | root = xml.getElementsByTagName(tag_name)[0] 103 | value = attr_from_element(root, attr_name, default) 104 | except XML_ERRORS: 105 | pass 106 | return value 107 | 108 | 109 | def attr_from_element( 110 | element: minidom.Element, attr_name: str, default: object | None = None 111 | ) -> object | None: 112 | """Extract an attribute value from an XML element.""" 113 | value = default 114 | if attr_name in element.attributes: 115 | value = element.attributes[attr_name].value 116 | return value 117 | 118 | 119 | def value_from_nested_xml(base: minidom.Element, chain, default: object | None = None) -> object | None: 120 | """Extract a value from multiple nested tags.""" 121 | value = default 122 | result = None 123 | try: 124 | result = base.getElementsByTagName(chain[0])[0] 125 | if len(chain) > 1: 126 | result = result.getElementsByTagName(chain[1])[0] 127 | if len(chain) > 2: 128 | result = result.getElementsByTagName(chain[2])[0] 129 | if len(chain) > 3: 130 | result = result.getElementsByTagName(chain[3])[0] 131 | value = result.firstChild.toxml() 132 | except XML_ERRORS: 133 | pass 134 | return value 135 | 136 | 137 | def ntp_to_system_time(timestamp): 138 | """Convert a ISY NTP time to system UTC time. 139 | 140 | Adapted from Python ntplib module. 141 | https://pypi.org/project/ntplib/ 142 | 143 | Parameters: 144 | timestamp -- timestamp in NTP time 145 | 146 | Returns: 147 | corresponding system time 148 | 149 | Note: The ISY uses a EPOCH_OFFSET in addition to standard NTP. 150 | 151 | """ 152 | _system_epoch = datetime.date(*time.gmtime(0)[0:3]) 153 | _ntp_epoch = datetime.date(1900, 1, 1) 154 | ntp_delta = ((_system_epoch - _ntp_epoch).days * 24 * 3600) - ISY_EPOCH_OFFSET 155 | 156 | return datetime.datetime.fromtimestamp(timestamp - ntp_delta) 157 | 158 | 159 | def now() -> datetime.datetime: 160 | """Get the current system time. 161 | 162 | Note: this module uses naive datetimes because the 163 | ISY is highly inconsistent with time conventions 164 | and does not present enough information to accurately 165 | manage DST without significant guessing and effort. 166 | """ 167 | return datetime.datetime.now() 168 | 169 | 170 | class EventEmitter: 171 | """Event Emitter class.""" 172 | 173 | _subscribers: list[EventListener] 174 | 175 | def __init__(self) -> None: 176 | """Initialize a new Event Emitter class.""" 177 | self._subscribers: list[EventListener] = [] 178 | 179 | def subscribe(self, callback: Callable, event_filter: dict | str | None = None, key: str | None = None): 180 | """Subscribe to the events.""" 181 | listener = EventListener(emitter=self, callback=callback, event_filter=event_filter, key=key) 182 | self._subscribers.append(listener) 183 | return listener 184 | 185 | def unsubscribe(self, listener: EventListener): 186 | """Unsubscribe from the events.""" 187 | self._subscribers.remove(listener) 188 | 189 | def notify(self, event): 190 | """Notify a listener.""" 191 | for subscriber in self._subscribers: 192 | # Guard against downstream errors interrupting the socket connection (#249) 193 | try: 194 | if e_filter := subscriber.event_filter: 195 | if is_dataclass(event) and isinstance(e_filter, dict): 196 | if not (e_filter.items() <= event.__dict__.items()): 197 | continue 198 | elif event != e_filter: 199 | continue 200 | 201 | if subscriber.key: 202 | subscriber.callback(event, subscriber.key) 203 | continue 204 | subscriber.callback(event) 205 | except Exception: # pylint: disable=broad-except 206 | _LOGGER.exception("Error during callback of %s", event) 207 | 208 | 209 | @dataclass 210 | class EventListener: 211 | """Event Listener class.""" 212 | 213 | emitter: EventEmitter 214 | callback: Callable 215 | event_filter: dict | str 216 | key: str 217 | 218 | def unsubscribe(self) -> None: 219 | """Unsubscribe from the events.""" 220 | self.emitter.unsubscribe(self) 221 | 222 | 223 | @dataclass 224 | class NodeProperty: 225 | """Class to hold result of a control event or node aux property.""" 226 | 227 | control: str 228 | value: int | float = ISY_VALUE_UNKNOWN 229 | prec: str = DEFAULT_PRECISION 230 | uom: str = DEFAULT_UNIT_OF_MEASURE 231 | formatted: str = None 232 | address: str = None 233 | 234 | 235 | @dataclass 236 | class ZWaveProperties: 237 | """Class to hold Z-Wave Product Details from a Z-Wave Node.""" 238 | 239 | category: str = "0" 240 | devtype_mfg: str = "0.0.0" 241 | devtype_gen: str = "0.0.0" 242 | basic_type: str = "0" 243 | generic_type: str = "0" 244 | specific_type: str = "0" 245 | mfr_id: str = "0" 246 | prod_type_id: str = "0" 247 | product_id: str = "0" 248 | raw: str = "" 249 | 250 | @classmethod 251 | def from_xml(cls, xml: minidom.Element) -> ZWaveProperties: 252 | """Return a Z-Wave Properties class from an xml DOM object.""" 253 | category = value_from_xml(xml, TAG_CATEGORY) 254 | devtype_mfg = value_from_xml(xml, TAG_MFG) 255 | devtype_gen = value_from_xml(xml, TAG_GENERIC) 256 | raw = xml.toxml() 257 | basic_type = "0" 258 | generic_type = "0" 259 | specific_type = "0" 260 | mfr_id = "0" 261 | prod_type_id = "0" 262 | product_id = "0" 263 | if devtype_gen: 264 | (basic_type, generic_type, specific_type) = devtype_gen.split(".") 265 | if devtype_mfg: 266 | (mfr_id, prod_type_id, product_id) = devtype_mfg.split(".") 267 | 268 | return ZWaveProperties( 269 | category=category, 270 | devtype_mfg=devtype_mfg, 271 | devtype_gen=devtype_gen, 272 | basic_type=basic_type, 273 | generic_type=generic_type, 274 | specific_type=specific_type, 275 | mfr_id=mfr_id, 276 | prod_type_id=prod_type_id, 277 | product_id=product_id, 278 | raw=raw, 279 | ) 280 | -------------------------------------------------------------------------------- /pyisy/isy.py: -------------------------------------------------------------------------------- 1 | """Module for connecting to and interacting with the ISY.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from threading import Thread 7 | from xml.dom import minidom 8 | 9 | import aiohttp 10 | 11 | from .clock import Clock 12 | from .configuration import Configuration 13 | from .connection import Connection 14 | from .constants import ( 15 | ATTR_ACTION, 16 | CMD_X10, 17 | CONFIG_NETWORKING, 18 | CONFIG_PORTAL, 19 | ES_CONNECTED, 20 | ES_RECONNECT_FAILED, 21 | ES_RECONNECTING, 22 | ES_START_UPDATES, 23 | ES_STOP_UPDATES, 24 | PROTO_ISY, 25 | SYSTEM_BUSY, 26 | SYSTEM_STATUS, 27 | URL_QUERY, 28 | X10_COMMANDS, 29 | ) 30 | from .events.tcpsocket import EventStream 31 | from .events.websocket import WebSocketClient 32 | from .helpers import EventEmitter, value_from_xml 33 | from .logging import _LOGGER, enable_logging 34 | from .networking import NetworkResources 35 | from .node_servers import NodeServers 36 | from .nodes import Nodes 37 | from .programs import Programs 38 | from .variables import Variables 39 | 40 | 41 | class ISY: 42 | """ 43 | This is the main class that handles interaction with the ISY device. 44 | 45 | | address: String of the IP address of the ISY device 46 | | port: String of the port over which the ISY is serving its API 47 | | username: String of the administrator username for the ISY 48 | | password: String of the administrator password for the ISY 49 | | use_https: [optional] Boolean of whether secured HTTP should be used 50 | | tls_ver: [optional] Number indicating the version of TLS encryption to 51 | use. Valid options are 1.1 or 1.2. 52 | 53 | :ivar auto_reconnect: Boolean value that indicates if the class should 54 | auto-reconnect to the event stream if the connection 55 | is lost. 56 | :ivar auto_update: Boolean value that controls the class's subscription to 57 | the event stream that allows node, program 58 | values to be updated automatically. 59 | :ivar connected: Read only boolean value indicating if the class is 60 | connected to the controller. 61 | :ivar nodes: :class:`pyisy.nodes.Nodes` manager that interacts with 62 | Insteon nodes and groups. 63 | :ivar programs: Program manager that interacts with ISY programs and i 64 | folders. 65 | :ivar variables: Variable manager that interacts with ISY variables. 66 | """ 67 | 68 | auto_reconnect = True 69 | 70 | def __init__( 71 | self, 72 | address: str, 73 | port: int, 74 | username: str, 75 | password: str, 76 | use_https: bool = False, 77 | tls_ver: float = 1.1, 78 | webroot: str = "", 79 | websession: aiohttp.ClientSession | None = None, 80 | use_websocket: bool = False, 81 | ) -> None: 82 | """Initialize the primary ISY Class.""" 83 | self._events: EventStream | None = None # create this JIT so no socket reuse 84 | self._reconnect_thread = None 85 | self._connected: bool = False 86 | 87 | if len(_LOGGER.handlers) == 0: 88 | enable_logging(add_null_handler=True) 89 | 90 | self.conn = Connection( 91 | address=address, 92 | port=port, 93 | username=username, 94 | password=password, 95 | use_https=use_https, 96 | tls_ver=tls_ver, 97 | webroot=webroot, 98 | websession=websession, 99 | ) 100 | 101 | self.websocket: WebSocketClient | None = None 102 | if use_websocket: 103 | self.websocket = WebSocketClient( 104 | isy=self, 105 | address=address, 106 | port=port, 107 | username=username, 108 | password=password, 109 | use_https=use_https, 110 | tls_ver=tls_ver, 111 | webroot=webroot, 112 | websession=websession, 113 | ) 114 | 115 | self.configuration: Configuration | None = None 116 | self.clock: Clock | None = None 117 | self.nodes: Nodes | None = None 118 | self.node_servers: NodeServers | None = None 119 | self.programs: Programs | None = None 120 | self.variables: Variables | None = None 121 | self.networking: NetworkResources | None = None 122 | self._hostname = address 123 | self.connection_events = EventEmitter() 124 | self.status_events = EventEmitter() 125 | self.system_status = SYSTEM_BUSY 126 | self.loop = asyncio.get_running_loop() 127 | self._uuid: str | None = None 128 | 129 | async def initialize(self, with_node_servers=False): 130 | """Initialize the connection with the ISY.""" 131 | config_xml = await self.conn.test_connection() 132 | self.configuration = Configuration(xml=config_xml) 133 | self._uuid = self.configuration["uuid"] 134 | if not self.configuration["model"].startswith("ISY 994"): 135 | self.conn.increase_available_connections() 136 | 137 | isy_setup_tasks = [ 138 | self.conn.get_status(), 139 | self.conn.get_time(), 140 | self.conn.get_nodes(), 141 | self.conn.get_programs(), 142 | self.conn.get_variable_defs(), 143 | self.conn.get_variables(), 144 | ] 145 | if self.configuration[CONFIG_NETWORKING] or self.configuration.get(CONFIG_PORTAL): 146 | isy_setup_tasks.append(asyncio.create_task(self.conn.get_network())) 147 | isy_setup_results = await asyncio.gather(*isy_setup_tasks) 148 | 149 | self.clock = Clock(self, xml=isy_setup_results[1]) 150 | self.nodes = Nodes(self, xml=isy_setup_results[2]) 151 | self.programs = Programs(self, xml=isy_setup_results[3]) 152 | self.variables = Variables( 153 | self, 154 | def_xml=isy_setup_results[4], 155 | var_xml=isy_setup_results[5], 156 | ) 157 | if self.configuration[CONFIG_NETWORKING] or self.configuration.get(CONFIG_PORTAL): 158 | self.networking = NetworkResources(self, xml=isy_setup_results[6]) 159 | await self.nodes.update(xml=isy_setup_results[0]) 160 | if self.node_servers and with_node_servers: 161 | await self.node_servers.load_node_servers() 162 | 163 | self._connected = True 164 | 165 | async def shutdown(self) -> None: 166 | """Cleanup connections and prepare for exit.""" 167 | if self.websocket is not None: 168 | self.websocket.stop() 169 | if self._events is not None and self._events.running: 170 | self.connection_events.notify(ES_STOP_UPDATES) 171 | self._events.running = False 172 | await self.conn.close() 173 | 174 | @property 175 | def conf(self) -> Configuration: 176 | """Return the status of the connection (shortcut property).""" 177 | return self.configuration 178 | 179 | @property 180 | def connected(self) -> bool: 181 | """Return the status of the connection.""" 182 | return self._connected 183 | 184 | @property 185 | def auto_update(self) -> bool: 186 | """Return the auto_update property.""" 187 | if self.websocket is not None: 188 | return self.websocket.status == ES_CONNECTED 189 | if self._events is not None: 190 | return self._events.running 191 | return False 192 | 193 | @auto_update.setter 194 | def auto_update(self, val: bool) -> None: 195 | """Set the auto_update property.""" 196 | if self.websocket is not None: 197 | _LOGGER.warning("Websockets are enabled. Use isy.websocket.start() or .stop() instead.") 198 | return 199 | if val and not self.auto_update: 200 | # create new event stream socket 201 | self._events = EventStream(self, self.conn.connection_info, self._on_lost_event_stream) 202 | if self._events is not None: 203 | self.connection_events.notify(ES_START_UPDATES if val else ES_STOP_UPDATES) 204 | self._events.running = val 205 | 206 | @property 207 | def hostname(self) -> str: 208 | """Return the hostname.""" 209 | return self._hostname 210 | 211 | @property 212 | def protocol(self) -> str: 213 | """Return the protocol for this entity.""" 214 | return PROTO_ISY 215 | 216 | @property 217 | def uuid(self) -> str: 218 | """Return the ISY's uuid.""" 219 | return self._uuid 220 | 221 | def _on_lost_event_stream(self) -> None: 222 | """Handle lost connection to event stream.""" 223 | del self._events 224 | self._events = None 225 | 226 | if self.auto_reconnect and self._reconnect_thread is None: 227 | # attempt to reconnect 228 | self._reconnect_thread = Thread(target=self._auto_reconnecter) 229 | self._reconnect_thread.daemon = True 230 | self._reconnect_thread.start() 231 | 232 | def _auto_reconnecter(self) -> None: 233 | """Auto-reconnect to the event stream.""" 234 | while self.auto_reconnect and not self.auto_update: 235 | _LOGGER.warning("PyISY attempting stream reconnect.") 236 | del self._events 237 | self._events = EventStream(self, self.conn.connection_info, self._on_lost_event_stream) 238 | self._events.running = True 239 | self.connection_events.notify(ES_RECONNECTING) 240 | 241 | if not self.auto_update: 242 | del self._events 243 | self._events = None 244 | _LOGGER.warning("PyISY could not reconnect to the event stream.") 245 | self.connection_events.notify(ES_RECONNECT_FAILED) 246 | else: 247 | _LOGGER.warning("PyISY reconnected to the event stream.") 248 | 249 | self._reconnect_thread = None 250 | 251 | async def query(self, address: str | None = None) -> bool: 252 | """Query all the nodes or a specific node if an address is provided . 253 | 254 | Args: 255 | address (string, optional): Node Address to query. Defaults to None. 256 | 257 | Returns: 258 | boolean: Returns `True` on successful command, `False` on error. 259 | """ 260 | req_path = [URL_QUERY] 261 | if address is not None: 262 | req_path.append(address) 263 | req_url = self.conn.compile_url(req_path) 264 | if not await self.conn.request(req_url): 265 | _LOGGER.warning("Error performing query.") 266 | return False 267 | _LOGGER.debug("ISY Query requested successfully.") 268 | return True 269 | 270 | async def send_x10_cmd(self, address: str, cmd: str) -> None: 271 | """ 272 | Send an X10 command. 273 | 274 | address: String of X10 device address (Ex: A10) 275 | cmd: String of command to execute. Any key of x10_commands can be used 276 | """ 277 | if cmd in X10_COMMANDS: 278 | command = X10_COMMANDS.get(cmd) 279 | req_url = self.conn.compile_url([CMD_X10, address, str(command)]) 280 | result = await self.conn.request(req_url) 281 | if result is not None: 282 | _LOGGER.info("ISY Sent X10 Command: %s To: %s", cmd, address) 283 | else: 284 | _LOGGER.error("ISY Failed to send X10 Command: %s To: %s", cmd, address) 285 | 286 | def system_status_changed_received(self, xmldoc: minidom.Element) -> None: 287 | """Handle System Status events from an event stream message.""" 288 | action = value_from_xml(xmldoc, ATTR_ACTION) 289 | if not action or action not in SYSTEM_STATUS: 290 | return 291 | self.system_status = action 292 | self.status_events.notify(action) 293 | -------------------------------------------------------------------------------- /pyisy/logging.py: -------------------------------------------------------------------------------- 1 | """Logging helper functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | _LOGGER = logging.getLogger(__package__) 8 | LOG_LEVEL = logging.DEBUG 9 | LOG_VERBOSE = 5 10 | LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s" 11 | LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" 12 | 13 | 14 | def enable_logging( 15 | level=LOG_LEVEL, 16 | add_null_handler: bool = False, 17 | log_no_color: bool = False, 18 | ) -> None: 19 | """Set up the logging.""" 20 | # Adapted from home-assistant/core/homeassistant/bootstrap.py 21 | if not log_no_color and not add_null_handler: 22 | try: 23 | # pylint: disable=import-outside-toplevel 24 | from colorlog import ColoredFormatter 25 | 26 | # basicConfig must be called after importing colorlog in order to 27 | # ensure that the handlers it sets up wraps the correct streams. 28 | logging.basicConfig(level=level) 29 | logging.addLevelName(LOG_VERBOSE, "VERBOSE") 30 | 31 | colorfmt = f"%(log_color)s{LOG_FORMAT}%(reset)s" 32 | logging.getLogger().handlers[0].setFormatter( 33 | ColoredFormatter( 34 | colorfmt, 35 | datefmt=LOG_DATE_FORMAT, 36 | reset=True, 37 | log_colors={ 38 | "VERBOSE": "blue", 39 | "DEBUG": "cyan", 40 | "INFO": "green", 41 | "WARNING": "yellow", 42 | "ERROR": "red", 43 | "CRITICAL": "red", 44 | }, 45 | ) 46 | ) 47 | except ImportError: 48 | pass 49 | 50 | # If the above initialization failed for any reason, setup the default 51 | # formatting. If the above succeeds, this will result in a no-op. 52 | logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=level) 53 | 54 | if add_null_handler: 55 | _LOGGER.addHandler(logging.NullHandler()) 56 | 57 | # Suppress overly verbose logs from libraries that aren't helpful 58 | logging.getLogger("requests").setLevel(logging.WARNING) 59 | logging.getLogger("urllib3").setLevel(logging.WARNING) 60 | logging.getLogger("aiohttp.access").setLevel(logging.WARNING) 61 | -------------------------------------------------------------------------------- /pyisy/networking.py: -------------------------------------------------------------------------------- 1 | """ISY Network Resources Module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from asyncio import sleep 6 | from typing import TYPE_CHECKING 7 | from xml.dom import minidom 8 | 9 | from .constants import ( 10 | ATTR_ID, 11 | PROTO_NETWORK, 12 | TAG_NAME, 13 | TAG_NET_RULE, 14 | URL_NETWORK, 15 | URL_RESOURCES, 16 | ) 17 | from .exceptions import XML_ERRORS, XML_PARSE_ERROR 18 | from .helpers import value_from_xml 19 | from .logging import _LOGGER 20 | 21 | if TYPE_CHECKING: 22 | from .isy import ISY 23 | 24 | 25 | class NetworkResources: 26 | """ 27 | Network Resources class cobject. 28 | 29 | DESCRIPTION: 30 | This class handles the ISY networking module. 31 | 32 | USAGE: 33 | This object may be used in a similar way as a 34 | dictionary with the either networking command 35 | names or ids being used as keys and the ISY 36 | networking command class will be returned. 37 | 38 | EXAMPLE: 39 | # a = networking['test function'] 40 | # a.run() 41 | 42 | ATTRIBUTES: 43 | isy: The ISY device class 44 | addresses: List of net command ids 45 | nnames: List of net command names 46 | nobjs: List of net command objects 47 | 48 | """ 49 | 50 | def __init__(self, isy: ISY, xml: str | None = None) -> None: 51 | """ 52 | Initialize the network resources class. 53 | 54 | isy: ISY class 55 | xml: String of xml data containing the configuration data 56 | """ 57 | self.isy = isy 58 | 59 | self.addresses: list[int] = [] 60 | self._address_index: dict[int, int] = {} 61 | self.nnames: list[str] = [] 62 | self.nobjs: list[NetworkCommand] = [] 63 | 64 | if xml is not None: 65 | self.parse(xml) 66 | 67 | def parse(self, xml: str) -> None: 68 | """ 69 | Parse the xml data. 70 | 71 | xml: String of the xml data 72 | """ 73 | try: 74 | xmldoc = minidom.parseString(xml) 75 | except XML_ERRORS: 76 | _LOGGER.error("%s: NetworkResources, resources not loaded", XML_PARSE_ERROR) 77 | return 78 | 79 | features = xmldoc.getElementsByTagName(TAG_NET_RULE) 80 | for feature in features: 81 | address = int(value_from_xml(feature, ATTR_ID)) 82 | if address in self._address_index: 83 | continue 84 | nname = value_from_xml(feature, TAG_NAME) 85 | nobj = NetworkCommand(self, address, nname) 86 | self.addresses.append(address) 87 | self._address_index[address] = len(self.addresses) - 1 88 | self.nnames.append(nname) 89 | self.nobjs.append(nobj) 90 | 91 | _LOGGER.info("ISY Loaded Network Resources Commands") 92 | 93 | async def update(self, wait_time: int = 0) -> None: 94 | """ 95 | Update the contents of the networking class. 96 | 97 | wait_time: [optional] Amount of seconds to wait before updating 98 | """ 99 | await sleep(wait_time) 100 | xml = await self.isy.conn.get_network() 101 | self.parse(xml) 102 | 103 | async def update_threaded(self, interval: int) -> None: 104 | """ 105 | Continually update the class until it is told to stop. 106 | 107 | Should be run in a thread. 108 | """ 109 | while self.isy.auto_update: 110 | await self.update(interval) 111 | 112 | def __getitem__(self, val: str | int) -> NetworkCommand | None: 113 | """Return the item from the collection.""" 114 | try: 115 | val = int(val) 116 | return self.get_by_id(val) 117 | except (ValueError, KeyError): 118 | return self.get_by_name(val) 119 | 120 | def __setitem__(self, val, value): 121 | """Set the item value.""" 122 | return 123 | 124 | def get_by_id(self, val: int) -> NetworkCommand | None: 125 | """ 126 | Return command object being given a command id. 127 | 128 | val: Integer representing command id 129 | """ 130 | ind = self._address_index.get(val) 131 | return None if ind is None else self.get_by_index(ind) 132 | 133 | def get_by_name(self, val: str) -> NetworkCommand | None: 134 | """ 135 | Return command object being given a command name. 136 | 137 | val: String representing command name 138 | """ 139 | try: 140 | ind = self.nnames.index(val) 141 | return self.get_by_index(ind) 142 | except (ValueError, KeyError): 143 | return None 144 | 145 | def get_by_index(self, val: int) -> NetworkCommand | None: 146 | """ 147 | Return command object being given a command index. 148 | 149 | val: Integer representing command index in List 150 | """ 151 | return self.nobjs[val] 152 | 153 | 154 | class NetworkCommand: 155 | """ 156 | Network Command Class. 157 | 158 | DESCRIPTION: 159 | This class handles individual networking commands. 160 | 161 | ATTRIBUTES: 162 | network_resources: The networkin resources class 163 | 164 | """ 165 | 166 | def __init__(self, network_resources: NetworkResources, address: int, name: str) -> None: 167 | """Initialize network command class. 168 | 169 | network_resources: NetworkResources class 170 | address: Integer of the command id 171 | """ 172 | self._network_resources = network_resources 173 | self.isy = network_resources.isy 174 | self._id = address 175 | self._name = name 176 | 177 | @property 178 | def address(self) -> str: 179 | """Return the Resource ID for the Network Resource.""" 180 | return str(self._id) 181 | 182 | @property 183 | def name(self) -> str: 184 | """Return the name of this entity.""" 185 | return self._name 186 | 187 | @property 188 | def protocol(self) -> str: 189 | """Return the Protocol for this node.""" 190 | return PROTO_NETWORK 191 | 192 | async def run(self) -> None: 193 | """Execute the networking command.""" 194 | address = self.address 195 | req_url = self.isy.conn.compile_url([URL_NETWORK, URL_RESOURCES, address]) 196 | 197 | if not await self.isy.conn.request(req_url, ok404=True): 198 | _LOGGER.warning("ISY could not run networking command: %s", address) 199 | return 200 | _LOGGER.debug("ISY ran networking command: %s", address) 201 | -------------------------------------------------------------------------------- /pyisy/node_servers.py: -------------------------------------------------------------------------------- 1 | """ISY Node Server Information.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import re 7 | from dataclasses import dataclass 8 | from typing import TYPE_CHECKING 9 | from xml.dom import getDOMImplementation, minidom 10 | 11 | from .constants import ( 12 | ATTR_ID, 13 | ATTR_UNIT_OF_MEASURE, 14 | TAG_ENABLED, 15 | TAG_NAME, 16 | TAG_ROOT, 17 | URL_PROFILE_NS, 18 | ) 19 | from .exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError 20 | from .helpers import attr_from_element, value_from_xml 21 | from .logging import _LOGGER 22 | 23 | if TYPE_CHECKING: 24 | from .isy import ISY 25 | 26 | ATTR_DIR = "dir" 27 | ATTR_EDITOR = "editor" 28 | ATTR_NLS = "nls" 29 | ATTR_SUBSET = "subset" 30 | ATTR_PROFILE = "profile" 31 | 32 | TAG_ACCEPTS = "accepts" 33 | TAG_CMD = "cmd" 34 | TAG_CONNECTION = "connection" 35 | TAG_FILE = "file" 36 | TAG_FILES = "files" 37 | TAG_IP = "ip" 38 | TAG_BASE_URL = "baseurl" 39 | TAG_ISY_USER_NUM = "isyusernum" 40 | TAG_NODE_DEF = "nodeDef" 41 | TAG_NS_USER = "nsuser" 42 | TAG_PORT = "port" 43 | TAG_RANGE = "range" 44 | TAG_SENDS = "sends" 45 | TAG_SNI = "sni" 46 | TAG_SSL = "ssl" 47 | TAG_ST = "st" 48 | TAG_TIMEOUT = "timeout" 49 | 50 | 51 | class NodeServers: 52 | """ 53 | ISY NodeServers class object. 54 | 55 | DESCRIPTION: 56 | This class handles the ISY Node Servers info. 57 | 58 | ATTRIBUTES: 59 | isy: The ISY device class 60 | 61 | """ 62 | 63 | def __init__(self, isy: ISY, slots: list[str]) -> None: 64 | """ 65 | Initialize the NodeServers class. 66 | 67 | isy: ISY class 68 | slots: List of slot numbers 69 | """ 70 | self.isy = isy 71 | self._slots = slots 72 | self._connections = [] 73 | self._profiles = {} 74 | self._node_server_node_definitions = [] 75 | self._node_server_node_editors = [] 76 | self._node_server_nls = [] 77 | self.loaded: bool = False 78 | 79 | async def load_node_servers(self) -> None: 80 | """Load information about node servers from the ISY.""" 81 | 82 | await self.get_connection_info() 83 | await self.get_node_server_profiles() 84 | for slot in self._slots: 85 | await self.parse_node_server_defs(slot) 86 | self.loaded = True 87 | _LOGGER.info("ISY updated node servers") 88 | # _LOGGER.debug(self._node_server_node_definitions) 89 | # _LOGGER.debug(self._node_server_node_editors) 90 | 91 | async def get_connection_info(self) -> None: 92 | """Fetch the node server connections from the ISY.""" 93 | result = await self.isy.conn.request( 94 | self.isy.conn.compile_url([URL_PROFILE_NS, "0", "connection"]), 95 | ok404=False, 96 | ) 97 | if result is None: 98 | return 99 | 100 | try: 101 | connections_xml = minidom.parseString(result) 102 | except XML_ERRORS as exc: 103 | _LOGGER.error("%s while parsing Node Server connections", XML_PARSE_ERROR) 104 | raise ISYResponseParseError(XML_PARSE_ERROR) from exc 105 | 106 | connections = connections_xml.getElementsByTagName(TAG_CONNECTION) 107 | for connection in connections: 108 | self._connections.append( 109 | NodeServerConnection( 110 | slot=attr_from_element(connection, ATTR_PROFILE), 111 | enabled=attr_from_element(connection, TAG_ENABLED), 112 | name=value_from_xml(connection, TAG_NAME), 113 | ssl=value_from_xml(connection, TAG_SSL), 114 | sni=value_from_xml(connection, TAG_SNI), 115 | port=value_from_xml(connection, TAG_PORT), 116 | timeout=value_from_xml(connection, TAG_TIMEOUT), 117 | isy_user_num=value_from_xml(connection, TAG_ISY_USER_NUM), 118 | ip=value_from_xml(connection, TAG_IP), 119 | base_url=value_from_xml(connection, TAG_BASE_URL), 120 | ns_user=value_from_xml(connection, TAG_NS_USER), 121 | ) 122 | ) 123 | _LOGGER.info("ISY updated node server connection info") 124 | 125 | async def get_node_server_profiles(self) -> None: 126 | """Retrieve the node server definition files from the ISY.""" 127 | node_server_file_list = await self.isy.conn.request( 128 | self.isy.conn.compile_url([URL_PROFILE_NS, "0", "files"]), ok404=False 129 | ) 130 | 131 | if node_server_file_list is None: 132 | return 133 | 134 | _LOGGER.debug("Parsing node server file list") 135 | 136 | try: 137 | file_list_xml = minidom.parseString(node_server_file_list) 138 | except XML_ERRORS as exc: 139 | _LOGGER.error("%s while parsing Node Server files", XML_PARSE_ERROR) 140 | raise ISYResponseParseError(XML_PARSE_ERROR) from exc 141 | 142 | file_list: list[str] = [] 143 | 144 | profiles = file_list_xml.getElementsByTagName(ATTR_PROFILE) 145 | for profile in profiles: 146 | slot = attr_from_element(profile, ATTR_ID) 147 | directories = profile.getElementsByTagName(TAG_FILES) 148 | for directory in directories: 149 | dir_name = attr_from_element(directory, ATTR_DIR) 150 | files = directory.getElementsByTagName(TAG_FILE) 151 | for file in files: 152 | file_name = attr_from_element(file, TAG_NAME) 153 | file_list.append(f"{slot}/download/{dir_name}/{file_name}") 154 | 155 | file_tasks = [ 156 | self.isy.conn.request(self.isy.conn.compile_url([URL_PROFILE_NS, file])) for file in file_list 157 | ] 158 | file_contents: list[str] = await asyncio.gather(*file_tasks) 159 | self._profiles: dict[str, str] = dict(zip(file_list, file_contents)) 160 | 161 | _LOGGER.info("ISY downloaded node server files") 162 | 163 | async def parse_node_server_defs(self, slot: str) -> None: 164 | """Retrieve and parse the node server definitions.""" 165 | _LOGGER.info("Parsing node server slot %s", slot) 166 | node_server_profile = {key: value for (key, value) in self._profiles.items() if key.startswith(slot)} 167 | 168 | node_defs_impl = getDOMImplementation() 169 | editors_impl = getDOMImplementation() 170 | node_defs_xml = node_defs_impl.createDocument(None, TAG_ROOT, None) 171 | editors_xml = editors_impl.createDocument(None, TAG_ROOT, None) 172 | nls_lookup: dict = {} 173 | 174 | for file_raw, contents in node_server_profile.items(): 175 | contents_xml = "" 176 | file = file_raw.lower() 177 | if file.endswith(".xml"): 178 | try: 179 | contents_xml = minidom.parseString(contents).firstChild 180 | except XML_ERRORS: 181 | _LOGGER.error( 182 | "%s while parsing Node Server %s file %s", 183 | XML_PARSE_ERROR, 184 | slot, 185 | file, 186 | ) 187 | continue 188 | if "nodedef" in file: 189 | node_defs_xml.firstChild.appendChild(contents_xml) 190 | if "editors" in file: 191 | editors_xml.firstChild.appendChild(contents_xml) 192 | if "nls" in file and "en_us" in file: 193 | nls_list = [line for line in contents.split("\n") if not line.startswith("#") and line != ""] 194 | if nls_list: 195 | nls_lookup = dict(re.split(r"\s?=\s?", line) for line in nls_list) 196 | self._node_server_nls.append( 197 | NodeServerNLS( 198 | slot=slot, 199 | nls=nls_lookup, 200 | ) 201 | ) 202 | 203 | # Process Node Def Files 204 | node_defs = node_defs_xml.getElementsByTagName(TAG_NODE_DEF) 205 | for node_def in node_defs: 206 | node_def_id = attr_from_element(node_def, ATTR_ID) 207 | nls_prefix = attr_from_element(node_def, ATTR_NLS) 208 | sts = node_def.getElementsByTagName(TAG_ST) 209 | statuses = {} 210 | for st in sts: 211 | status_id = attr_from_element(st, ATTR_ID) 212 | editor = attr_from_element(st, ATTR_EDITOR) 213 | statuses.update({status_id: editor}) 214 | 215 | cmds_sends = node_def.getElementsByTagName(TAG_SENDS)[0] 216 | cmds_accepts = node_def.getElementsByTagName(TAG_ACCEPTS)[0] 217 | cmds_sends_cmd = cmds_sends.getElementsByTagName(TAG_CMD) 218 | cmds_accepts_cmd = cmds_accepts.getElementsByTagName(TAG_CMD) 219 | sends_commands = [attr_from_element(cmd, ATTR_ID) for cmd in cmds_sends_cmd] 220 | accepts_commands = [attr_from_element(cmd, ATTR_ID) for cmd in cmds_accepts_cmd] 221 | 222 | status_names = {} 223 | name = node_def_id 224 | if nls_lookup: 225 | if (name_key := f"ND-{node_def_id}-NAME") in nls_lookup: 226 | name = nls_lookup[name_key] 227 | for st in statuses: 228 | if (key := f"ST-{nls_prefix}-{st}-NAME") in nls_lookup: 229 | status_names.update({st: nls_lookup[key]}) 230 | 231 | self._node_server_node_definitions.append( 232 | NodeServerNodeDefinition( 233 | node_def_id=node_def_id, 234 | name=name, 235 | nls_prefix=nls_prefix, 236 | slot=slot, 237 | statuses=statuses, 238 | status_names=status_names, 239 | sends_commands=sends_commands, 240 | accepts_commands=accepts_commands, 241 | ) 242 | ) 243 | # Process Editor Files 244 | editors = editors_xml.getElementsByTagName(ATTR_EDITOR) 245 | for editor in editors: 246 | editor_id = attr_from_element(editor, ATTR_ID) 247 | editor_range = editor.getElementsByTagName(TAG_RANGE)[0] 248 | uom = attr_from_element(editor_range, ATTR_UNIT_OF_MEASURE) 249 | subset = attr_from_element(editor_range, ATTR_SUBSET) 250 | nls = attr_from_element(editor_range, ATTR_NLS) 251 | 252 | values = None 253 | if nls_lookup and uom == "25": 254 | values = { 255 | key.partition("-")[2]: value for (key, value) in nls_lookup.items() if key.startswith(nls) 256 | } 257 | 258 | self._node_server_node_editors.append( 259 | NodeServerNodeEditor( 260 | editor_id=editor_id, 261 | unit_of_measurement=uom, 262 | subset=subset, 263 | nls=nls, 264 | slot=slot, 265 | values=values, 266 | ) 267 | ) 268 | 269 | _LOGGER.debug("ISY parsed node server profiles") 270 | 271 | 272 | @dataclass 273 | class NodeServerNodeDefinition: 274 | """Node Server Node Definition parsed from the ISY/IoX.""" 275 | 276 | node_def_id: str 277 | name: str 278 | nls_prefix: str 279 | slot: str 280 | statuses: dict[str, str] 281 | status_names: dict[str, str] 282 | sends_commands: list[str] 283 | accepts_commands: list[str] 284 | 285 | 286 | @dataclass 287 | class NodeServerNodeEditor: 288 | """Node Server Editor definition.""" 289 | 290 | editor_id: str 291 | unit_of_measurement: str 292 | subset: str 293 | nls: str 294 | slot: str 295 | values: dict[str, str] 296 | 297 | 298 | @dataclass 299 | class NodeServerNLS: 300 | """Node Server Natural Language Selection definition.""" 301 | 302 | slot: str 303 | nls: dict[str, str] 304 | 305 | 306 | @dataclass 307 | class NodeServerConnection: 308 | """Node Server Connection details.""" 309 | 310 | slot: str 311 | enabled: str 312 | name: str 313 | ssl: str 314 | sni: str 315 | port: str 316 | timeout: str 317 | isy_user_num: str 318 | ip: str 319 | base_url: str 320 | ns_user: str 321 | 322 | def configuration_url(self) -> str: 323 | """Compile a configuration url from the connection data.""" 324 | protocol: str = "https://" if self.ssl else "http://" 325 | return f"{protocol}{self.ip}:{self.port}" 326 | -------------------------------------------------------------------------------- /pyisy/nodes/group.py: -------------------------------------------------------------------------------- 1 | """Representation of groups (scenes) from an ISY.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from ..constants import ( 8 | FAMILY_GENERIC, 9 | INSTEON_STATELESS_NODEDEFID, 10 | ISY_VALUE_UNKNOWN, 11 | PROTO_GROUP, 12 | ) 13 | from ..helpers import now 14 | from .node import Node 15 | from .nodebase import NodeBase 16 | 17 | if TYPE_CHECKING: 18 | from . import Nodes 19 | 20 | 21 | class Group(NodeBase): 22 | """ 23 | Interact with ISY groups (scenes). 24 | 25 | | nodes: The node manager object. 26 | | address: The node ID. 27 | | name: The node name. 28 | | members: List of the members in this group. 29 | | controllers: List of the controllers in this group. 30 | | spoken: The string of the Notes Spoken field. 31 | 32 | :ivar has_children: Boolean value indicating that group has no children. 33 | :ivar members: List of the members of this group. 34 | :ivar controllers: List of the controllers of this group. 35 | :ivar name: The name of this group. 36 | :ivar status: Watched property indicating the status of the group. 37 | :ivar group_all_on: Watched property indicating if all devices in group are on. 38 | """ 39 | 40 | def __init__( 41 | self, 42 | nodes: Nodes, 43 | address: str, 44 | name: str, 45 | members: list[str] | None = None, 46 | controllers: list[str] | None = None, 47 | family_id: str = FAMILY_GENERIC, 48 | pnode: str | None = None, 49 | flag: int = 0, 50 | ) -> None: 51 | """Initialize a Group class.""" 52 | self._all_on: bool = False 53 | self._controllers: list[str] = controllers or [] 54 | self._members: list[str] = members or [] 55 | super().__init__(nodes, address, name, 0, family_id=family_id, pnode=pnode, flag=flag) 56 | 57 | # listen for changes in children 58 | self._members_handlers = [ 59 | self._nodes[m].status_events.subscribe(self.update_callback) for m in self.members 60 | ] 61 | 62 | # get and update the status 63 | self._update() 64 | 65 | def __del__(self) -> None: 66 | """Cleanup event handlers before deleting.""" 67 | for handler in self._members_handlers: 68 | handler.unsubscribe() 69 | 70 | @property 71 | def controllers(self) -> list[str]: 72 | """Get the controller nodes of the scene/group.""" 73 | return self._controllers 74 | 75 | @property 76 | def group_all_on(self) -> bool: 77 | """Return the current node state.""" 78 | return self._all_on 79 | 80 | @group_all_on.setter 81 | def group_all_on(self, value: bool) -> bool: 82 | """Set the current node state and notify listeners.""" 83 | if self._all_on != value: 84 | self._all_on = value 85 | self._last_changed = now() 86 | # Re-publish the current status. Let users pick up the all on change. 87 | self.status_events.notify(self._status) 88 | return self._all_on 89 | 90 | @property 91 | def members(self) -> list[str]: 92 | """Get the members of the scene/group.""" 93 | return self._members 94 | 95 | @property 96 | def protocol(self) -> str: 97 | """Return the protocol for this entity.""" 98 | return PROTO_GROUP 99 | 100 | async def update(self, event=None, wait_time: float = 0.0, xmldoc=None): 101 | """Update the group with values from the controller.""" 102 | return self._update(event, wait_time, xmldoc) 103 | 104 | def _update(self, event=None, wait_time: float = 0.0, xmldoc=None): 105 | """Update the group with values from the controller.""" 106 | self._last_update = now() 107 | address_to_node: dict[str, Node] = {address: self._nodes[address] for address in self.members} 108 | valid_nodes = [ 109 | address 110 | for address, node_obj in address_to_node.items() 111 | if ( 112 | node_obj.status is not None 113 | and node_obj.status != ISY_VALUE_UNKNOWN 114 | and node_obj.node_def_id not in INSTEON_STATELESS_NODEDEFID 115 | ) 116 | ] 117 | on_nodes = [node for node in valid_nodes if int(address_to_node[node].status) > 0] 118 | if on_nodes: 119 | self.group_all_on = len(on_nodes) == len(valid_nodes) 120 | self.status = 255 121 | return 122 | self.status = 0 123 | self.group_all_on = False 124 | 125 | def update_callback(self, event=None): 126 | """Handle synchronous callbacks for subscriber events.""" 127 | self._update(event) 128 | -------------------------------------------------------------------------------- /pyisy/nodes/nodebase.py: -------------------------------------------------------------------------------- 1 | """Base object for nodes and groups.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING 7 | from xml.dom import minidom 8 | 9 | from ..constants import ( 10 | ATTR_LAST_CHANGED, 11 | ATTR_LAST_UPDATE, 12 | ATTR_STATUS, 13 | CMD_BEEP, 14 | CMD_BRIGHTEN, 15 | CMD_DIM, 16 | CMD_DISABLE, 17 | CMD_ENABLE, 18 | CMD_FADE_DOWN, 19 | CMD_FADE_STOP, 20 | CMD_FADE_UP, 21 | CMD_OFF, 22 | CMD_OFF_FAST, 23 | CMD_ON, 24 | CMD_ON_FAST, 25 | COMMAND_FRIENDLY_NAME, 26 | METHOD_COMMAND, 27 | NODE_FAMILY_ID, 28 | TAG_ADDRESS, 29 | TAG_DESCRIPTION, 30 | TAG_IS_LOAD, 31 | TAG_LOCATION, 32 | TAG_NAME, 33 | TAG_SPOKEN, 34 | URL_CHANGE, 35 | URL_NODES, 36 | URL_NOTES, 37 | XML_TRUE, 38 | ) 39 | from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError 40 | from ..helpers import EventEmitter, NodeProperty, now, value_from_xml 41 | from ..logging import _LOGGER 42 | 43 | if TYPE_CHECKING: 44 | from . import Nodes 45 | 46 | 47 | class NodeBase: 48 | """Base Object for Nodes and Groups/Scenes.""" 49 | 50 | has_children = False 51 | 52 | def __init__( 53 | self, 54 | nodes: Nodes, 55 | address: str, 56 | name: str, 57 | status: float, 58 | family_id: str | None = None, 59 | aux_properties: dict[str, NodeProperty] | None = None, 60 | pnode: str | None = None, 61 | flag: int = 0, 62 | ) -> None: 63 | """Initialize a Node Base class.""" 64 | self._aux_properties = aux_properties if aux_properties is not None else {} 65 | self._family = NODE_FAMILY_ID.get(family_id) 66 | self._id: str = address 67 | self._name = name 68 | self._nodes = nodes 69 | self._notes: str | None = None 70 | self._primary_node = pnode 71 | self._flag = flag 72 | self._status = status 73 | self._last_update = now() 74 | self._last_changed = self._last_update 75 | self.isy = nodes.isy 76 | self.status_events = EventEmitter() 77 | 78 | def __str__(self): 79 | """Return a string representation of the node.""" 80 | return f"{type(self).__name__}({self._id})" 81 | 82 | @property 83 | def aux_properties(self) -> dict[str, NodeProperty]: 84 | """Return the aux properties that were in the Node Definition.""" 85 | return self._aux_properties 86 | 87 | @property 88 | def address(self) -> str: 89 | """Return the Node ID.""" 90 | return self._id 91 | 92 | @property 93 | def description(self) -> str | None: 94 | """Return the description of the node from it's notes.""" 95 | if self._notes is None: 96 | _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") 97 | return self._notes[TAG_DESCRIPTION] 98 | 99 | @property 100 | def family(self) -> str | None: 101 | """Return the ISY Family category.""" 102 | return self._family 103 | 104 | @property 105 | def flag(self) -> int: 106 | """Return the flag of the current node as a property.""" 107 | return self._flag 108 | 109 | @property 110 | def folder(self) -> str | None: 111 | """Return the folder of the current node as a property.""" 112 | return self._nodes.get_folder(self.address) 113 | 114 | @property 115 | def is_load(self) -> bool | None: 116 | """Return the isLoad property of the node from it's notes.""" 117 | if self._notes is None: 118 | _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") 119 | return self._notes[TAG_IS_LOAD] 120 | 121 | @property 122 | def last_changed(self) -> datetime: 123 | """Return the UTC Time of the last status change for this node.""" 124 | return self._last_changed 125 | 126 | @property 127 | def last_update(self) -> datetime: 128 | """Return the UTC Time of the last update for this node.""" 129 | return self._last_update 130 | 131 | @property 132 | def location(self) -> str | None: 133 | """Return the location of the node from it's notes.""" 134 | if self._notes is None: 135 | _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") 136 | return self._notes[TAG_LOCATION] 137 | 138 | @property 139 | def name(self) -> str: 140 | """Return the name of the Node.""" 141 | return self._name 142 | 143 | @property 144 | def primary_node(self) -> str | None: 145 | """Return just the parent/primary node address. 146 | 147 | This is similar to Node.parent_node but does not return the whole Node 148 | class, and will return itself if it is the primary node/group. 149 | 150 | """ 151 | return self._primary_node 152 | 153 | @property 154 | def spoken(self) -> str | None: 155 | """Return the text of the Spoken property inside the group notes.""" 156 | if self._notes is None: 157 | _LOGGER.debug("No notes retrieved for node. Call get_notes() before accessing.") 158 | return self._notes[TAG_SPOKEN] 159 | 160 | @property 161 | def status(self) -> float: 162 | """Return the current node state.""" 163 | return self._status 164 | 165 | @status.setter 166 | def status(self, value: float) -> float: 167 | """Set the current node state and notify listeners.""" 168 | if self._status != value: 169 | self._status = value 170 | self._last_changed = now() 171 | self.status_events.notify(self.status_feedback) 172 | return self._status 173 | 174 | @property 175 | def status_feedback(self) -> dict[str, str | datetime]: 176 | """Return information for a status change event.""" 177 | return { 178 | TAG_ADDRESS: self.address, 179 | ATTR_STATUS: self._status, 180 | ATTR_LAST_CHANGED: self._last_changed, 181 | ATTR_LAST_UPDATE: self._last_update, 182 | } 183 | 184 | async def get_notes(self) -> dict[str, str | None]: 185 | """Retrieve and parse the notes for a given node. 186 | 187 | Notes are not retrieved unless explicitly requested by 188 | a call to this function. 189 | """ 190 | notes_xml = await self.isy.conn.request( 191 | self.isy.conn.compile_url([URL_NODES, self._id, URL_NOTES]), ok404=True 192 | ) 193 | spoken = None 194 | is_load = None 195 | description = None 196 | location = None 197 | if notes_xml is not None and notes_xml != "": 198 | try: 199 | notes_dom = minidom.parseString(notes_xml) 200 | except XML_ERRORS as exc: 201 | _LOGGER.error("%s: Node Notes %s", XML_PARSE_ERROR, notes_xml) 202 | raise ISYResponseParseError from exc 203 | 204 | spoken = value_from_xml(notes_dom, TAG_SPOKEN) 205 | location = value_from_xml(notes_dom, TAG_LOCATION) 206 | description = value_from_xml(notes_dom, TAG_DESCRIPTION) 207 | is_load = value_from_xml(notes_dom, TAG_IS_LOAD) 208 | return { 209 | TAG_SPOKEN: spoken, 210 | TAG_IS_LOAD: is_load == XML_TRUE, 211 | TAG_DESCRIPTION: description, 212 | TAG_LOCATION: location, 213 | } 214 | 215 | async def update(self, event=None, wait_time=0, xmldoc=None): 216 | """Update the group with values from the controller.""" 217 | self.update_last_update() 218 | 219 | def update_property(self, prop: NodeProperty) -> None: 220 | """Update an aux property for the node when received.""" 221 | if not isinstance(prop, NodeProperty): 222 | _LOGGER.error("Could not update property value. Invalid type provided.") 223 | return 224 | self.update_last_update() 225 | 226 | aux_prop = self.aux_properties.get(prop.control) 227 | if aux_prop: 228 | if prop.uom == "" and aux_prop.uom != "": 229 | # Guard against overwriting known UOM with blank UOM (ISYv4). 230 | prop.uom = aux_prop.uom 231 | if aux_prop == prop: 232 | return 233 | self.aux_properties[prop.control] = prop 234 | self.update_last_changed() 235 | self.status_events.notify(self.status_feedback) 236 | 237 | def update_last_changed(self, timestamp: datetime | None = None) -> None: 238 | """Set the UTC Time of the last status change for this node.""" 239 | if timestamp is None: 240 | timestamp = now() 241 | self._last_changed = timestamp 242 | 243 | def update_last_update(self, timestamp: datetime | None = None) -> None: 244 | """Set the UTC Time of the last update for this node.""" 245 | if timestamp is None: 246 | timestamp = now() 247 | self._last_update = timestamp 248 | 249 | async def send_cmd(self, cmd: str, val=None, uom=None, query=None) -> bool: 250 | """Send a command to the device.""" 251 | value = str(val) if val is not None else None 252 | _uom = str(uom) if uom is not None else None 253 | req = [URL_NODES, str(self._id), METHOD_COMMAND, cmd] 254 | if value: 255 | req.append(value) 256 | if _uom: 257 | req.append(_uom) 258 | req_url = self.isy.conn.compile_url(req, query) 259 | if not await self.isy.conn.request(req_url): 260 | _LOGGER.warning( 261 | "ISY could not send %s command to %s.", 262 | COMMAND_FRIENDLY_NAME.get(cmd), 263 | self._id, 264 | ) 265 | return False 266 | _LOGGER.debug("ISY command %s sent to %s.", COMMAND_FRIENDLY_NAME.get(cmd), self._id) 267 | return True 268 | 269 | async def beep(self) -> bool: 270 | """Identify physical device by sound (if supported).""" 271 | return await self.send_cmd(CMD_BEEP) 272 | 273 | async def brighten(self) -> bool: 274 | """Increase brightness of a device by ~3%.""" 275 | return await self.send_cmd(CMD_BRIGHTEN) 276 | 277 | async def dim(self) -> bool: 278 | """Decrease brightness of a device by ~3%.""" 279 | return await self.send_cmd(CMD_DIM) 280 | 281 | async def disable(self) -> bool: 282 | """Send command to the node to disable it.""" 283 | if not await self.isy.conn.request( 284 | self.isy.conn.compile_url([URL_NODES, str(self._id), CMD_DISABLE]) 285 | ): 286 | _LOGGER.warning("ISY could not %s %s.", CMD_DISABLE, self._id) 287 | return False 288 | return True 289 | 290 | async def enable(self) -> bool: 291 | """Send command to the node to enable it.""" 292 | if not await self.isy.conn.request(self.isy.conn.compile_url([URL_NODES, str(self._id), CMD_ENABLE])): 293 | _LOGGER.warning("ISY could not %s %s.", CMD_ENABLE, self._id) 294 | return False 295 | return True 296 | 297 | async def fade_down(self) -> bool: 298 | """Begin fading down (dim) a device.""" 299 | return await self.send_cmd(CMD_FADE_DOWN) 300 | 301 | async def fade_stop(self) -> bool: 302 | """Stop fading a device.""" 303 | return await self.send_cmd(CMD_FADE_STOP) 304 | 305 | async def fade_up(self) -> bool: 306 | """Begin fading up (dim) a device.""" 307 | return await self.send_cmd(CMD_FADE_UP) 308 | 309 | async def fast_off(self) -> bool: 310 | """Start manually brightening a device.""" 311 | return await self.send_cmd(CMD_OFF_FAST) 312 | 313 | async def fast_on(self) -> bool: 314 | """Start manually brightening a device.""" 315 | return await self.send_cmd(CMD_ON_FAST) 316 | 317 | async def query(self) -> bool: 318 | """Request the ISY query this node.""" 319 | return await self.isy.query(address=self.address) 320 | 321 | async def turn_off(self) -> bool: 322 | """Turn off the nodes/group in the ISY.""" 323 | return await self.send_cmd(CMD_OFF) 324 | 325 | async def turn_on(self, val: int | None = None) -> bool: 326 | """ 327 | Turn the node on. 328 | 329 | | [optional] val: The value brightness value (0-255) for the node. 330 | """ 331 | if val is None or type(self).__name__ == "Group": 332 | cmd = CMD_ON 333 | elif int(val) > 0: 334 | cmd = CMD_ON 335 | val = str(val) if int(val) <= 255 else None 336 | else: 337 | cmd = CMD_OFF 338 | val = None 339 | return await self.send_cmd(cmd, val) 340 | 341 | async def rename(self, new_name: str) -> bool: 342 | """ 343 | Rename the node or group in the ISY. 344 | 345 | Note: Feature was added in ISY v5.2.0, this will fail on earlier versions. 346 | """ 347 | 348 | # /rest/nodes//change?name= 349 | req_url = self.isy.conn.compile_url( 350 | [URL_NODES, self._id, URL_CHANGE], 351 | query={TAG_NAME: new_name}, 352 | ) 353 | if not await self.isy.conn.request(req_url): 354 | _LOGGER.warning( 355 | "ISY could not update name for %s.", 356 | self._id, 357 | ) 358 | return False 359 | _LOGGER.debug("ISY renamed %s to %s.", self._id, new_name) 360 | 361 | self._name = new_name 362 | return True 363 | -------------------------------------------------------------------------------- /pyisy/programs/__init__.py: -------------------------------------------------------------------------------- 1 | """Init for management of ISY Programs.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from operator import itemgetter 7 | from typing import TYPE_CHECKING 8 | from xml.dom import minidom 9 | 10 | from dateutil import parser 11 | 12 | from ..constants import ( 13 | ATTR_ID, 14 | ATTR_PARENT, 15 | ATTR_STATUS, 16 | EMPTY_TIME, 17 | TAG_ENABLED, 18 | TAG_FOLDER, 19 | TAG_NAME, 20 | TAG_PRGM_FINISH, 21 | TAG_PRGM_RUN, 22 | TAG_PRGM_RUNNING, 23 | TAG_PRGM_STATUS, 24 | TAG_PROGRAM, 25 | UPDATE_INTERVAL, 26 | XML_OFF, 27 | XML_ON, 28 | XML_TRUE, 29 | ) 30 | from ..exceptions import XML_ERRORS, XML_PARSE_ERROR 31 | from ..helpers import attr_from_element, now, value_from_xml 32 | from ..logging import _LOGGER 33 | from ..nodes import NodeIterator as ProgramIterator 34 | from .folder import Folder 35 | from .program import Program 36 | 37 | if TYPE_CHECKING: 38 | from ..isy import ISY 39 | 40 | 41 | class Programs: 42 | """ 43 | This class handles the ISY programs. 44 | 45 | This class can be used as a dictionary 46 | to navigate through the controller's structure to objects of type 47 | :class:`pyisy.programs.Program` and :class:`pyisy.programs.Folder` 48 | (when requested) that represent objects on the controller. 49 | 50 | | isy: The ISY device class 51 | | root: Program/Folder ID representing the current level of navigation. 52 | | addresses: List of program and folder IDs. 53 | | pnames: List of the program and folder names. 54 | | pparents: List of the program and folder parent IDs. 55 | | pobjs: List of program and folder objects. 56 | | ptypes: List of the program and folder types. 57 | | xml: XML string from the controller detailing the programs and folders. 58 | 59 | :ivar all_lower_programs: A list of all programs below the current 60 | navigation level. Does not return folders. 61 | :ivar children: A list of the children immediately below the current 62 | navigation level. 63 | :ivar leaf: The child object representing the current item in navigation. 64 | This is useful for getting a folder to act as a program. 65 | :ivar name: The name of the program at the current level of navigation. 66 | """ 67 | 68 | def __init__( 69 | self, 70 | isy: ISY, 71 | root: str | None = None, 72 | addresses: list[str] | None = None, 73 | pnames: list[str] | None = None, 74 | pparents: list[str] | None = None, 75 | pobjs: list[Program | Folder] | None = None, 76 | ptypes: list[str] | None = None, 77 | xml: str | None = None, 78 | _address_index: dict[str, int] | None = None, 79 | ) -> None: 80 | """Initialize the Programs ISY programs manager class.""" 81 | self.isy = isy 82 | self.root = root 83 | 84 | self.addresses: list[str] = [] 85 | self._address_index: dict[str, int] = {} 86 | self.pnames: list[str] = [] 87 | self.pparents: list[str] = [] 88 | self.pobjs: list[Program | Folder] = [] 89 | self.ptypes: list[str] = [] 90 | 91 | if xml is not None: 92 | self.parse(xml) 93 | return 94 | 95 | if addresses is not None: 96 | self.addresses = addresses 97 | self._address_index = _address_index or {address: i for i, address in enumerate(addresses)} 98 | if pnames is not None: 99 | self.pnames = pnames 100 | if pparents is not None: 101 | self.pparents = pparents 102 | if pobjs is not None: 103 | self.pobjs = pobjs 104 | if ptypes is not None: 105 | self.ptypes = ptypes 106 | 107 | def __str__(self) -> str: 108 | """Return a string representation of the program manager.""" 109 | if self.root is None: 110 | return "Folder " 111 | ind = self._address_index[self.root] 112 | if self.ptypes[ind] == TAG_FOLDER: 113 | return f"Folder ({self.root})" 114 | if self.ptypes[ind] == TAG_PROGRAM: 115 | return f"Program ({self.root})" 116 | return "" 117 | 118 | def __repr__(self) -> str: 119 | """Return a string showing the hierarchy of the program manager.""" 120 | # get and sort children 121 | folders: list[tuple[str, str, str]] = [] 122 | programs: list[tuple[str, str, str]] = [] 123 | for child in self.children: 124 | if child[0] == TAG_FOLDER: 125 | folders.append(child) 126 | elif child[0] == TAG_PROGRAM: 127 | programs.append(child) 128 | 129 | # initialize data 130 | folders.sort(key=itemgetter(1)) 131 | programs.sort(key=itemgetter(1)) 132 | out = str(self) + "\n" 133 | 134 | # format folders 135 | for fold in folders: 136 | fold_obj = self[fold[2]] 137 | out += f" + {fold[1]}: Folder({fold[2]})\n" 138 | for line in repr(fold_obj).split("\n")[1:]: 139 | out += f" | {line}\n" 140 | out += " -\n" 141 | 142 | # format programs 143 | for prog in programs: 144 | out += f" {prog[1]}: {self[prog[2]]}\n" 145 | 146 | return out 147 | 148 | def __iter__(self) -> ProgramIterator: 149 | """ 150 | Return an iterator that iterates through all the programs. 151 | 152 | Does not iterate folders. Only Programs that are beneath the current 153 | folder in navigation. 154 | """ 155 | iter_data = self.all_lower_programs 156 | return ProgramIterator(self, iter_data, delta=1) 157 | 158 | def __reversed__(self) -> ProgramIterator: 159 | """Return an iterator that goes in reverse order.""" 160 | iter_data = self.all_lower_programs 161 | return ProgramIterator(self, iter_data, delta=-1) 162 | 163 | def update_received(self, xmldoc: minidom.Document) -> None: 164 | """Update programs from EventStream message.""" 165 | # pylint: disable=attribute-defined-outside-init 166 | xml = xmldoc.toxml() 167 | address = value_from_xml(xmldoc, ATTR_ID).zfill(4) 168 | try: 169 | pobj = self.get_by_id(address).leaf 170 | except (KeyError, ValueError): 171 | _LOGGER.warning("ISY received program update for new program; reload the module to update") 172 | return # this is a new program that hasn't been registered 173 | 174 | if not isinstance(pobj, Program): 175 | return 176 | 177 | new_status = False 178 | 179 | if f"<{TAG_PRGM_STATUS}>" in xml: 180 | status = value_from_xml(xmldoc, TAG_PRGM_STATUS) 181 | if status == "21": 182 | pobj.ran_then += 1 183 | new_status = True 184 | elif status == "31": 185 | pobj.ran_else += 1 186 | 187 | if f"<{TAG_PRGM_RUN}>" in xml: 188 | pobj.last_run = parser.parse(value_from_xml(xmldoc, TAG_PRGM_RUN)) 189 | 190 | if f"<{TAG_PRGM_FINISH}>" in xml: 191 | pobj.last_finished = parser.parse(value_from_xml(xmldoc, TAG_PRGM_FINISH)) 192 | 193 | if XML_ON in xml or XML_OFF in xml: 194 | pobj.enabled = XML_ON in xml 195 | 196 | # Update Status last and make sure the change event fires, but only once. 197 | if pobj.status != new_status: 198 | pobj.status = new_status 199 | else: 200 | # Status didn't change, but something did, so fire the event. 201 | pobj.status_events.notify(new_status) 202 | 203 | _LOGGER.debug("ISY Updated Program: %s", address) 204 | 205 | def parse(self, xml: str) -> None: 206 | """ 207 | Parse the XML from the controller and updates the state of the manager. 208 | 209 | xml: XML string from the controller. 210 | """ 211 | try: 212 | xmldoc = minidom.parseString(xml) 213 | except XML_ERRORS: 214 | _LOGGER.error("%s: Programs, programs not loaded", XML_PARSE_ERROR) 215 | return 216 | 217 | plastup = now() 218 | 219 | # get nodes 220 | features = xmldoc.getElementsByTagName(TAG_PROGRAM) 221 | for feature in features: 222 | # id, name, and status 223 | address = attr_from_element(feature, ATTR_ID) 224 | pname = value_from_xml(feature, TAG_NAME) 225 | 226 | _LOGGER.debug("Parsing Program/Folder: %s [%s]", pname, address) 227 | 228 | pparent = attr_from_element(feature, ATTR_PARENT) 229 | pstatus = attr_from_element(feature, ATTR_STATUS) == XML_TRUE 230 | 231 | if attr_from_element(feature, TAG_FOLDER) == XML_TRUE: 232 | # folder specific parsing 233 | ptype = TAG_FOLDER 234 | data = {"pstatus": pstatus, "plastup": plastup} 235 | 236 | else: 237 | # program specific parsing 238 | ptype = TAG_PROGRAM 239 | 240 | # last run time 241 | plastrun = value_from_xml(feature, "lastRunTime", EMPTY_TIME) 242 | if plastrun != EMPTY_TIME: 243 | plastrun = parser.parse(plastrun) 244 | 245 | # last finish time 246 | plastfin = value_from_xml(feature, "lastFinishTime", EMPTY_TIME) 247 | if plastfin != EMPTY_TIME: 248 | plastfin = parser.parse(plastfin) 249 | 250 | # enabled, run at startup, running 251 | penabled = bool(attr_from_element(feature, TAG_ENABLED) == XML_TRUE) 252 | pstartrun = bool(attr_from_element(feature, "runAtStartup") == XML_TRUE) 253 | prunning = bool(attr_from_element(feature, TAG_PRGM_RUNNING) != "idle") 254 | 255 | # create data dictionary 256 | data = { 257 | "pstatus": pstatus, 258 | "plastrun": plastrun, 259 | "plastfin": plastfin, 260 | "penabled": penabled, 261 | "pstartrun": pstartrun, 262 | "prunning": prunning, 263 | "plastup": plastup, 264 | } 265 | 266 | # add or update object if it already exists 267 | if address not in self.addresses: 268 | if ptype == TAG_FOLDER: 269 | pobj = Folder(self, address, pname, **data) 270 | else: 271 | pobj = Program(self, address, pname, **data) 272 | self.insert(address, pname, pparent, pobj, ptype) 273 | else: 274 | pobj = self.get_by_id(address).leaf 275 | pobj._update(data=data) 276 | 277 | _LOGGER.info("ISY Loaded/Updated Programs") 278 | 279 | async def update(self, wait_time=UPDATE_INTERVAL, address=None): 280 | """ 281 | Update the status of the programs and folders. 282 | 283 | | wait_time: How long to wait before updating. 284 | | address: The program ID to update. 285 | """ 286 | await asyncio.sleep(wait_time) 287 | 288 | xml = await self.isy.conn.get_programs(address) 289 | 290 | if xml is not None: 291 | self.parse(xml) 292 | else: 293 | _LOGGER.warning("ISY Failed to update programs.") 294 | 295 | def insert(self, address: str, pname: str, pparent: str, pobj: Program | Programs, ptype: str) -> None: 296 | """ 297 | Insert a new program or folder into the manager. 298 | 299 | | address: The ID of the program or folder. 300 | | pname: The name of the program or folder. 301 | | pparent: The parent of the program or folder. 302 | | pobj: The object representing the program or folder. 303 | | ptype: The type of the item being added (program/folder). 304 | """ 305 | self.addresses.append(address) 306 | self._address_index[address] = len(self.addresses) - 1 307 | self.pnames.append(pname) 308 | self.pparents.append(pparent) 309 | self.ptypes.append(ptype) 310 | self.pobjs.append(pobj) 311 | 312 | def __getitem__(self, val: str) -> Program | Folder | None: 313 | """ 314 | Navigate through the hierarchy using names or IDs. 315 | 316 | | val: Name or ID to navigate to. 317 | """ 318 | if val in self._address_index: 319 | fun = self.get_by_id 320 | else: 321 | try: 322 | self.pnames.index(val) 323 | fun = self.get_by_name 324 | except ValueError: 325 | try: 326 | val = int(val) 327 | fun = self.get_by_index 328 | except (TypeError, ValueError) as err: 329 | raise KeyError("Unrecognized Key: " + str(val)) from err 330 | try: 331 | return fun(val) 332 | except (ValueError, KeyError, IndexError): 333 | return None 334 | 335 | def __setitem__(self, val, value): 336 | """Set the item value.""" 337 | return 338 | 339 | def get_by_name(self, val: str) -> Program | Folder | Programs | None: 340 | """ 341 | Get a child program/folder with the given name. 342 | 343 | | val: The name of the child program/folder to look for. 344 | """ 345 | for i in range(len(self.addresses)): 346 | if (self.root is None or self.pparents[i] == self.root) and self.pnames[i] == val: 347 | return self.get_by_index(i) 348 | return None 349 | 350 | def get_by_id(self, address: str) -> Program | Folder | Programs: 351 | """ 352 | Get a program/folder with the given ID. 353 | 354 | | address: The program/folder ID to look for. 355 | """ 356 | return self.get_by_index(self._address_index[address]) 357 | 358 | def get_by_index(self, i: int) -> Program | Folder | Programs: 359 | """ 360 | Get the program/folder at the given index. 361 | 362 | | i: The program/folder index. 363 | """ 364 | if self.ptypes[i] == TAG_FOLDER: 365 | return Programs( 366 | isy=self.isy, 367 | root=self.addresses[i], 368 | addresses=self.addresses, 369 | pnames=self.pnames, 370 | pparents=self.pparents, 371 | pobjs=self.pobjs, 372 | ptypes=self.ptypes, 373 | _address_index=self._address_index, 374 | ) 375 | return self.pobjs[i] 376 | 377 | @property 378 | def children(self) -> list[tuple[str, str, str]]: 379 | """Return the children of the class.""" 380 | return [ 381 | (self.ptypes[ind], self.pnames[ind], self.addresses[ind]) 382 | for ind in range(len(self.pnames)) 383 | if self.pparents[ind] == self.root 384 | ] 385 | 386 | @property 387 | def leaf(self) -> Program | Folder: 388 | """Return the leaf property.""" 389 | if self.root is not None: 390 | ind = self._address_index[self.root] 391 | if self.pobjs[ind] is not None: 392 | return self.pobjs[ind] 393 | return self 394 | 395 | @property 396 | def name(self) -> str: 397 | """Return the name of the path.""" 398 | if self.root is not None: 399 | return self.pnames[self._address_index[self.root]] 400 | return "" 401 | 402 | @property 403 | def all_lower_programs(self) -> list[tuple[str, str, str]]: 404 | """Return all lower programs in a path.""" 405 | output = [] 406 | myname = self.name + "/" 407 | 408 | for dtype, name, ident in self.children: 409 | if dtype == TAG_PROGRAM: 410 | output.append((dtype, myname + name, ident)) 411 | 412 | else: 413 | output += [ 414 | (dtype2, myname + name2, ident2) 415 | for (dtype2, name2, ident2) in self[ident].all_lower_programs 416 | ] 417 | return output 418 | -------------------------------------------------------------------------------- /pyisy/programs/folder.py: -------------------------------------------------------------------------------- 1 | """ISY Program Folders.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from ..constants import ( 9 | ATTR_LAST_CHANGED, 10 | ATTR_LAST_UPDATE, 11 | ATTR_STATUS, 12 | CMD_DISABLE, 13 | CMD_ENABLE, 14 | CMD_RUN, 15 | CMD_RUN_ELSE, 16 | CMD_RUN_THEN, 17 | CMD_STOP, 18 | PROTO_FOLDER, 19 | TAG_ADDRESS, 20 | TAG_FOLDER, 21 | UPDATE_INTERVAL, 22 | URL_PROGRAMS, 23 | ) 24 | from ..helpers import EventEmitter, now 25 | from ..logging import _LOGGER 26 | 27 | if TYPE_CHECKING: 28 | from . import Programs 29 | 30 | 31 | class Folder: 32 | """ 33 | Object representing a program folder on the ISY device. 34 | 35 | | programs: The folder manager object. 36 | | address: The folder ID. 37 | | pname: The folder name. 38 | | pstatus: The current folder status. 39 | 40 | :ivar dtype: Returns the type of the object (folder). 41 | :ivar status: Watched property representing the current status of the 42 | folder. 43 | """ 44 | 45 | dtype = TAG_FOLDER 46 | 47 | def __init__(self, programs: Programs, address: str, pname: str, pstatus: int, plastup: datetime) -> None: 48 | """Initialize the Folder class.""" 49 | self._id = address 50 | self._last_update = plastup 51 | self._last_changed = now() 52 | self._name = pname 53 | self._programs = programs 54 | self._status = pstatus 55 | self.isy = programs.isy 56 | self.status_events = EventEmitter() 57 | 58 | def __str__(self) -> str: 59 | """Return a string representation of the node.""" 60 | return f"{type(self).__name__}({self._id})" 61 | 62 | @property 63 | def address(self) -> str: 64 | """Return the program or folder ID.""" 65 | return self._id 66 | 67 | @property 68 | def last_changed(self) -> datetime: 69 | """Return the last time the program was changed in this module.""" 70 | return self._last_changed 71 | 72 | @last_changed.setter 73 | def last_changed(self, value: datetime) -> datetime: 74 | """Set the last time the program was changed.""" 75 | if self._last_changed != value: 76 | self._last_changed = value 77 | return self._last_changed 78 | 79 | @property 80 | def last_update(self) -> datetime: 81 | """Return the last time the program was updated.""" 82 | return self._last_update 83 | 84 | @last_update.setter 85 | def last_update(self, value: datetime) -> datetime: 86 | """Set the last time the program was updated.""" 87 | if self._last_update != value: 88 | self._last_update = value 89 | return self._last_update 90 | 91 | @property 92 | def leaf(self) -> Folder: 93 | """Get the leaf property.""" 94 | return self 95 | 96 | @property 97 | def name(self) -> str: 98 | """Return the name of the Node.""" 99 | return self._name 100 | 101 | @property 102 | def protocol(self) -> str: 103 | """Return the protocol for this entity.""" 104 | return PROTO_FOLDER 105 | 106 | @property 107 | def status(self) -> int: 108 | """Return the current node state.""" 109 | return self._status 110 | 111 | @status.setter 112 | def status(self, value: int) -> int: 113 | """Set the current node state and notify listeners.""" 114 | if self._status != value: 115 | self._status = value 116 | self.status_events.notify(self._status) 117 | return self._status 118 | 119 | @property 120 | def status_feedback(self) -> dict[str, Any]: 121 | """Return information for a status change event.""" 122 | return { 123 | TAG_ADDRESS: self.address, 124 | ATTR_STATUS: self._status, 125 | ATTR_LAST_CHANGED: self._last_changed, 126 | ATTR_LAST_UPDATE: self._last_update, 127 | } 128 | 129 | def _update(self, data: dict[str, Any]) -> None: 130 | """Update the folder with values from the controller.""" 131 | self._last_changed = now() 132 | self.status = data["pstatus"] 133 | 134 | async def update(self, wait_time: float = UPDATE_INTERVAL, data: dict[str, Any] | None = None) -> None: 135 | """ 136 | Update the status of the program. 137 | 138 | | data: [optional] The data to update the folder with. 139 | | wait_time: [optional] Seconds to wait before updating. 140 | """ 141 | if data is not None: 142 | self._update(data) 143 | return 144 | await self._programs.update(wait_time=wait_time, address=self._id) 145 | 146 | async def send_cmd(self, command: str) -> bool: 147 | """Run the appropriate clause of the object.""" 148 | req_url = self.isy.conn.compile_url([URL_PROGRAMS, str(self._id), command]) 149 | result = await self.isy.conn.request(req_url) 150 | if not result: 151 | _LOGGER.warning('ISY could not call "%s" on program: %s', command, self._id) 152 | return False 153 | _LOGGER.debug('ISY ran "%s" on program: %s', command, self._id) 154 | if not self.isy.auto_update: 155 | await self.update() 156 | return True 157 | 158 | async def enable(self) -> bool: 159 | """Send command to the program/folder to enable it.""" 160 | return await self.send_cmd(CMD_ENABLE) 161 | 162 | async def disable(self) -> bool: 163 | """Send command to the program/folder to enable it.""" 164 | return await self.send_cmd(CMD_DISABLE) 165 | 166 | async def run(self) -> bool: 167 | """Send a run command to the program/folder.""" 168 | return await self.send_cmd(CMD_RUN) 169 | 170 | async def run_then(self) -> bool: 171 | """Send a runThen command to the program/folder.""" 172 | return await self.send_cmd(CMD_RUN_THEN) 173 | 174 | async def run_else(self) -> bool: 175 | """Send a runElse command to the program/folder.""" 176 | return await self.send_cmd(CMD_RUN_ELSE) 177 | 178 | async def stop(self) -> bool: 179 | """Send a stop command to the program/folder.""" 180 | return await self.send_cmd(CMD_STOP) 181 | -------------------------------------------------------------------------------- /pyisy/programs/program.py: -------------------------------------------------------------------------------- 1 | """Representation of a program from the ISY.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from ..constants import ( 9 | CMD_DISABLE_RUN_AT_STARTUP, 10 | CMD_ENABLE_RUN_AT_STARTUP, 11 | PROTO_PROGRAM, 12 | TAG_PROGRAM, 13 | UPDATE_INTERVAL, 14 | ) 15 | from .folder import Folder 16 | 17 | if TYPE_CHECKING: 18 | from . import Programs 19 | 20 | 21 | class Program(Folder): 22 | """ 23 | Class representing a program on the ISY controller. 24 | 25 | | programs: The program manager object. 26 | | address: The ID of the program. 27 | | pname: The name of the program. 28 | | pstatus: The current status of the program. 29 | | plastup: The last time the program was updated. 30 | | plastrun: The last time the program was run. 31 | | plastfin: The last time the program finished running. 32 | | penabled: Boolean value showing if the program is enabled on the 33 | controller. 34 | | pstartrun: Boolean value showing if the if the program runs on 35 | controller start up. 36 | | prunning: Boolean value showing if the current program is running 37 | on the controller. 38 | """ 39 | 40 | dtype = TAG_PROGRAM 41 | 42 | def __init__( 43 | self, 44 | programs: Programs, 45 | address: str, 46 | pname: str, 47 | pstatus: bool, 48 | plastup: datetime, 49 | plastrun: datetime, 50 | plastfin: datetime, 51 | penabled: bool, 52 | pstartrun: bool, 53 | prunning: bool, 54 | ) -> None: 55 | """Initialize a Program class.""" 56 | super().__init__(programs, address, pname, pstatus, plastup) 57 | self._enabled = penabled 58 | self._last_finished = plastfin 59 | self._last_run = plastrun 60 | self._ran_else: int = 0 61 | self._ran_then: int = 0 62 | self._run_at_startup = pstartrun 63 | self._running = prunning 64 | 65 | @property 66 | def enabled(self) -> bool: 67 | """Return if the program is enabled on the controller.""" 68 | return self._enabled 69 | 70 | @enabled.setter 71 | def enabled(self, value: bool) -> bool: 72 | """Set if the program is enabled on the controller.""" 73 | if self._enabled != value: 74 | self._enabled = value 75 | return self._enabled 76 | 77 | @property 78 | def last_finished(self) -> datetime: 79 | """Return the last time the program finished running.""" 80 | return self._last_finished 81 | 82 | @last_finished.setter 83 | def last_finished(self, value: datetime) -> datetime: 84 | """Set the last time the program finished running.""" 85 | if self._last_finished != value: 86 | self._last_finished = value 87 | return self._last_finished 88 | 89 | @property 90 | def last_run(self) -> datetime: 91 | """Return the last time the program was run.""" 92 | return self._last_run 93 | 94 | @last_run.setter 95 | def last_run(self, value: datetime) -> datetime: 96 | """Set the last time the program was run.""" 97 | if self._last_run != value: 98 | self._last_run = value 99 | return self._last_run 100 | 101 | @property 102 | def protocol(self) -> str: 103 | """Return the protocol for this entity.""" 104 | return PROTO_PROGRAM 105 | 106 | @property 107 | def ran_else(self) -> int: 108 | """Return the Ran Else property for this program.""" 109 | return self._ran_else 110 | 111 | @ran_else.setter 112 | def ran_else(self, value: int) -> int: 113 | """Set the Ran Else property for this program.""" 114 | if self._ran_else != value: 115 | self._ran_else = value 116 | return self._ran_else 117 | 118 | @property 119 | def ran_then(self) -> int: 120 | """Return the Ran Then property for this program.""" 121 | return self._ran_then 122 | 123 | @ran_then.setter 124 | def ran_then(self, value: int) -> int: 125 | """Set the Ran Then property for this program.""" 126 | if self._ran_then != value: 127 | self._ran_then = value 128 | return self._ran_then 129 | 130 | @property 131 | def run_at_startup(self) -> bool: 132 | """Return if the program runs on controller start up.""" 133 | return self._run_at_startup 134 | 135 | @run_at_startup.setter 136 | def run_at_startup(self, value: bool) -> bool: 137 | """Set if the program runs on controller start up.""" 138 | if self._run_at_startup != value: 139 | self._run_at_startup = value 140 | return self._run_at_startup 141 | 142 | @property 143 | def running(self) -> bool: 144 | """Return if the current program is running on the controller.""" 145 | return self._running 146 | 147 | @running.setter 148 | def running(self, value: bool) -> bool: 149 | """Set if the current program is running on the controller.""" 150 | if self._running != value: 151 | self._running = value 152 | return self._running 153 | 154 | def _update(self, data: dict[str, Any]) -> None: 155 | """Update the program with values on the controller.""" 156 | self._enabled = data["penabled"] 157 | self._last_finished = data["plastfin"] 158 | self._last_run = data["plastrun"] 159 | self._last_update = data["plastup"] 160 | self._run_at_startup = data["pstartrun"] 161 | self._running = (data["plastrun"] >= data["plastup"]) or data["prunning"] 162 | # Update Status last and make sure the change event fires, but only once. 163 | if self.status != data["pstatus"]: 164 | self.status = data["pstatus"] 165 | else: 166 | # Status didn't change, but something did, so fire the event. 167 | self.status_events.notify(self.status) 168 | 169 | async def update(self, wait_time=UPDATE_INTERVAL, data: dict[str, Any] | None = None) -> None: 170 | """ 171 | Update the program with values on the controller. 172 | 173 | | wait_time: [optional] Seconds to wait before updating. 174 | | data: [optional] Data to update the object with. 175 | """ 176 | if data is not None: 177 | self._update(data) 178 | return 179 | await self._programs.update(wait_time, address=self._id) 180 | 181 | async def enable_run_at_startup(self) -> bool: 182 | """Send command to the program to enable it to run at startup.""" 183 | return await self.send_cmd(CMD_ENABLE_RUN_AT_STARTUP) 184 | 185 | async def disable_run_at_startup(self) -> bool: 186 | """Send command to the program to enable it to run at startup.""" 187 | return await self.send_cmd(CMD_DISABLE_RUN_AT_STARTUP) 188 | -------------------------------------------------------------------------------- /pyisy/variables/__init__.py: -------------------------------------------------------------------------------- 1 | """ISY Variables.""" 2 | 3 | from __future__ import annotations 4 | 5 | from asyncio import sleep 6 | from typing import TYPE_CHECKING 7 | from xml.dom import minidom 8 | 9 | from dateutil import parser 10 | 11 | from ..constants import ( 12 | ATTR_ID, 13 | ATTR_INIT, 14 | ATTR_PRECISION, 15 | ATTR_TS, 16 | ATTR_VAL, 17 | ATTR_VAR, 18 | TAG_NAME, 19 | TAG_TYPE, 20 | TAG_VARIABLE, 21 | ) 22 | from ..exceptions import XML_ERRORS, XML_PARSE_ERROR, ISYResponseParseError 23 | from ..helpers import attr_from_element, attr_from_xml, now, value_from_xml 24 | from ..logging import _LOGGER 25 | from .variable import Variable 26 | 27 | if TYPE_CHECKING: 28 | from ..isy import ISY 29 | 30 | 31 | EMPTY_VARIABLE_RESPONSES = [ 32 | "/CONF/INTEGER.VAR not found", 33 | "/CONF/STATE.VAR not found", 34 | '', 35 | ] 36 | 37 | 38 | class Variables: 39 | """ 40 | This class handles the ISY variables. 41 | 42 | This class can be used as a dictionary to navigate through the 43 | controller's structure to objects of type 44 | :class:`pyisy.variables.Variable` that represent objects on the 45 | controller. 46 | 47 | | isy: The ISY object. 48 | | root: The ID of the current level of navigation. 49 | | vids: List of variable IDs from the controller. 50 | | vnames: List of variable names form the controller. 51 | | vobjs: List of variable objects. 52 | | xml: XML string from the controller detailing the device's variables. 53 | 54 | :ivar children: List of the children below the current level of navigation. 55 | """ 56 | 57 | def __init__( 58 | self, 59 | isy: ISY, 60 | root=None, 61 | vids: dict[int, list[int]] | None = None, 62 | vnames: dict[int, dict[int, str]] | None = None, 63 | vobjs: dict[int, dict[int, Variable]] | None = None, 64 | def_xml: list[str] | None = None, 65 | var_xml: str | None = None, 66 | ) -> None: 67 | """Initialize a Variables ISY Variable Manager class.""" 68 | self.isy = isy 69 | self.root = root 70 | 71 | self.vids: dict[int, list[int]] = {1: [], 2: []} 72 | self.vobjs: dict[int, dict[int, Variable]] = {1: {}, 2: {}} 73 | self.vnames: dict[int, dict[int, str]] = {1: {}, 2: {}} 74 | 75 | if vids is not None and vnames is not None and vobjs is not None: 76 | self.vids = vids 77 | self.vnames = vnames 78 | self.vobjs = vobjs 79 | return 80 | 81 | valid_definitions: bool = False 82 | if def_xml is not None: 83 | valid_definitions = self.parse_definitions(def_xml) 84 | if valid_definitions and var_xml is not None: 85 | self.parse(var_xml) 86 | else: 87 | _LOGGER.warning("No valid variables defined") 88 | 89 | def __str__(self) -> str: 90 | """Return a string representation of the variable manager.""" 91 | if self.root is None: 92 | return "Variable Collection" 93 | return f"Variable Collection (Type: {self.root})" 94 | 95 | def __repr__(self) -> str: 96 | """Return a string representing the children variables.""" 97 | if self.root is None: 98 | return repr(self[1]) + repr(self[2]) 99 | out = str(self) + "\n" 100 | for child in self.children: 101 | out += f" {child[1]}: Variable({child[2]})\n" 102 | return out 103 | 104 | def parse_definitions(self, xmls: list[str]) -> bool: 105 | """Parse the XML Variable Definitions from the ISY.""" 106 | valid_definitions = False 107 | for ind in range(2): 108 | # parse definitions 109 | if xmls[ind] is None or xmls[ind] in EMPTY_VARIABLE_RESPONSES: 110 | # No variables of this type defined. 111 | _LOGGER.info("No Type %s variables defined", ind + 1) 112 | continue 113 | try: 114 | xmldoc = minidom.parseString(xmls[ind]) 115 | except XML_ERRORS: 116 | _LOGGER.error("%s: Type %s Variables", XML_PARSE_ERROR, ind + 1) 117 | continue 118 | else: 119 | features = xmldoc.getElementsByTagName(TAG_VARIABLE) 120 | for feature in features: 121 | vid = int(attr_from_element(feature, ATTR_ID)) 122 | self.vnames[ind + 1][vid] = attr_from_element(feature, TAG_NAME) 123 | valid_definitions = True 124 | return valid_definitions 125 | 126 | def parse(self, xml: str) -> None: 127 | """Parse XML from the controller with details about the variables.""" 128 | try: 129 | xmldoc = minidom.parseString(xml) 130 | except XML_ERRORS as exc: 131 | _LOGGER.error("%s: Variables", XML_PARSE_ERROR) 132 | raise ISYResponseParseError(XML_PARSE_ERROR) from exc 133 | 134 | features = xmldoc.getElementsByTagName(ATTR_VAR) 135 | for feature in features: 136 | vid = int(attr_from_element(feature, ATTR_ID)) 137 | vtype = int(attr_from_element(feature, TAG_TYPE)) 138 | init = value_from_xml(feature, ATTR_INIT) 139 | prec = int(value_from_xml(feature, ATTR_PRECISION, 0)) 140 | val = value_from_xml(feature, ATTR_VAL) 141 | ts_raw = value_from_xml(feature, ATTR_TS) 142 | timestamp = parser.parse(ts_raw) 143 | vname = self.vnames[vtype].get(vid, "") 144 | 145 | vobj = self.vobjs[vtype].get(vid) 146 | if vobj is None: 147 | vobj = Variable(self, vid, vtype, vname, init, val, timestamp, prec) 148 | self.vids[vtype].append(vid) 149 | self.vobjs[vtype][vid] = vobj 150 | else: 151 | vobj.init = init 152 | vobj.status = val 153 | vobj.prec = prec 154 | vobj.last_edited = timestamp 155 | 156 | _LOGGER.info("ISY Loaded Variables") 157 | 158 | async def update(self, wait_time: int = 0) -> None: 159 | """ 160 | Update the variable objects with data from the controller. 161 | 162 | | wait_time: Seconds to wait before updating. 163 | """ 164 | await sleep(wait_time) 165 | xml = await self.isy.conn.get_variables() 166 | if xml is not None: 167 | self.parse(xml) 168 | else: 169 | _LOGGER.warning("ISY Failed to update variables.") 170 | 171 | def update_received(self, xmldoc: minidom.Document) -> None: 172 | """Process an update received from the event stream.""" 173 | xml = xmldoc.toxml() 174 | vtype = int(attr_from_xml(xmldoc, ATTR_VAR, TAG_TYPE)) 175 | vid = int(attr_from_xml(xmldoc, ATTR_VAR, ATTR_ID)) 176 | try: 177 | vobj = self.vobjs[vtype][vid] 178 | except KeyError: 179 | return # this is a new variable that hasn't been loaded 180 | 181 | vobj.last_update = now() 182 | if f"<{ATTR_INIT}>" in xml: 183 | vobj.init = int(value_from_xml(xmldoc, ATTR_INIT)) 184 | else: 185 | vobj.status = int(value_from_xml(xmldoc, ATTR_VAL)) 186 | vobj.prec = int(value_from_xml(xmldoc, ATTR_PRECISION, 0)) 187 | vobj.last_edited = parser.parse(value_from_xml(xmldoc, ATTR_TS)) 188 | 189 | _LOGGER.debug("ISY Updated Variable: %s.%s", str(vtype), str(vid)) 190 | 191 | def __getitem__(self, val: int | str) -> Variable: 192 | """ 193 | Navigate through the variables by ID or name. 194 | 195 | | val: Name or ID for navigation. 196 | """ 197 | if self.root is None: 198 | if val in [1, 2]: 199 | return Variables(self.isy, val, self.vids, self.vnames, self.vobjs) 200 | raise KeyError(f"Unknown variable type: {val}") 201 | if isinstance(val, int): 202 | try: 203 | return self.vobjs[self.root][val] 204 | except (ValueError, KeyError) as err: 205 | raise KeyError(f"Unrecognized variable id: {val}") from err 206 | 207 | for vid, vname in self.vnames[self.root]: 208 | if vname == val: 209 | return self.vobjs[self.root][vid] 210 | raise KeyError(f"Unrecognized variable name: {val}") 211 | 212 | def __setitem__(self, val, value): 213 | """Handle the setitem function for the Class.""" 214 | return 215 | 216 | def get_by_name(self, val: str) -> Variable | None: 217 | """ 218 | Get a variable with the given name. 219 | 220 | | val: The name of the variable to look for. 221 | """ 222 | vtype, _, vid = next(item for item in self.children if val in item) 223 | if not vid and vtype: 224 | raise KeyError(f"Unrecognized variable name: {val}") 225 | return self.vobjs[vtype].get(vid) 226 | 227 | @property 228 | def children(self) -> list[tuple[int, str, int]]: 229 | """Get the children of the class.""" 230 | types = [1, 2] if self.root is None else [self.root] 231 | 232 | return [ 233 | ( 234 | vtype, 235 | self.vnames[vtype].get(self.vids[vtype][ind], ""), 236 | self.vids[vtype][ind], 237 | ) 238 | for vtype in types 239 | for ind in range(len(self.vids[vtype])) 240 | ] 241 | -------------------------------------------------------------------------------- /pyisy/variables/variable.py: -------------------------------------------------------------------------------- 1 | """Manage variables from the ISY.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING 7 | 8 | from ..constants import ( 9 | ATTR_INIT, 10 | ATTR_LAST_CHANGED, 11 | ATTR_LAST_UPDATE, 12 | ATTR_PRECISION, 13 | ATTR_SET, 14 | ATTR_STATUS, 15 | ATTR_TS, 16 | PROTO_INT_VAR, 17 | PROTO_STATE_VAR, 18 | TAG_ADDRESS, 19 | URL_VARIABLES, 20 | VAR_INTEGER, 21 | ) 22 | from ..helpers import EventEmitter, now 23 | from ..logging import _LOGGER 24 | 25 | if TYPE_CHECKING: 26 | from . import Variables 27 | 28 | 29 | class Variable: 30 | """ 31 | Object representing a variable on the controller. 32 | 33 | | variables: The variable manager object. 34 | | vid: List of variable IDs. 35 | | vtype: List of variable types. 36 | | init: List of values that variables initialize to when the controller 37 | starts. 38 | | val: The current variable value. 39 | | ts: The timestamp for the last time the variable was edited. 40 | 41 | :ivar init: Watched property that represents the value the variable 42 | initializes to when the controller boots. 43 | :ivar lastEdit: Watched property that indicates the last time the variable 44 | was edited. 45 | :ivar val: Watched property that represents the value of the variable. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | variables: Variables, 51 | vid: int, 52 | vtype: int, 53 | vname: str, 54 | init: object | None, 55 | status: object | None, 56 | timestamp: datetime, 57 | prec: int, 58 | ) -> None: 59 | """Initialize a Variable class.""" 60 | super().__init__() 61 | self._id = vid 62 | self._init = init 63 | self._last_edited = timestamp 64 | self._last_update = now() 65 | self._last_changed = self._last_update 66 | self._name = vname 67 | self._prec = prec 68 | self._status = status 69 | self._type = vtype 70 | self._variables = variables 71 | self.isy = variables.isy 72 | self.status_events = EventEmitter() 73 | 74 | def __str__(self) -> str: 75 | """Return a string representation of the variable.""" 76 | return f"Variable(type={self._type}, id={self._id}, value={self.status}, init={self.init})" 77 | 78 | def __repr__(self) -> str: 79 | """Return a string representation of the variable.""" 80 | return str(self) 81 | 82 | @property 83 | def address(self) -> str: 84 | """Return the formatted Variable Type and ID.""" 85 | return f"{self._type}.{self._id}" 86 | 87 | @property 88 | def init(self) -> object | None: 89 | """Return the initial state.""" 90 | return self._init 91 | 92 | @init.setter 93 | def init(self, value: object | None) -> object | None: 94 | """Set the initial state and notify listeners.""" 95 | if self._init != value: 96 | self._init = value 97 | self._last_changed = now() 98 | self.status_events.notify(self.status_feedback) 99 | return self._init 100 | 101 | @property 102 | def last_changed(self) -> datetime: 103 | """Return the UTC Time of the last status change for this node.""" 104 | return self._last_changed 105 | 106 | @property 107 | def last_edited(self) -> datetime: 108 | """Return the last edit time.""" 109 | return self._last_edited 110 | 111 | @last_edited.setter 112 | def last_edited(self, value: datetime) -> datetime: 113 | """Set the last edited time.""" 114 | if self._last_edited != value: 115 | self._last_edited = value 116 | return self._last_edited 117 | 118 | @property 119 | def last_update(self) -> datetime: 120 | """Return the UTC Time of the last update for this node.""" 121 | return self._last_update 122 | 123 | @last_update.setter 124 | def last_update(self, value: datetime) -> datetime: 125 | """Set the last update time.""" 126 | if self._last_update != value: 127 | self._last_update = value 128 | return self._last_update 129 | 130 | @property 131 | def protocol(self) -> str: 132 | """Return the protocol for this entity.""" 133 | return PROTO_INT_VAR if self._type == VAR_INTEGER else PROTO_STATE_VAR 134 | 135 | @property 136 | def name(self) -> str: 137 | """Return the Variable Name.""" 138 | return self._name 139 | 140 | @property 141 | def prec(self) -> int: 142 | """Return the Variable Precision.""" 143 | return self._prec 144 | 145 | @prec.setter 146 | def prec(self, value: int) -> int: 147 | """Set the current node state and notify listeners.""" 148 | if self._prec != value: 149 | self._prec = value 150 | self._last_changed = now() 151 | self.status_events.notify(self.status_feedback) 152 | return self._prec 153 | 154 | @property 155 | def status(self) -> object | None: 156 | """Return the current node state.""" 157 | return self._status 158 | 159 | @status.setter 160 | def status(self, value: object | None) -> object | None: 161 | """Set the current node state and notify listeners.""" 162 | if self._status != value: 163 | self._status = value 164 | self._last_changed = now() 165 | self.status_events.notify(self.status_feedback) 166 | return self._status 167 | 168 | @property 169 | def status_feedback(self) -> dict[str, object | None]: 170 | """Return information for a status change event.""" 171 | return { 172 | TAG_ADDRESS: self.address, 173 | ATTR_STATUS: self._status, 174 | ATTR_INIT: self._init, 175 | ATTR_PRECISION: self._prec, 176 | ATTR_TS: self._last_edited, 177 | ATTR_LAST_CHANGED: self._last_changed, 178 | ATTR_LAST_UPDATE: self._last_update, 179 | } 180 | 181 | @property 182 | def vid(self) -> int: 183 | """Return the Variable ID.""" 184 | return self._id 185 | 186 | async def update(self, wait_time: int = 0) -> None: 187 | """ 188 | Update the object with the variable's parameters from the controller. 189 | 190 | | wait_time: Seconds to wait before updating. 191 | """ 192 | self._last_update = now() 193 | await self._variables.update(wait_time) 194 | 195 | async def set_init(self, value: float) -> bool: 196 | """ 197 | Set the initial value for the variable after the controller boots. 198 | 199 | | val: The value to have the variable initialize to. 200 | """ 201 | return await self.set_value(value, True) 202 | 203 | async def set_value(self, value: float, init: bool = False) -> bool: 204 | """ 205 | Set the value of the variable. 206 | 207 | | val: The value to set the variable to. 208 | 209 | ISY Version 5 firmware will automatically convert float back to int. 210 | """ 211 | req_url = self.isy.conn.compile_url( 212 | [ 213 | URL_VARIABLES, 214 | ATTR_INIT if init else ATTR_SET, 215 | str(self._type), 216 | str(self._id), 217 | str(value), 218 | ] 219 | ) 220 | if not await self.isy.conn.request(req_url): 221 | _LOGGER.warning( 222 | "ISY could not set variable%s: %s.%s", 223 | " init value" if init else "", 224 | str(self._type), 225 | str(self._id), 226 | ) 227 | return False 228 | _LOGGER.debug( 229 | "ISY set variable%s: %s.%s", 230 | " init value" if init else "", 231 | str(self._type), 232 | str(self._id), 233 | ) 234 | return True 235 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=62.3,<81.0", "wheel","setuptools_scm[toml]>=6.2",] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pyisy" 7 | description = "Python module to talk to ISY devices from UDI." 8 | license = {text = "Apache-2.0"} 9 | keywords = ["home", "automation", "isy", "isy994", "isy-994", "UDI", "polisy", "eisy"] 10 | authors = [ 11 | {name = "Ryan Kraus", email = "automicus@gmail.com"}, 12 | {name = "shbatm", email = "support@shbatm.com"} 13 | ] 14 | readme = "README.md" 15 | classifiers=[ 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: End Users/Desktop", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Topic :: Home Automation", 24 | ] 25 | dynamic = ["version"] 26 | requires-python = ">=3.9" 27 | 28 | dependencies = [ 29 | "aiohttp>=3.8.1", 30 | "python-dateutil>=2.8.1", 31 | "requests>=2.28.1", 32 | "colorlog>=6.6.0", 33 | ] 34 | 35 | [project.urls] 36 | "Source Code" = "https://github.com/automicus/PyISY" 37 | "Homepage" = "https://github.com/automicus/PyISY" 38 | 39 | [tool.setuptools_scm] 40 | 41 | 42 | 43 | [tool.ruff] 44 | target-version = "py39" 45 | line-length = 110 46 | 47 | [tool.ruff.lint] 48 | ignore = [ 49 | "S101", # use of assert 50 | "TID252", # skip 51 | "SLF001", # design choice 52 | "SIM110", # this is slower 53 | "S318", # intentional 54 | "TRY003", # nice to have 55 | "PLR2004", # to many to fix right now 56 | "PLR0913", # ship has sail on this one 57 | "PLR0911", # ship has sail on this one 58 | "PLR0912", # hard to fix without a major refactor 59 | "PLR0915", # hard to fix without a major refactor 60 | "PYI034", # enable when we drop Py3.10 61 | "RUF003", # probably not worth fixing 62 | "SIM105", # this is slower 63 | ] 64 | select = [ 65 | "ASYNC", # async rules 66 | "B", # flake8-bugbear 67 | "C4", # flake8-comprehensions 68 | "S", # flake8-bandit 69 | "F", # pyflake 70 | "E", # pycodestyle 71 | "W", # pycodestyle 72 | "UP", # pyupgrade 73 | "I", # isort 74 | "RUF", # ruff specific 75 | "FLY", # flynt 76 | "G", # flake8-logging-format , 77 | "PERF", # Perflint 78 | "PGH", # pygrep-hooks 79 | "PIE", # flake8-pie 80 | "PL", # pylint 81 | "PT", # flake8-pytest-style 82 | "PTH", # flake8-pathlib 83 | "PYI", # flake8-pyi 84 | "RET", # flake8-return 85 | "RSE", # flake8-raise , 86 | "SIM", # flake8-simplify 87 | "SLF", # flake8-self 88 | "SLOT", # flake8-slots 89 | "T100", # Trace found: {name} used 90 | "T20", # flake8-print 91 | "TID", # Tidy imports 92 | "TRY", # tryceratops 93 | ] 94 | 95 | [tool.ruff.lint.per-file-ignores] 96 | "tests/**/*" = [ 97 | "D100", 98 | "D101", 99 | "D102", 100 | "D103", 101 | "D104", 102 | "S101", 103 | "SLF001", 104 | "PLR2004", # too many to fix right now 105 | "PT011", # too many to fix right now 106 | "PT006", # too many to fix right now 107 | "PGH003", # too many to fix right now 108 | "PT007", # too many to fix right now 109 | "PT027", # too many to fix right now 110 | "PLW0603" , # too many to fix right now 111 | "PLR0915", # too many to fix right now 112 | "FLY002", # too many to fix right now 113 | "PT018", # too many to fix right now 114 | "PLR0124", # too many to fix right now 115 | "SIM202" , # too many to fix right now 116 | "PT012" , # too many to fix right now 117 | "TID252", # too many to fix right now 118 | "PLR0913", # skip this one 119 | "SIM102" , # too many to fix right now 120 | "SIM108", # too many to fix right now 121 | "T201", # too many to fix right now 122 | "PT004", # nice to have 123 | ] 124 | "bench/**/*" = [ 125 | "T201", # intended 126 | ] 127 | "examples/**/*" = [ 128 | "T201", # intended 129 | ] 130 | "setup.py" = ["D100"] 131 | "conftest.py" = ["D100"] 132 | "docs/conf.py" = [ 133 | "D100", 134 | "PTH100" 135 | ] 136 | 137 | 138 | [tool.pytest.ini_options] 139 | testpaths = [ 140 | "tests", 141 | ] 142 | norecursedirs = [ 143 | ".git", 144 | ] 145 | log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(name)s:%(filename)s:%(lineno)s %(message)s" 146 | log_date_format = "%Y-%m-%d %H:%M:%S" 147 | asyncio_mode = "auto" 148 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pylint>=2.15.10 2 | pylint-strict-informational>=0.1 3 | black>=22.12.0 4 | codespell>=2.2.2 5 | flake8-docstrings>=1.6.0 6 | flake8>=6.0.0 7 | isort>=5.12.0 8 | pydocstyle>=6.2.3 9 | pyupgrade>=3.3.1 10 | pre-commit>=2.4.0 11 | sphinx>=3.4.3 12 | recommonmark>=0.7.1 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.8.1 2 | python-dateutil>=2.8.1 3 | requests>=2.28.1 4 | colorlog>=6.6.0 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | norecursedirs = .git testing_config 4 | 5 | [flake8] 6 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 7 | doctests = True 8 | # To work with Black 9 | max-line-length = 88 10 | # E501: line too long 11 | # W503: Line break occurred before a binary operator 12 | # E203: Whitespace before ':' 13 | # D202 No blank lines allowed after function docstring 14 | # W504 line break after binary operator 15 | ignore = 16 | E501, 17 | W503, 18 | E203, 19 | D202, 20 | W504 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Module Setup File for PIP Installation.""" 2 | 3 | import pathlib 4 | 5 | from setuptools import find_packages, setup 6 | 7 | HERE = pathlib.Path(__file__).parent 8 | README = (HERE / "README.md").read_text() 9 | 10 | setup( 11 | name="pyisy", 12 | version_format="{tag}", 13 | license="Apache License 2.0", 14 | url="https://github.com/automicus/PyISY", 15 | author="Ryan Kraus", 16 | author_email="automicus@gmail.com", 17 | description="Python module to talk to ISY994 from UDI.", 18 | long_description=README, 19 | long_description_content_type="text/markdown", 20 | packages=find_packages(), 21 | zip_safe=False, 22 | include_package_data=True, 23 | platforms="any", 24 | use_scm_version=True, 25 | setup_requires=["setuptools_scm"], 26 | install_requires=[ 27 | "aiohttp>=3.8.1", 28 | "python-dateutil>=2.8.1", 29 | "requests>=2.28.1", 30 | "colorlog>=6.6.0", 31 | ], 32 | keywords=["home automation", "isy", "isy994", "isy-994", "UDI"], 33 | classifiers=[ 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: Apache Software License", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3", 39 | "Topic :: Home Automation", 40 | ], 41 | ) 42 | --------------------------------------------------------------------------------