From 1cfc1fc020cca3e767a90eff74c11d87dae41175 Mon Sep 17 00:00:00 2001 From: Laurynas Sakalauskas Date: Sat, 9 Jan 2021 13:32:07 +0200 Subject: [PATCH 01/25] Add power sensor --- custom_components/sia/const.py | 4 ++-- custom_components/sia/hub.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index aafb346..040e635 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -28,8 +28,8 @@ PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { - "AT": {"type": "timestamp", "attr": "last_message"}, - "AR": {"type": "timestamp", "attr": "last_message"}, + "AT": {"type": "power", "new_state": False}, + "AR": {"type": "power", "new_state": True}, "BA": {"type": "alarm", "new_state": "triggered"}, "PA": {"type": "alarm", "new_state": "triggered"}, "JA": {"type": "alarm", "new_state": "triggered"}, diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 6593068..8dcf8b0 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_POWER, ) from homeassistant.const import ( CONF_PORT, @@ -74,6 +75,7 @@ def __init__( DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_POWER, ], } for a in self._accounts @@ -130,7 +132,7 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) @@ -158,7 +160,7 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: + if zone == 0 and entity_type == DEVICE_CLASS_TIMESTAMP: return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" From 4383d1c0034a35d528a77bba2d50db72c0ef9a01 Mon Sep 17 00:00:00 2001 From: Laurynas Sakalauskas Date: Sat, 9 Jan 2021 14:12:23 +0200 Subject: [PATCH 02/25] restructure power sensor --- README.md | 3 ++- custom_components/sia/hub.py | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 30a9974..1f3a649 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This integration was tested with Ajax Systems security hub only. Other SIA hubs Platform | Description -- | -- -`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. You can disable these sensors if you do not have those devices. +`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. Power sensor for the hub. You can disable these sensors if you do not have those devices. `alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. You can disable this sensor if you have zones defined with just sensors and no alarm. `sensor` | Sensor with the last heartbeat message from your system, one per account. Please do not disable this sensor as it will show you the status of the connection. @@ -20,6 +20,7 @@ Platform | Description - Alarm tracking with a alarm_control_panel component, but no alarm setting - Fire/gas tracker - Water leak tracker +- Hub Power status tracker - AES-128 CBC encryption support ## Hub Setup (Ajax Systems Hub example) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 8dcf8b0..00550ba 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -62,7 +62,7 @@ def __init__( { CONF_ACCOUNT: a[CONF_ACCOUNT], CONF_ZONE: HUB_ZONE, - CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP], + CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_POWER], } for a in self._accounts ] @@ -75,7 +75,6 @@ def __init__( DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, - DEVICE_CLASS_POWER, ], } for a in self._accounts @@ -132,11 +131,18 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) return + + if entity_type == DEVICE_CLASS_POWER: + self.states[entity_id] = SIABinarySensor( + entity_id, entity_name, entity_type, port, account, zone, ping + ) + return + if entity_type == DEVICE_CLASS_TIMESTAMP: self.states[entity_id] = SIASensor( entity_id, entity_name, entity_type, port, account, zone, ping @@ -147,8 +153,14 @@ def _get_entity_id_and_name( ): """Give back a entity_id and name according to the variables.""" if zone == 0: + if entity_type == DEVICE_CLASS_POWER: + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - Power", + ) + return ( - self._get_entity_id(account, zone, entity_type), + self._get_entity_id(account, zone, entity_type), f"{self._port} - {account} - Last Heartbeat", ) if entity_type: @@ -160,7 +172,9 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0 and entity_type == DEVICE_CLASS_TIMESTAMP: + if entity_type == DEVICE_CLASS_POWER: + return f"{self._port}_{account}_{entity_type}" + if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" From f954d6052881f14bef5ee65ca0bbadfa70face60 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 11 Jan 2021 16:17:58 +0100 Subject: [PATCH 03/25] cleaned power sensor code and added message attribute (#37) --- custom_components/sia/hub.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 00550ba..19dd973 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -131,18 +131,11 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) return - - if entity_type == DEVICE_CLASS_POWER: - self.states[entity_id] = SIABinarySensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - return - if entity_type == DEVICE_CLASS_TIMESTAMP: self.states[entity_id] = SIASensor( entity_id, entity_name, entity_type, port, account, zone, ping @@ -153,15 +146,10 @@ def _get_entity_id_and_name( ): """Give back a entity_id and name according to the variables.""" if zone == 0: - if entity_type == DEVICE_CLASS_POWER: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Power", - ) - + entity_type_name = "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Last Heartbeat", + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - {entity_type_name}", ) if entity_type: return ( @@ -172,10 +160,10 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if entity_type == DEVICE_CLASS_POWER: + if zone == 0: + if entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{self._port}_{account}_{HUB_SENSOR_NAME}" return f"{self._port}_{account}_{entity_type}" - if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" return None @@ -221,15 +209,21 @@ async def update_states(self, event: SIAEvent): event.account, int(event.ri), reaction["type"] ) + #update state if new_state is not None: self.states[entity_id].state = new_state elif new_state_eval is not None: if new_state_eval == UTCNOW: self.states[entity_id].state = utcnow() + + #update standard attributes of the touched sensor and if necessary the last_message or other attributes + self.states[entity_id].add_attribute( { "last_message": {event.message} }) + self.states[entity_id].add_attribute( { "last_code": {event.code} }) + self.states[entity_id].add_attribute( { "last_update": {utcnow().isoformat()} }) if attr is not None: if attr == LAST_MESSAGE: self.states[entity_id].add_attribute( { - "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" + "last_sia_event_string": "SIA: {event.sia_string}" } ) From b5d10a9a320b3374a3b6c54bbde4b23134cc210d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 14 Jan 2021 16:55:01 +0000 Subject: [PATCH 04/25] added devcontainer --- .devcontainer/configuration.yaml | 6 ++ .devcontainer/devcontainer.json | 31 +++++++ .devcontainer/readme.md | 43 ++++++++++ .gitignore | 138 ++++++++++++++++++++++++++++++- .vscode/settings.json | 6 ++ .vscode/tasks.json | 48 ++--------- 6 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/readme.md create mode 100644 .vscode/settings.json diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..d95e37b --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,6 @@ +default_config: + + logger: + default: error + logs: + custom_components.sia: debug \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..28af27c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ludeeus/container:integration", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "runArgs": [ + "-v", + "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" + ], + "extensions": [ + "ms-python.vscode-pylance", + "github.vscode-pull-request-github", + "tabnine.tabnine-vscode" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.devcontainer/readme.md b/.devcontainer/readme.md new file mode 100644 index 0000000..a01c141 --- /dev/null +++ b/.devcontainer/readme.md @@ -0,0 +1,43 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. \ No newline at end of file diff --git a/.gitignore b/.gitignore index dbe9c82..49260ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,137 @@ -.vscode/ \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# PyCharm stuff: +.idea/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# HA Config directory for local testing +/Config/ + +**/.DS_Store \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3c0f3d6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.associations": { + "*.yaml": "home-assistant" + }, + "python.pythonPath": "C:\\Anaconda3\\python.exe" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0749072..7ab4ba8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,59 +2,27 @@ "version": "2.0.0", "tasks": [ { - "label": "Start Home Assistant on port 8124", + "label": "Run Home Assistant on port 9123", "type": "shell", - "command": "source .devcontainer/custom_component_helper && StartHomeAssistant", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container start", "problemMatcher": [] }, { - "label": "Upgrade Home Assistant to latest dev", + "label": "Run Home Assistant configuration against /config", "type": "shell", - "command": "source .devcontainer/custom_component_helper && UpdgradeHomeAssistantDev", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container check", "problemMatcher": [] }, { - "label": "Set Home Assistant Version", + "label": "Upgrade Home Assistant to latest dev", "type": "shell", - "command": "source .devcontainer/custom_component_helper && SetHomeAssistantVersion", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container install", "problemMatcher": [] }, { - "label": "Home Assistant Config Check", + "label": "Install a specific version of Home Assistant", "type": "shell", - "command": "source .devcontainer/custom_component_helper && HomeAssistantConfigCheck", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container set-version", "problemMatcher": [] } ] From 4b7269f8b8584996892f12a2097a61ea30754767 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:23:43 +0100 Subject: [PATCH 05/25] added add_attribute to alarm_control_panel --- custom_components/sia/alarm_control_panel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index c61fc5d..7e8bc64 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -149,6 +149,10 @@ def device_state_attributes(self) -> dict: """Return device attributes.""" return self._attr + def add_attribute(self, attr: dict): + """Update attributes.""" + self._attr.update(attr) + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. From 1b4ae7d725ae7c76973b7fc083ee05888bc6d5bf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:24:31 +0100 Subject: [PATCH 06/25] added add_attribute to binary --- custom_components/sia/binary_sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index f99dce0..005cc93 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -155,6 +155,10 @@ def state(self, new_on: bool): if not self.registry_entry.disabled: self.async_schedule_update_ha_state() + def add_attribute(self, attr: dict): + """Update attributes.""" + self._attr.update(attr) + async def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: From f3208ac26651cc8bb27cf07505b5377428a07709 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:25:20 +0100 Subject: [PATCH 07/25] updated package --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 5012576..3f8ed83 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,9 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-3" + "pysiaalarm==2.0.9beta-4" ], "codeowners": [ "@eavanvalkenburg" ] -} \ No newline at end of file +} From e366f711b216d228ea454a120a3e69b5c4e1be8b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 26 Jan 2021 12:03:34 +0000 Subject: [PATCH 08/25] Rebuilt for Event based updating --- .devcontainer/configuration.yaml | 9 +- .devcontainer/devcontainer.json | 10 +- .vscode/launch.json | 23 +++ .vscode/settings.json | 2 +- custom_components/sia/__init__.py | 29 ++- custom_components/sia/alarm_control_panel.py | 101 +++++++++- custom_components/sia/binary_sensor.py | 121 ++++++++++- custom_components/sia/const.py | 76 ++----- custom_components/sia/helpers.py | 43 ++++ custom_components/sia/hub.py | 202 +++---------------- custom_components/sia/manifest.json | 2 +- custom_components/sia/reactions.json | 29 --- custom_components/sia/sensor.py | 83 ++++++-- test_zc.py | 20 ++ todo.md | 0 15 files changed, 437 insertions(+), 313 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 custom_components/sia/helpers.py delete mode 100644 custom_components/sia/reactions.json create mode 100644 test_zc.py create mode 100644 todo.md diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index d95e37b..ef41a5b 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -1,6 +1,7 @@ default_config: +debugpy: - logger: - default: error - logs: - custom_components.sia: debug \ No newline at end of file +logger: + default: info + logs: + custom_components.sia: debug \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28af27c..eff1f12 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration", + "image": "ludeeus/container:integration-debian", "context": "..", "appPort": [ "9123:8123" @@ -8,12 +8,16 @@ "postCreateCommand": "container install", "runArgs": [ "-v", - "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" + "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh", + // "--network=host", + "--add-host=host.docker.internal:host-gateway" ], "extensions": [ "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", "github.vscode-pull-request-github", - "tabnine.tabnine-vscode" + "redhat.vscode-yaml", + "esbenp.prettier-vscode" ], "settings": { "files.eol": "\n", diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f006b7f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Debug by attaching to local Home Asistant server using Remote Python Debugger. + // See https://www.home-assistant.io/integrations/debugpy/ + "name": "Home Assistant: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c0f3d6..678c1b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,5 @@ "files.associations": { "*.yaml": "home-assistant" }, - "python.pythonPath": "C:\\Anaconda3\\python.exe" + "python.pythonPath": "/usr/local/bin/python" } \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index ba14212..9e447f2 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -3,11 +3,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - -from .const import DOMAIN, PLATFORMS +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +from .const import DOMAIN, SIA_HUB, DATA_UNSUBSCRIBE from .hub import SIAHub +PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] + + async def async_setup(hass: HomeAssistant, config: dict): """Set up the sia component.""" hass.data.setdefault(DOMAIN, {}) @@ -17,8 +26,15 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up sia from a config entry.""" hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) + await hub.async_setup_hub() - hass.data[DOMAIN][entry.entry_id] = hub + + unsub = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hub.async_shutdown) + + hass.data[DOMAIN][entry.entry_id] = { + SIA_HUB: hub, + DATA_UNSUBSCRIBE: unsub, + } for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) @@ -37,10 +53,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ] ) ) + info = hass.data[DOMAIN].pop(entry.entry_id) - if unload_ok: - await hass.data[DOMAIN][entry.entry_id].sia_client.stop() - hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() - hass.data[DOMAIN].pop(entry.entry_id) + info[DATA_UNSUBSCRIBE]() + await info[SIA_HUB].sia_client.stop() return unload_ok diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index c61fc5d..e0d5673 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -9,6 +9,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_PORT, CONF_ZONE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, @@ -17,33 +18,79 @@ STATE_ALARM_TRIGGERED, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import callback, Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, + CONF_ACCOUNTS, CONF_PING_INTERVAL, + CONF_ZONES, DATA_UPDATED, + EVENT_CODE, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + SIA_EVENT, DOMAIN, PING_INTERVAL_MARGIN, - PREVIOUS_STATE, ) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) +DEVICE_CLASS_ALARM = "alarm" +PREVIOUS_STATE = "previous_state" + +CODE_CONSEQUENCES = { + "BA": STATE_ALARM_TRIGGERED, + "PA": STATE_ALARM_TRIGGERED, + "JA": STATE_ALARM_TRIGGERED, + "TA": STATE_ALARM_TRIGGERED, + "CA": STATE_ALARM_ARMED_AWAY, + "CG": STATE_ALARM_ARMED_AWAY, + "CL": STATE_ALARM_ARMED_AWAY, + "CP": STATE_ALARM_ARMED_AWAY, + "CQ": STATE_ALARM_ARMED_AWAY, + "CS": STATE_ALARM_ARMED_AWAY, + "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, + "OA": STATE_ALARM_DISARMED, + "OG": STATE_ALARM_DISARMED, + "OP": STATE_ALARM_DISARMED, + "OQ": STATE_ALARM_DISARMED, + "OR": STATE_ALARM_DISARMED, + "OS": STATE_ALARM_DISARMED, + "NC": STATE_ALARM_ARMED_NIGHT, + "NL": STATE_ALARM_ARMED_NIGHT, + "BR": PREVIOUS_STATE, + "NP": PREVIOUS_STATE, + "NO": PREVIOUS_STATE, +} + async def async_setup_entry( - hass, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] ) -> bool: - """Set up sia_alarm_control_panel from a config entry.""" + """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_devices( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIAAlarmControlPanel) + SIAAlarmControlPanel( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + zone, + acc[CONF_PING_INTERVAL], + ) + for acc in entry.data[CONF_ACCOUNTS] + for zone in range(1, acc[CONF_ZONES] + 1) ] ) return True @@ -68,7 +115,9 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = ping_interval + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._should_poll = False self._is_available = True @@ -77,8 +126,11 @@ def __init__( self._old_state = None self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: str(self._ping_interval), + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -109,6 +161,36 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the unavailability and event listener.""" + if self._unsub: + self._unsub() + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update states. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + await self.assume_available() + if int(event.data[EVENT_ZONE]) == self._zone: + new_state = CODE_CONSEQUENCES.get(event.data[EVENT_CODE]) + if new_state: + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + self.state = new_state @callback def _schedule_immediate_update(self): @@ -183,6 +265,7 @@ async def _async_track_unavailable(self) -> bool: ) if not self._is_available: self._is_available = True + self.async_schedule_update_ha_state() return True return False diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index f99dce0..a3a1645 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -6,38 +6,97 @@ from homeassistant.components.binary_sensor import ( ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, BinarySensorEntity, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SMOKE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, callback, Event from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, + CONF_ACCOUNTS, CONF_PING_INTERVAL, + CONF_ZONES, DATA_UPDATED, + EVENT_CODE, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + HUB_ZONE, + SIA_EVENT, DOMAIN, PING_INTERVAL_MARGIN, ) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) +ZONE_DEVICES = [ + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, +] +CODE_CONSEQUENCES = { + "AT": (DEVICE_CLASS_POWER, False), + "AR": (DEVICE_CLASS_POWER, True), + "GA": (DEVICE_CLASS_SMOKE, True), + "GH": (DEVICE_CLASS_SMOKE, False), + "FA": (DEVICE_CLASS_SMOKE, True), + "FH": (DEVICE_CLASS_SMOKE, False), + "KA": (DEVICE_CLASS_SMOKE, True), + "KH": (DEVICE_CLASS_SMOKE, False), + "WA": (DEVICE_CLASS_MOISTURE, True), + "WH": (DEVICE_CLASS_MOISTURE, False), +} + async def async_setup_entry( - hass, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] ) -> bool: """Set up sia_binary_sensor from a config entry.""" - async_add_devices( + + devices = [ + SIABinarySensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + zone, + acc[CONF_PING_INTERVAL], + device_class, + ) + for acc in entry.data[CONF_ACCOUNTS] + for zone in range(1, acc[CONF_ZONES] + 1) + for device_class in ZONE_DEVICES + ] + devices.extend( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIABinarySensor) + SIABinarySensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + DEVICE_CLASS_POWER, + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + acc[CONF_PING_INTERVAL], + DEVICE_CLASS_POWER, + ) + for acc in entry.data[CONF_ACCOUNTS] ] ) - + async_add_devices(devices) return True @@ -48,11 +107,11 @@ def __init__( self, entity_id: str, name: str, - device_class: str, port: int, account: str, zone: int, ping_interval: int, + device_class: str, ): """Create SIABinarySensor object.""" self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) @@ -62,7 +121,9 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = ping_interval + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._should_poll = False self._is_on = None @@ -70,8 +131,11 @@ def __init__( self._remove_unavailability_tracker = None self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: str(self._ping_interval), + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -87,12 +151,47 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the unavailability and event listener.""" + if self._unsub: + self._unsub() + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() @callback def _schedule_immediate_update(self): """Schedule update.""" self.async_schedule_update_ha_state(True) + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update states. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + await self.assume_available() + if ( + int(event.data[EVENT_ZONE]) == self._zone + or self._device_class == DEVICE_CLASS_POWER + ): + device_class, new_state = CODE_CONSEQUENCES.get( + event.data[EVENT_CODE], (None, None) + ) + if new_state is not None and device_class == self._device_class: + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + self.state = new_state + @property def name(self) -> str: """Return name.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 040e635..f00649a 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,12 +1,9 @@ """Constants for the sia integration.""" - from datetime import timedelta -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +ATTR_LAST_MESSAGE = "last_message" +ATTR_LAST_CODE = "last_code" +ATTR_LAST_TIMESTAMP = "last_timestamp" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" @@ -14,65 +11,20 @@ CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" CONF_ZONES = "zones" + DOMAIN = "sia" +DATA_UNSUBSCRIBE = "unsubs" DATA_UPDATED = f"{DOMAIN}_data_updated" -DEFAULT_NAME = "SIA Alarm" -DEVICE_CLASS_ALARM = "alarm" +SIA_HUB = "sia_hub" +SIA_EVENT = "sia_event" HUB_SENSOR_NAME = "last_heartbeat" HUB_ZONE = 0 PING_INTERVAL_MARGIN = timedelta(seconds=30) -PREVIOUS_STATE = "previous_state" -UTCNOW = "utcnow" -LAST_MESSAGE = "last_message" - -PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] - -REACTIONS = { - "AT": {"type": "power", "new_state": False}, - "AR": {"type": "power", "new_state": True}, - "BA": {"type": "alarm", "new_state": "triggered"}, - "PA": {"type": "alarm", "new_state": "triggered"}, - "JA": {"type": "alarm", "new_state": "triggered"}, - "BR": {"type": "alarm", "new_state": "previous_state"}, - "CA": {"type": "alarm", "new_state": "armed_away"}, - "CF": {"type": "alarm", "new_state": "armed_custom_bypass"}, - "CG": {"type": "alarm", "new_state": "armed_away"}, - "CL": {"type": "alarm", "new_state": "armed_away"}, - "CP": {"type": "alarm", "new_state": "armed_away"}, - "CQ": {"type": "alarm", "new_state": "armed_away"}, - "CS": {"type": "alarm", "new_state": "armed_away"}, - "GA": {"type": "smoke", "new_state": True}, - "GH": {"type": "smoke", "new_state": False}, - "FA": {"type": "smoke", "new_state": True}, - "FH": {"type": "smoke", "new_state": False}, - "KA": {"type": "smoke", "new_state": True}, - "KH": {"type": "smoke", "new_state": False}, - "NC": {"type": "alarm", "new_state": "armed_night"}, - "NL": {"type": "alarm", "new_state": "armed_night"}, - "NP": {"type": "alarm", "new_state": "previous_state"}, - "NO": {"type": "alarm", "new_state": "previous_state"}, - "OA": {"type": "alarm", "new_state": "disarmed"}, - "OG": {"type": "alarm", "new_state": "disarmed"}, - "OP": {"type": "alarm", "new_state": "disarmed"}, - "OQ": {"type": "alarm", "new_state": "disarmed"}, - "OR": {"type": "alarm", "new_state": "disarmed"}, - "OS": {"type": "alarm", "new_state": "disarmed"}, - "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, - "TA": {"type": "alarm", "new_state": "triggered"}, - "WA": {"type": "moisture", "new_state": True}, - "WH": {"type": "moisture", "new_state": False}, - "YG": {"type": "timestamp", "attr": "last_message"}, - "YC": {"type": "timestamp", "attr": "last_message"}, - "XI": {"type": "timestamp", "attr": "last_message"}, - "YM": {"type": "timestamp", "attr": "last_message"}, - "YA": {"type": "timestamp", "attr": "last_message"}, - "YS": {"type": "timestamp", "attr": "last_message"}, - "XQ": {"type": "timestamp", "attr": "last_message"}, - "XH": {"type": "timestamp", "attr": "last_message"}, - "YT": {"type": "timestamp", "attr": "last_message"}, - "YR": {"type": "timestamp", "attr": "last_message"}, - "TR": {"type": "timestamp", "attr": "last_message"}, - "ZZ": {"type": "timestamp", "attr": "last_message"}, - "ZY": {"type": "timestamp", "attr": "last_message"}, -} +EVENT_CODE = "code" +EVENT_ACCOUNT = "account" +EVENT_ZONE = "zone" +EVENT_PORT = "port" +EVENT_MESSAGE = "message" +EVENT_ID = "id" +EVENT_TIMESTAMP = "timestamp" diff --git a/custom_components/sia/helpers.py b/custom_components/sia/helpers.py new file mode 100644 index 0000000..d9aa371 --- /dev/null +++ b/custom_components/sia/helpers.py @@ -0,0 +1,43 @@ +from typing import Tuple +from datetime import timedelta + +from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from .const import HUB_SENSOR_NAME, HUB_ZONE + + +def GET_ENTITY_AND_NAME( + port: int, account: str, zone: int = 0, entity_type: str = None +) -> Tuple[str, str]: + """Give back a entity_id and name according to the variables.""" + if zone == HUB_ZONE: + entity_type_name = ( + "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" + ) + return ( + GET_ENTITY_ID(port, account, zone, entity_type), + f"{port} - {account} - {entity_type_name}", + ) + if entity_type: + return ( + GET_ENTITY_ID(port, account, zone, entity_type), + f"{port} - {account} - zone {zone} - {entity_type}", + ) + return None + + +def GET_PING_INTERVAL(ping: int) -> timedelta: + """Return the ping interval as timedelta.""" + return timedelta(minutes=ping) + + +def GET_ENTITY_ID( + port: int, account: str, zone: int = 0, entity_type: str = None +) -> str: + """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" + if zone == HUB_ZONE: + if entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{port}_{account}_{HUB_SENSOR_NAME}" + return f"{port}_{account}_{entity_type}" + if entity_type: + return f"{port}_{account}_{zone}_{entity_type}" + return None diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 19dd973..76904f0 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,43 +1,31 @@ """The sia hub.""" import asyncio from datetime import timedelta +from typing import Tuple import logging from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_POWER, -) -from homeassistant.const import ( - CONF_PORT, - CONF_SENSORS, - CONF_ZONE, - DEVICE_CLASS_TIMESTAMP, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.core import Event, EventOrigin +from homeassistant.const import CONF_PORT from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util.dt import utcnow +from homeassistant.helpers.typing import EventType -from .alarm_control_panel import SIAAlarmControlPanel -from .binary_sensor import SIABinarySensor from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, - CONF_PING_INTERVAL, - CONF_ZONES, - DEVICE_CLASS_ALARM, + EVENT_CODE, + EVENT_ACCOUNT, + EVENT_ZONE, + EVENT_PORT, + EVENT_MESSAGE, + EVENT_ID, + EVENT_TIMESTAMP, DOMAIN, - HUB_SENSOR_NAME, - HUB_ZONE, - LAST_MESSAGE, - REACTIONS, - UTCNOW, + SIA_EVENT, ) -from .sensor import SIASensor _LOGGER = logging.getLogger(__name__) @@ -50,46 +38,18 @@ def __init__( ): """Create the SIAHub.""" self._hass = hass - self.states = {} self._port = int(hub_config[CONF_PORT]) self.entry_id = entry_id self._title = title self._accounts = hub_config[CONF_ACCOUNTS] - self.shutdown_remove_listener = None - self._reactions = REACTIONS - - self._zones = [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: HUB_ZONE, - CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_POWER], - } - for a in self._accounts - ] - self._zones.extend( - [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: z, - CONF_SENSORS: [ - DEVICE_CLASS_ALARM, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - ], - } - for a in self._accounts - for z in range(1, int(a[CONF_ZONES]) + 1) - ] - ) self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY)) + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), (300, 150)) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.update_states + "", self._port, self.sia_accounts, self.async_create_and_fire_event ) - self._create_sensors() async def async_setup_hub(self): """Add a device to the device_registry, register shutdown listener, load reactions.""" @@ -102,128 +62,24 @@ async def async_setup_hub(self): identifiers={(DOMAIN, port, account)}, name=f"{port} - {account}", ) - self.shutdown_remove_listener = self._hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_shutdown - ) async def async_shutdown(self, _: Event): """Shutdown the SIA server.""" await self.sia_client.stop() - def _create_sensors(self): - """Create all the sensors.""" - for zone in self._zones: - ping = self._get_ping_interval(zone[CONF_ACCOUNT]) - for entity_type in zone[CONF_SENSORS]: - self._create_sensor( - self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], entity_type, ping - ) - - def _create_sensor( - self, port: int, account: str, zone: int, entity_type: str, ping: int - ): - """Check if the entity exists, and creates otherwise.""" - entity_id, entity_name = self._get_entity_id_and_name( - account, zone, entity_type + async def async_create_and_fire_event(self, event: SIAEvent): + """Create a event on HA's bus, with the data from the SIAEvent.""" + event_data = { + EVENT_PORT: self._port, + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } + self._hass.bus.async_fire( + f"{SIA_EVENT}_{self._port}_{event.account}", + event_data, + origin=EventOrigin.remote, ) - if entity_type == DEVICE_CLASS_ALARM: - self.states[entity_id] = SIAAlarmControlPanel( - entity_id, entity_name, port, account, zone, ping - ) - return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): - self.states[entity_id] = SIABinarySensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - return - if entity_type == DEVICE_CLASS_TIMESTAMP: - self.states[entity_id] = SIASensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - - def _get_entity_id_and_name( - self, account: str, zone: int = 0, entity_type: str = None - ): - """Give back a entity_id and name according to the variables.""" - if zone == 0: - entity_type_name = "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - {entity_type_name}", - ) - if entity_type: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - zone {zone} - {entity_type}", - ) - return None - - def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): - """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0: - if entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{self._port}_{account}_{HUB_SENSOR_NAME}" - return f"{self._port}_{account}_{entity_type}" - if entity_type: - return f"{self._port}_{account}_{zone}_{entity_type}" - return None - - def _get_ping_interval(self, account: str): - """Return the ping interval for specified account.""" - for acc in self._accounts: - if acc[CONF_ACCOUNT] == account: - return timedelta(minutes=acc[CONF_PING_INTERVAL]) - return None - - async def update_states(self, event: SIAEvent): - """Update the sensors. This can be both a new state and a new attribute. - - Whenever a message comes in and is a event that should cause a reaction, the connection is good, so reset the availability timer for all devices of that account, excluding the last heartbeat. - - """ - - # ignore exceptions (those are returned now, but not read) to deal with disabled sensors. - await asyncio.gather( - *[ - entity.assume_available() - for entity in self.states.values() - if entity.account == event.account and not isinstance(entity, SIASensor) - ], - return_exceptions=True, - ) - - # find the reactions for that code (if any) - reaction = self._reactions.get(event.code) - if not reaction: - _LOGGER.info( - "Unhandled event code, will be set as attribute in the heartbeat sensor. Code is: %s, Message: %s, Full event: %s", - event.code, - event.message, - event.sia_string, - ) - reaction = {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE} - attr = reaction.get("attr") - new_state = reaction.get("new_state") - new_state_eval = reaction.get("new_state_eval") - entity_id = self._get_entity_id( - event.account, int(event.ri), reaction["type"] - ) - - #update state - if new_state is not None: - self.states[entity_id].state = new_state - elif new_state_eval is not None: - if new_state_eval == UTCNOW: - self.states[entity_id].state = utcnow() - - #update standard attributes of the touched sensor and if necessary the last_message or other attributes - self.states[entity_id].add_attribute( { "last_message": {event.message} }) - self.states[entity_id].add_attribute( { "last_code": {event.code} }) - self.states[entity_id].add_attribute( { "last_update": {utcnow().isoformat()} }) - if attr is not None: - if attr == LAST_MESSAGE: - self.states[entity_id].add_attribute( - { - "last_sia_event_string": "SIA: {event.sia_string}" - } - ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 5012576..267169e 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-3" + "pysiaalarm==2.0.9beta-5" ], "codeowners": [ "@eavanvalkenburg" diff --git a/custom_components/sia/reactions.json b/custom_components/sia/reactions.json deleted file mode 100644 index 1d2b071..0000000 --- a/custom_components/sia/reactions.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "BA": { "type": "alarm", "new_state": "triggered" }, - "BR": { "type": "alarm", "new_state": "previous_state" }, - "CA": { "type": "alarm", "new_state": "armed_away" }, - "CF": { - "type": "alarm", - "new_state": "armed_custom_bypass" - }, - "CG": { "type": "alarm", "new_state": "armed_away" }, - "CL": { "type": "alarm", "new_state": "armed_away" }, - "CP": { "type": "alarm", "new_state": "armed_away" }, - "CQ": { "type": "alarm", "new_state": "armed_away" }, - "GA": { "type": "smoke", "new_state": true }, - "GH": { "type": "smoke", "new_state": false }, - "NL": { - "type": "alarm", - "new_state": "armed_night" - }, - "OA": { "type": "alarm", "new_state": "disarmed" }, - "OG": { "type": "alarm", "new_state": "disarmed" }, - "OP": { "type": "alarm", "new_state": "disarmed" }, - "OQ": { "type": "alarm", "new_state": "disarmed" }, - "OR": { "type": "alarm", "new_state": "disarmed" }, - "RP": { "type": "timestamp", "new_state_eval": "utcnow" }, - "TA": { "type": "alarm", "new_state": "triggered" }, - "WA": { "type": "moisture", "new_state": true }, - "WH": { "type": "moisture", "new_state": false }, - "YG": { "type": "timestamp", "attr": "last_message" } -} diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 9c5694d..52c2787 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -6,13 +6,28 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ZONE -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP +from homeassistant.core import HomeAssistant, callback, Event from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DATA_UPDATED, DOMAIN +from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_PING_INTERVAL, + DATA_UPDATED, + EVENT_CODE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + HUB_ZONE, + SIA_EVENT, + DOMAIN, +) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -23,12 +38,22 @@ async def async_setup_entry( """Set up sia_sensor from a config entry.""" async_add_devices( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIASensor) + SIASensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + DEVICE_CLASS_TIMESTAMP, + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + acc[CONF_PING_INTERVAL], + DEVICE_CLASS_TIMESTAMP, + ) + for acc in entry.data[CONF_ACCOUNTS] ] ) - return True @@ -39,11 +64,11 @@ def __init__( self, entity_id: str, name: str, - device_class: str, port: int, account: str, zone: int, ping_interval: int, + device_class: str, ): """Create SIASensor object.""" self.entity_id = SENSOR_FORMAT.format(entity_id) @@ -53,14 +78,19 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = str(ping_interval) + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._state = utcnow() self._should_poll = False self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self._ping_interval, + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -74,17 +104,46 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the event listener.""" + if self._unsub: + self._unsub() @callback def _schedule_immediate_update(self): """Schedule update.""" self.async_schedule_update_ha_state(True) + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update the state and attributes.""" + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + if event.data[EVENT_CODE] == "RP": + self.state = utcnow() + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() + @property def name(self) -> str: """Return name.""" return self._name + @property + def ping_interval(self) -> int: + """Get ping_interval.""" + return str(self._ping_interval) + @property def unique_id(self) -> str: """Get unique_id.""" @@ -116,7 +175,7 @@ def should_poll(self) -> bool: False if entity pushes its state to HA. """ return False - + @property def device_class(self) -> str: """Return device class.""" @@ -126,8 +185,6 @@ def device_class(self) -> str: def state(self, state: dt.datetime): """Set state.""" self._state = state - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() @property def icon(self) -> str: diff --git a/test_zc.py b/test_zc.py new file mode 100644 index 0000000..f754477 --- /dev/null +++ b/test_zc.py @@ -0,0 +1,20 @@ +from zeroconf import ServiceBrowser, Zeroconf + + +class MyListener: + + def remove_service(self, zeroconf, type, name): + print("Service %s removed" % (name,)) + + def add_service(self, zeroconf, type, name): + info = zeroconf.get_service_info(type, name) + print("Service %s added, service info: %s" % (name, info)) + + +zeroconf = Zeroconf(apple_p2p=False) +listener = MyListener() +browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) +try: + input("Press enter to exit...\n\n") +finally: + zeroconf.close() \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e69de29 From 1cc84a13b4e93a3f3d4a3ec2645f6412013caeec Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 1 Feb 2021 15:42:06 +0000 Subject: [PATCH 09/25] cleaned up code --- custom_components/sia/__init__.py | 19 +++------- custom_components/sia/alarm_control_panel.py | 9 ++--- custom_components/sia/binary_sensor.py | 9 ++--- custom_components/sia/const.py | 2 - custom_components/sia/hub.py | 39 +++++++++++--------- custom_components/sia/sensor.py | 9 +---- 6 files changed, 35 insertions(+), 52 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 9e447f2..c1d4ff2 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -3,14 +3,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from .const import DOMAIN, SIA_HUB, DATA_UNSUBSCRIBE +from .const import DOMAIN from .hub import SIAHub @@ -29,16 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await hub.async_setup_hub() - unsub = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hub.async_shutdown) - - hass.data[DOMAIN][entry.entry_id] = { - SIA_HUB: hub, - DATA_UNSUBSCRIBE: unsub, - } + hass.data[DOMAIN][entry.entry_id] = hub for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) + hub.sia_client.start(reuse_port=True) return True @@ -53,9 +48,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ] ) ) - info = hass.data[DOMAIN].pop(entry.entry_id) - - info[DATA_UNSUBSCRIBE]() - await info[SIA_HUB].sia_client.stop() - + if unload_ok: + hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) + await hub.async_shutdown() return unload_ok diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index e0d5673..ae69534 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -191,6 +191,8 @@ async def async_handle_event(self, event: Event): } ) self.state = new_state + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() @callback def _schedule_immediate_update(self): @@ -233,10 +235,7 @@ def device_state_attributes(self) -> dict: @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @state.setter @@ -245,8 +244,6 @@ def state(self, state: str): temp = self._old_state if state == PREVIOUS_STATE else state self._old_state = self._state self._state = temp - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index a3a1645..9be2e7d 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -191,6 +191,8 @@ async def async_handle_event(self, event: Event): } ) self.state = new_state + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() @property def name(self) -> str: @@ -241,18 +243,13 @@ def is_on(self) -> bool: @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @state.setter def state(self, new_on: bool): """Set state.""" self._is_on = new_on - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index f00649a..5c574b7 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -13,9 +13,7 @@ CONF_ZONES = "zones" DOMAIN = "sia" -DATA_UNSUBSCRIBE = "unsubs" DATA_UPDATED = f"{DOMAIN}_data_updated" -SIA_HUB = "sia_hub" SIA_EVENT = "sia_event" HUB_SENSOR_NAME = "last_heartbeat" HUB_ZONE = 0 diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 76904f0..be62073 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -6,11 +6,9 @@ from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent -from homeassistant.core import Event, EventOrigin -from homeassistant.const import CONF_PORT -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, EventOrigin, HomeAssistant +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import EventType from .const import ( CONF_ACCOUNT, @@ -27,6 +25,8 @@ SIA_EVENT, ) +ALLOWED_TIMEBAND = (300, 150) + _LOGGER = logging.getLogger(__name__) @@ -43,8 +43,9 @@ def __init__( self._title = title self._accounts = hub_config[CONF_ACCOUNTS] + self._remove_shutdown_listener = None self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), (300, 150)) + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), ALLOWED_TIMEBAND) for a in self._accounts ] self.sia_client = SIAClient( @@ -62,24 +63,28 @@ async def async_setup_hub(self): identifiers={(DOMAIN, port, account)}, name=f"{port} - {account}", ) + self._remove_shutdown_listener = self._hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self.async_shutdown + ) - async def async_shutdown(self, _: Event): + async def async_shutdown(self, _: Event = None): """Shutdown the SIA server.""" + if self._remove_shutdown_listener: + self._remove_shutdown_listener() await self.sia_client.stop() async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" - event_data = { - EVENT_PORT: self._port, - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - } self._hass.bus.async_fire( - f"{SIA_EVENT}_{self._port}_{event.account}", - event_data, + event_type=f"{SIA_EVENT}_{self._port}_{event.account}", + event_data={ + EVENT_PORT: self._port, + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + }, origin=EventOrigin.remote, ) diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 52c2787..7bb54e6 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -164,16 +164,9 @@ def device_state_attributes(self) -> dict: """Return attributes.""" return self._attr - def add_attribute(self, attr: dict): - """Update attributes.""" - self._attr.update(attr) - @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @property From 5ed289583167766c028dafca56a743045e8a06f5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 5 Mar 2021 07:55:50 +0000 Subject: [PATCH 10/25] add version to manifest --- custom_components/sia/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 44c7886..b5f0f25 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -8,5 +8,6 @@ ], "codeowners": [ "@eavanvalkenburg" - ] + ], + "version": "0.4.0b2" } From 804b21044c4b7dd09ee2e29baa64fff54d0955d3 Mon Sep 17 00:00:00 2001 From: Qxlkdr <33372537+Qxlkdr@users.noreply.github.com> Date: Thu, 25 Mar 2021 12:38:26 +0100 Subject: [PATCH 11/25] Update README.md Updated Account/Object number and added information in step 9. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30a9974..f8c3101 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,13 @@ Platform | Description 1. Select "SIA Protocol". 2. Enable "Connect on demand". -3. Place Account Id - 3-16 ASCII hex characters. For example AAA. +3. Place Account Id/Object number - 3-16 ASCII hex characters. For example AAA. 4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. 5. Insert Home Assistant listening port. This port must not be used with anything else. 6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. 7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. 8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. +9. Keep in mind that Monitoring Station will say "Connected" in the app if configured correctly. The sensors will have state "Unknown" until they get a new state. Arm/disarm to update the alarm sensor as an example. ## Installation From 4bca2047db1190c156c015564c1671fa7a176f1f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 7 Apr 2021 16:05:45 +0000 Subject: [PATCH 12/25] update to latest version of package, improvements in code --- .devcontainer/configuration.yaml | 2 +- .devcontainer/devcontainer.json | 6 +- custom_components/sia/__init__.py | 7 +- custom_components/sia/alarm_control_panel.py | 71 ++++++++-------- custom_components/sia/binary_sensor.py | 66 +++++++-------- custom_components/sia/config_flow.py | 86 ++++++++++---------- custom_components/sia/const.py | 14 ++-- custom_components/sia/helpers.py | 29 ++++++- custom_components/sia/hub.py | 31 ++----- custom_components/sia/manifest.json | 4 +- custom_components/sia/sensor.py | 54 ++++-------- hacs.json | 2 +- 12 files changed, 170 insertions(+), 202 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index ef41a5b..de5ad0e 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -4,4 +4,4 @@ debugpy: logger: default: info logs: - custom_components.sia: debug \ No newline at end of file + custom_components.sia: debug diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index eff1f12..4f7329d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,6 +30,8 @@ "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } + "files.trimTrailingWhitespace": true, + "remote.autoForwardPorts": false + }, + "forwardPorts": [5678, 8126] } \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index c1d4ff2..e491af9 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,18 +1,17 @@ """The sia integration.""" import asyncio -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .const import DOMAIN from .hub import SIAHub - PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] @@ -34,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) - hub.sia_client.start(reuse_port=True) + await hub.sia_client.start(reuse_port=True) return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index ae69534..2b5344d 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -2,6 +2,7 @@ import logging from typing import Callable +from pysiaalarm import SIAEvent from homeassistant.components.alarm_control_panel import ( ENTITY_ID_FORMAT as ALARM_FORMAT, @@ -25,15 +26,14 @@ from homeassistant.util.dt import utcnow from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, CONF_ZONES, DATA_UPDATED, + EVENT_ACCOUNT, EVENT_CODE, + EVENT_ID, EVENT_ZONE, EVENT_MESSAGE, EVENT_TIMESTAMP, @@ -41,7 +41,7 @@ DOMAIN, PING_INTERVAL_MARGIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) @@ -75,10 +75,10 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up SIA alarm_control_panel(s) from a config entry.""" - async_add_devices( + async_add_entities( [ SIAAlarmControlPanel( *GET_ENTITY_AND_NAME( @@ -119,7 +119,6 @@ def __init__( self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None - self._should_poll = False self._is_available = True self._remove_unavailability_tracker = None self._state = None @@ -128,9 +127,12 @@ def __init__( CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, + EVENT_ACCOUNT: None, + EVENT_CODE: None, + EVENT_ID: None, + EVENT_ZONE: None, + EVENT_MESSAGE: None, + EVENT_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -158,16 +160,14 @@ async def async_added_to_hass(self): else: self.state = None await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the unavailability and event listener.""" if self._unsub: self._unsub() @@ -180,23 +180,16 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ await self.assume_available() - if int(event.data[EVENT_ZONE]) == self._zone: - new_state = CODE_CONSEQUENCES.get(event.data[EVENT_CODE]) - if new_state: - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) - self.state = new_state - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() - - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) + sia_event = SIAEvent.from_dict(event.data) + if int(sia_event.ri) != self._zone: + return + new_state = CODE_CONSEQUENCES.get(sia_event.code) + if not new_state: + return + self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self.state = new_state + if self.enabled: + self.async_schedule_update_ha_state() @property def name(self) -> str: @@ -213,6 +206,13 @@ def state(self) -> str: """Get state.""" return self._state + @state.setter + def state(self, state: str): + """Set state.""" + temp = self._old_state if state == PREVIOUS_STATE else state + self._old_state = self._state + self._state = temp + @property def account(self) -> str: """Return device account.""" @@ -238,13 +238,6 @@ def should_poll(self) -> bool: """Return False if entity pushes its state to HA.""" return False - @state.setter - def state(self, state: str): - """Set state.""" - temp = self._old_state if state == PREVIOUS_STATE else state - self._old_state = self._state - self._state = temp - async def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 9be2e7d..80d5546 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -4,39 +4,41 @@ from typing import Callable from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, - BinarySensorEntity, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_POWER, DEVICE_CLASS_SMOKE, ) +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, +) +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow +from pysiaalarm import SIAEvent from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, CONF_ZONES, DATA_UPDATED, + DOMAIN, + EVENT_ACCOUNT, EVENT_CODE, + EVENT_ID, EVENT_ZONE, EVENT_MESSAGE, EVENT_TIMESTAMP, HUB_ZONE, - SIA_EVENT, - DOMAIN, PING_INTERVAL_MARGIN, + SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) @@ -59,7 +61,7 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up sia_binary_sensor from a config entry.""" @@ -96,7 +98,7 @@ async def async_setup_entry( for acc in entry.data[CONF_ACCOUNTS] ] ) - async_add_devices(devices) + async_add_entities(devices) return True @@ -125,7 +127,6 @@ def __init__( self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None - self._should_poll = False self._is_on = None self._is_available = True self._remove_unavailability_tracker = None @@ -133,9 +134,12 @@ def __init__( CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, + EVENT_ACCOUNT: None, + EVENT_CODE: None, + EVENT_ID: None, + EVENT_ZONE: None, + EVENT_MESSAGE: None, + EVENT_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -148,50 +152,36 @@ async def async_added_to_hass(self): elif state.state == STATE_OFF: self._is_on = False await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the unavailability and event listener.""" if self._unsub: self._unsub() if self._remove_unavailability_tracker: self._remove_unavailability_tracker() - @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - async def async_handle_event(self, event: Event): """Listen to events for this port and account and update states. If the port and account combo receives any message it means it is online and can therefore be set to available. """ await self.assume_available() - if ( - int(event.data[EVENT_ZONE]) == self._zone - or self._device_class == DEVICE_CLASS_POWER - ): + sia_event = SIAEvent.from_dict(event.data) + sia_event.message_type = sia_event.message_type.value + if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: device_class, new_state = CODE_CONSEQUENCES.get( - event.data[EVENT_CODE], (None, None) + sia_event.code, (None, None) ) if new_state is not None and device_class == self._device_class: - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) + self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) self.state = new_state - if not self.registry_entry.disabled: + if self.enabled: self.async_schedule_update_ha_state() @property diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 4398c01..d9cd870 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,6 +1,10 @@ """Config flow for sia integration.""" import logging +import voluptuous as vol +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_PORT +from homeassistant.data_entry_flow import AbortFlow from pysiaalarm import ( InvalidAccountFormatError, InvalidAccountLengthError, @@ -8,11 +12,6 @@ InvalidKeyLengthError, SIAAccount, ) -import voluptuous as vol - -from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_PORT -from homeassistant.data_entry_flow import AbortFlow from .const import ( CONF_ACCOUNT, @@ -49,9 +48,9 @@ ) -def validate_input(data: dict) -> bool: +def validate_input(data: dict): """Validate the input by the user.""" - SIAAccount(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) + SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) try: ping = int(data[CONF_PING_INTERVAL]) @@ -64,8 +63,6 @@ def validate_input(data: dict) -> bool: except AssertionError: raise InvalidZones - return True - class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" @@ -79,12 +76,10 @@ async def async_step_add_account(self, user_input: dict = None): errors = {} if user_input is not None: try: - if validate_input(user_input): - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) - if user_input[CONF_ADDITIONAL_ACCOUNTS]: - return await self.async_step_add_account() + validate_input(user_input) + self.update_data(user_input) + if user_input[CONF_ADDITIONAL_ACCOUNTS]: + return await self.async_step_add_account() except InvalidKeyFormatError: errors["base"] = "invalid_key_format" except InvalidKeyLengthError: @@ -99,7 +94,9 @@ async def async_step_add_account(self, user_input: dict = None): errors["base"] = "invalid_zones" return self.async_show_form( - step_id="user", data_schema=ACCOUNT_SCHEMA, errors=errors, + step_id="user", + data_schema=ACCOUNT_SCHEMA, + errors=errors, ) async def async_step_user(self, user_input: dict = None): @@ -107,34 +104,16 @@ async def async_step_user(self, user_input: dict = None): errors = {} if user_input is not None: try: - if validate_input(user_input): - if not self.data: - self.data = { - CONF_PORT: user_input[CONF_PORT], - CONF_ACCOUNTS: [ - { - CONF_ACCOUNT: user_input[CONF_ACCOUNT], - CONF_ENCRYPTION_KEY: user_input.get( - CONF_ENCRYPTION_KEY - ), - CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], - CONF_ZONES: user_input[CONF_ZONES], - } - ], - } - else: - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) - await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") - self._abort_if_unique_id_configured() - - if not user_input[CONF_ADDITIONAL_ACCOUNTS]: - return self.async_create_entry( - title=f"SIA Alarm on port {self.data[CONF_PORT]}", - data=self.data, - ) - return await self.async_step_add_account() + validate_input(user_input) + await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") + self._abort_if_unique_id_configured() + self.update_data(user_input) + if not user_input[CONF_ADDITIONAL_ACCOUNTS]: + return self.async_create_entry( + title=f"SIA Alarm on port {self.data[CONF_PORT]}", + data=self.data, + ) + return await self.async_step_add_account() except InvalidKeyFormatError: errors["base"] = "invalid_key_format" except InvalidKeyLengthError: @@ -157,6 +136,25 @@ async def async_step_user(self, user_input: dict = None): step_id="user", data_schema=HUB_SCHEMA, errors=errors ) + def update_data(self, user_input): + """Parse the user_input and store in self.data.""" + if not self.data: + self.data = { + CONF_PORT: user_input[CONF_PORT], + CONF_ACCOUNTS: [ + { + CONF_ACCOUNT: user_input[CONF_ACCOUNT], + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), + CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], + CONF_ZONES: user_input[CONF_ZONES], + } + ], + } + else: + add_data = user_input.copy() + add_data.pop(CONF_ADDITIONAL_ACCOUNTS) + self.data[CONF_ACCOUNTS].append(add_data) + class InvalidPing(exceptions.HomeAssistantError): """Error to indicate there is invalid ping interval.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 5c574b7..531afc0 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,9 +1,6 @@ """Constants for the sia integration.""" from datetime import timedelta -ATTR_LAST_MESSAGE = "last_message" -ATTR_LAST_CODE = "last_code" -ATTR_LAST_TIMESTAMP = "last_timestamp" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" @@ -19,10 +16,9 @@ HUB_ZONE = 0 PING_INTERVAL_MARGIN = timedelta(seconds=30) -EVENT_CODE = "code" -EVENT_ACCOUNT = "account" +EVENT_CODE = "last_code" +EVENT_ACCOUNT = "last_account" EVENT_ZONE = "zone" -EVENT_PORT = "port" -EVENT_MESSAGE = "message" -EVENT_ID = "id" -EVENT_TIMESTAMP = "timestamp" +EVENT_MESSAGE = "last_message" +EVENT_ID = "last_id" +EVENT_TIMESTAMP = "last_timestamp" diff --git a/custom_components/sia/helpers.py b/custom_components/sia/helpers.py index d9aa371..5c78988 100644 --- a/custom_components/sia/helpers.py +++ b/custom_components/sia/helpers.py @@ -1,8 +1,19 @@ -from typing import Tuple from datetime import timedelta +from typing import Tuple from homeassistant.const import DEVICE_CLASS_TIMESTAMP -from .const import HUB_SENSOR_NAME, HUB_ZONE +from pysiaalarm import SIAEvent + +from .const import ( + HUB_SENSOR_NAME, + HUB_ZONE, + EVENT_ACCOUNT, + EVENT_CODE, + EVENT_ID, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, +) def GET_ENTITY_AND_NAME( @@ -41,3 +52,17 @@ def GET_ENTITY_ID( if entity_type: return f"{port}_{account}_{zone}_{entity_type}" return None + + +def SIA_EVENT_TO_ATTR(event: SIAEvent) -> dict: + """Create the attributes dict from a SIAEvent.""" + return ( + { + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } + ) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index be62073..1568460 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,26 +1,15 @@ """The sia hub.""" -import asyncio -from datetime import timedelta -from typing import Tuple import logging -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent - -from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.helpers import device_registry as dr +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, - EVENT_CODE, - EVENT_ACCOUNT, - EVENT_ZONE, - EVENT_PORT, - EVENT_MESSAGE, - EVENT_ID, - EVENT_TIMESTAMP, DOMAIN, SIA_EVENT, ) @@ -54,6 +43,7 @@ def __init__( async def async_setup_hub(self): """Add a device to the device_registry, register shutdown listener, load reactions.""" + _LOGGER.debug("Setting up SIA Hub.") device_registry = await dr.async_get_registry(self._hass) port = self._port for acc in self._accounts: @@ -75,16 +65,13 @@ async def async_shutdown(self, _: Event = None): async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" + # Get rid of account, because it might contain encryption key. + event.sia_account = None + # Change the message_type to value because otherwise it is not JSON serializable. + event.message_type = event.message_type.value + # Fire event! self._hass.bus.async_fire( event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data={ - EVENT_PORT: self._port, - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - }, + event_data=event.to_dict(), origin=EventOrigin.remote, ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index b5f0f25..cb5e29f 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-5" + "pysiaalarm==3.0.0b3" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.4.0b2" + "version": "0.5.0b1" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 7bb54e6..23186ba 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,5 +1,4 @@ """Module for SIA Sensors.""" - import datetime as dt import logging from typing import Callable @@ -7,36 +6,31 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP -from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow +from pysiaalarm import SIAEvent from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, DATA_UPDATED, - EVENT_CODE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, + DOMAIN, HUB_ZONE, SIA_EVENT, - DOMAIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up sia_sensor from a config entry.""" - async_add_devices( + async_add_entities( [ SIASensor( *GET_ENTITY_AND_NAME( @@ -83,55 +77,39 @@ def __init__( self._unsub = None self._state = utcnow() - self._should_poll = False self._attr = { CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Once the sensor is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") - else: - return - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the event listener.""" if self._unsub: self._unsub() - @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - async def async_handle_event(self, event: Event): """Listen to events for this port and account and update the state and attributes.""" - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) - if event.data[EVENT_CODE] == "RP": + sia_event = SIAEvent.from_dict(event.data) + sia_event.message_type = sia_event.message_type.value + self._attr.update(sia_event.to_dict()) + if sia_event.code == "RP": self.state = utcnow() - if not self.registry_entry.disabled: + if self.enabled: self.async_schedule_update_ha_state() @property diff --git a/hacs.json b/hacs.json index 4117330..159968d 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "SIA", - "domains": ["binary_sensor"] + "domains": ["binary_sensor", "alarm_control_panel", "sensor"] } \ No newline at end of file From ead83a54c7ce81cf3fe94ad80f1f15da050f0892 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 12 Apr 2021 14:19:53 +0000 Subject: [PATCH 13/25] new package and in sync with PR --- custom_components/sia/__init__.py | 12 ++++++-- custom_components/sia/alarm_control_panel.py | 23 +++++++++----- custom_components/sia/binary_sensor.py | 27 ++++++++++------- custom_components/sia/hub.py | 7 +---- custom_components/sia/manifest.json | 4 +-- custom_components/sia/sensor.py | 16 ++++++---- .../sia/{helpers.py => utils.py} | 30 +++++++++---------- 7 files changed, 69 insertions(+), 50 deletions(-) rename custom_components/sia/{helpers.py => utils.py} (72%) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index e491af9..94c861c 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -7,7 +7,9 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .hub import SIAHub @@ -24,16 +26,20 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up sia from a config entry.""" hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) - await hub.async_setup_hub() hass.data[DOMAIN][entry.entry_id] = hub + try: + await hub.sia_client.start(reuse_port=True) + except OSError: + raise ConfigEntryNotReady( + "SIA Server at port %s could not start.", entry.data[CONF_PORT] + ) + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - - await hub.sia_client.start(reuse_port=True) return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 2b5344d..ca732de 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -41,7 +41,7 @@ DOMAIN, PING_INTERVAL_MARGIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ async def async_setup_entry( async_add_entities( [ SIAAlarmControlPanel( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM ), entry.data[CONF_PORT], @@ -115,7 +115,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -159,11 +159,18 @@ async def async_added_to_hass(self): self.state = state.state else: self.state = None - await self._async_track_unavailable() async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_alarm() + + def setup_sia_alarm(self): + """Run the setup of the alarm control panel.""" + self.assume_available() + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -186,7 +193,7 @@ async def async_handle_event(self, event: Event): new_state = CODE_CONSEQUENCES.get(sia_event.code) if not new_state: return - self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self._attr.update(sia_event_to_attr(sia_event)) self.state = new_state if self.enabled: self.async_schedule_update_ha_state() @@ -238,13 +245,13 @@ def should_poll(self) -> bool: """Return False if entity pushes its state to HA.""" return False - async def assume_available(self): + def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: - await self._async_track_unavailable() + self._async_track_unavailable() @callback - async def _async_track_unavailable(self) -> bool: + def _async_track_unavailable(self) -> bool: """Reset unavailability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 80d5546..642ad9f 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -38,7 +38,7 @@ PING_INTERVAL_MARGIN, SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ async def async_setup_entry( devices = [ SIABinarySensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class ), entry.data[CONF_PORT], @@ -83,7 +83,7 @@ async def async_setup_entry( devices.extend( [ SIABinarySensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], HUB_ZONE, @@ -123,7 +123,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -151,11 +151,18 @@ async def async_added_to_hass(self): self._is_on = True elif state.state == STATE_OFF: self._is_on = False - await self._async_track_unavailable() async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_entity() + + def setup_sia_entity(self): + """Run the setup of the alarm control panel.""" + self.assume_available() + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -171,7 +178,7 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ - await self.assume_available() + self.assume_available() sia_event = SIAEvent.from_dict(event.data) sia_event.message_type = sia_event.message_type.value if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: @@ -179,7 +186,7 @@ async def async_handle_event(self, event: Event): sia_event.code, (None, None) ) if new_state is not None and device_class == self._device_class: - self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self._attr.update(sia_event_to_attr(sia_event)) self.state = new_state if self.enabled: self.async_schedule_update_ha_state() @@ -241,13 +248,13 @@ def state(self, new_on: bool): """Set state.""" self._is_on = new_on - async def assume_available(self): + def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: - await self._async_track_unavailable() + self._async_track_unavailable() @callback - async def _async_track_unavailable(self) -> bool: + def _async_track_unavailable(self) -> bool: """Track availability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 1568460..25592f7 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -65,13 +65,8 @@ async def async_shutdown(self, _: Event = None): async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" - # Get rid of account, because it might contain encryption key. - event.sia_account = None - # Change the message_type to value because otherwise it is not JSON serializable. - event.message_type = event.message_type.value - # Fire event! self._hass.bus.async_fire( event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data=event.to_dict(), + event_data=event.to_dict(encode_json=True), origin=EventOrigin.remote, ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index cb5e29f..4102100 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b3" + "pysiaalarm==3.0.0b4" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b1" + "version": "0.5.0b2" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 23186ba..6bfab53 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -21,7 +21,7 @@ HUB_ZONE, SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities( [ SIASensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], HUB_ZONE, @@ -72,7 +72,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -94,6 +94,13 @@ async def async_added_to_hass(self) -> None: self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_entity() + + def setup_sia_entity(self): + """Run the setup of the sensor.""" + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -105,8 +112,7 @@ def _async_sia_on_remove(self): async def async_handle_event(self, event: Event): """Listen to events for this port and account and update the state and attributes.""" sia_event = SIAEvent.from_dict(event.data) - sia_event.message_type = sia_event.message_type.value - self._attr.update(sia_event.to_dict()) + self._attr.update(event.data) if sia_event.code == "RP": self.state = utcnow() if self.enabled: diff --git a/custom_components/sia/helpers.py b/custom_components/sia/utils.py similarity index 72% rename from custom_components/sia/helpers.py rename to custom_components/sia/utils.py index 5c78988..ca51e02 100644 --- a/custom_components/sia/helpers.py +++ b/custom_components/sia/utils.py @@ -16,7 +16,7 @@ ) -def GET_ENTITY_AND_NAME( +def get_entity_and_name( port: int, account: str, zone: int = 0, entity_type: str = None ) -> Tuple[str, str]: """Give back a entity_id and name according to the variables.""" @@ -25,23 +25,23 @@ def GET_ENTITY_AND_NAME( "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" ) return ( - GET_ENTITY_ID(port, account, zone, entity_type), + get_entity_id(port, account, zone, entity_type), f"{port} - {account} - {entity_type_name}", ) if entity_type: return ( - GET_ENTITY_ID(port, account, zone, entity_type), + get_entity_id(port, account, zone, entity_type), f"{port} - {account} - zone {zone} - {entity_type}", ) return None -def GET_PING_INTERVAL(ping: int) -> timedelta: +def get_ping_interval(ping: int) -> timedelta: """Return the ping interval as timedelta.""" return timedelta(minutes=ping) -def GET_ENTITY_ID( +def get_entity_id( port: int, account: str, zone: int = 0, entity_type: str = None ) -> str: """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" @@ -54,15 +54,13 @@ def GET_ENTITY_ID( return None -def SIA_EVENT_TO_ATTR(event: SIAEvent) -> dict: +def sia_event_to_attr(event: SIAEvent) -> dict: """Create the attributes dict from a SIAEvent.""" - return ( - { - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - } - ) + return { + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } From 34db4a91032585833f1964a7283baa3d4fbe2636 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 07:03:01 +0000 Subject: [PATCH 14/25] fixed await assume_available --- custom_components/sia/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index ca732de..72bc612 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -186,7 +186,7 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ - await self.assume_available() + self.assume_available() sia_event = SIAEvent.from_dict(event.data) if int(sia_event.ri) != self._zone: return From 8c5043364ac2a3778cd62a1560884a0d3a112db2 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 07:04:08 +0000 Subject: [PATCH 15/25] incremented version --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 4102100..d69c4ae 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,5 +9,5 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b2" + "version": "0.5.0b3" } From f2fe4d29d7f8ca9db5a5e872270e027eaf13c9c4 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 14:09:06 +0000 Subject: [PATCH 16/25] new package and integration version incremented --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index d69c4ae..0c9eb8d 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b4" + "pysiaalarm==3.0.0b5" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b3" + "version": "0.5.0b4" } From bbe80eccbdc78869d3c87609b3b7d131410e39ad Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 15 Apr 2021 07:51:52 +0000 Subject: [PATCH 17/25] new package version --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 0c9eb8d..8285981 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b5" + "pysiaalarm==3.0.0b6" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b4" + "version": "0.5.0b5" } From 5845a53e0b97979721d4fb6bb243dab4eaada209 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sat, 8 May 2021 11:34:47 +0000 Subject: [PATCH 18/25] new version of config_entry with migration and fix for #45 --- custom_components/sia/__init__.py | 19 ++- custom_components/sia/config_flow.py | 137 ++++++++++----------- custom_components/sia/const.py | 1 + custom_components/sia/manifest.json | 5 +- custom_components/sia/strings.json | 26 ++-- custom_components/sia/translations/en.json | 12 ++ 6 files changed, 119 insertions(+), 81 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 94c861c..1b28334 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,5 +1,6 @@ """The sia integration.""" import asyncio +import logging from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -7,7 +8,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -16,6 +17,8 @@ PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: dict): """Set up the sia component.""" @@ -57,3 +60,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) await hub.async_shutdown() return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + data = config_entry.data.copy() + data[CONF_PROTOCOL] = "TCP" + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=data) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + return True diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index d9cd870..442c324 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,10 +1,6 @@ """Config flow for sia integration.""" import logging -import voluptuous as vol -from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_PORT -from homeassistant.data_entry_flow import AbortFlow from pysiaalarm import ( InvalidAccountFormatError, InvalidAccountLengthError, @@ -12,12 +8,18 @@ InvalidKeyLengthError, SIAAccount, ) +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.data_entry_flow import AbortFlow from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ADDITIONAL_ACCOUNTS, CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, CONF_PING_INTERVAL, CONF_ZONES, DOMAIN, @@ -29,10 +31,12 @@ HUB_SCHEMA = vol.Schema( { vol.Required(CONF_PORT): int, + vol.Optional(CONF_PROTOCOL, default="TCP"): vol.In(["TCP", "UDP"]), vol.Required(CONF_ACCOUNT): str, vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, + vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -43,6 +47,7 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, + vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -55,105 +60,95 @@ def validate_input(data: dict): try: ping = int(data[CONF_PING_INTERVAL]) assert 1 <= ping <= 1440 - except AssertionError: - raise InvalidPing + except AssertionError as invalid_ping: + raise InvalidPing from invalid_ping try: zones = int(data[CONF_ZONES]) assert zones > 0 - except AssertionError: - raise InvalidZones + except AssertionError as invalid_zone: + raise InvalidZones from invalid_zone class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - data = None + VERSION = 2 + + def __init__(self): + """Initialize the config flow.""" + self._data = {} async def async_step_add_account(self, user_input: dict = None): """Handle the additional accounts steps.""" - errors = {} - if user_input is not None: - try: - validate_input(user_input) - self.update_data(user_input) - if user_input[CONF_ADDITIONAL_ACCOUNTS]: - return await self.async_step_add_account() - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - - return self.async_show_form( - step_id="user", - data_schema=ACCOUNT_SCHEMA, - errors=errors, - ) + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=ACCOUNT_SCHEMA, + errors={}, + ) async def async_step_user(self, user_input: dict = None): """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=HUB_SCHEMA, errors={} + ) errors = {} - if user_input is not None: - try: - validate_input(user_input) - await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") - self._abort_if_unique_id_configured() - self.update_data(user_input) - if not user_input[CONF_ADDITIONAL_ACCOUNTS]: - return self.async_create_entry( - title=f"SIA Alarm on port {self.data[CONF_PORT]}", - data=self.data, - ) - return await self.async_step_add_account() - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - except AbortFlow: - return self.async_abort(reason="already_configured") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors=errors + try: + validate_input(user_input) + except InvalidKeyFormatError: + errors["base"] = "invalid_key_format" + except InvalidKeyLengthError: + errors["base"] = "invalid_key_length" + except InvalidAccountFormatError: + errors["base"] = "invalid_account_format" + except InvalidAccountLengthError: + errors["base"] = "invalid_account_length" + except InvalidPing: + errors["base"] = "invalid_ping" + except InvalidZones: + errors["base"] = "invalid_zones" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + if errors: + return self.async_show_form( + step_id="user", data_schema=HUB_SCHEMA, errors=errors + ) + self.update_data(user_input) + await self.async_set_unique_id(f"{DOMAIN}_{self._data[CONF_PORT]}") + try: + self._abort_if_unique_id_configured() + except AbortFlow: + return self.async_abort(reason="already_configured") + + if user_input[CONF_ADDITIONAL_ACCOUNTS]: + return await self.async_step_add_account() + return self.async_create_entry( + title=f"SIA Alarm on port {self._data[CONF_PORT]}", + data=self._data, ) def update_data(self, user_input): """Parse the user_input and store in self.data.""" - if not self.data: - self.data = { + if not self._data: + self._data = { CONF_PORT: user_input[CONF_PORT], + CONF_PROTOCOL: user_input[CONF_PROTOCOL], CONF_ACCOUNTS: [ { CONF_ACCOUNT: user_input[CONF_ACCOUNT], CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], CONF_ZONES: user_input[CONF_ZONES], + CONF_IGNORE_TIMESTAMPS: user_input[CONF_IGNORE_TIMESTAMPS], } ], } else: add_data = user_input.copy() add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) + self._data[CONF_ACCOUNTS].append(add_data) class InvalidPing(exceptions.HomeAssistantError): diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 531afc0..512fb5a 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -8,6 +8,7 @@ CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" CONF_ZONES = "zones" +CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" DOMAIN = "sia" DATA_UPDATED = f"{DOMAIN}_data_updated" diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 8285981..8268c82 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b6" + "pysiaalarm==3.0.0b12" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b5" + "version": "0.5.0b6", + "iot_class": "local_push" } diff --git a/custom_components/sia/strings.json b/custom_components/sia/strings.json index 1eed82a..1d7aeba 100644 --- a/custom_components/sia/strings.json +++ b/custom_components/sia/strings.json @@ -4,15 +4,27 @@ "step": { "user": { "data": { - "name": "Name", - "port": "Port", - "account": "Account", + "port": "[%key:common::config_flow::data::port%]", + "protocol": "Protocol", + "account": "Account ID", "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", - "additional_account": "Add more accounts?" + "ignore_timestamps": "Ignore the timestamp check", + "additional_account": "Additional accounts" }, - "title": "Create a connection for SIA DC-09 based alarm systems." + "title": "Create a connection for SIA based alarm systems." + }, + "additional_account": { + "data": { + "account": "[%key:component::sia::config::step::user::data::account%]", + "encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]", + "ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]", + "zones": "[%key:component::sia::config::step::user::data::zones%]", + "ignore_timestamps": "[%key:component::sia::config::step::user::data::ignore_timestamps%]", + "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" + }, + "title": "Add another account to the current port." } }, "error": { @@ -22,10 +34,10 @@ "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", "invalid_zones": "There needs to be at least 1 zone.", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } diff --git a/custom_components/sia/translations/en.json b/custom_components/sia/translations/en.json index 9cc5202..c46bc9e 100644 --- a/custom_components/sia/translations/en.json +++ b/custom_components/sia/translations/en.json @@ -21,9 +21,21 @@ "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", + "ignore_timestamps": "Ignore the timestamp check", "additional_account": "Add more accounts?" }, "title": "Create a connection for SIA DC-09 based alarm systems." + }, + "additional_account": { + "data": { + "account": "Account", + "encryption_key": "Encryption Key", + "ping_interval": "Ping Interval (min)", + "zones": "Number of zones for the account", + "ignore_timestamps": "Ignore the timestamp check", + "additional_account": "Add more accounts?" + }, + "title": "Add another account to the current port." } } }, From dc7f65e4a43735a77be3fc5f051a5ea048cb4f24 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:21:13 +0000 Subject: [PATCH 19/25] complete implementation of timestamp and protocol --- custom_components/sia/config_flow.py | 2 +- custom_components/sia/const.py | 4 ++++ custom_components/sia/hub.py | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 442c324..9adf167 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -131,7 +131,7 @@ async def async_step_user(self, user_input: dict = None): def update_data(self, user_input): """Parse the user_input and store in self.data.""" - if not self._data: + if not self._data or user_input.get(CONF_PORT): self._data = { CONF_PORT: user_input[CONF_PORT], CONF_PROTOCOL: user_input[CONF_PROTOCOL], diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 512fb5a..6e32d5b 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -23,3 +23,7 @@ EVENT_MESSAGE = "last_message" EVENT_ID = "last_id" EVENT_TIMESTAMP = "last_timestamp" + + +DEFAULT_TIMEBAND = (80, 40) +IGNORED_TIMEBAND = (3600, 1800) \ No newline at end of file diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 25592f7..8376abe 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,20 +1,22 @@ """The sia hub.""" import logging -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.helpers import device_registry as dr -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent, CommunicationsProtocol from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, DOMAIN, SIA_EVENT, + IGNORED_TIMEBAND, + DEFAULT_TIMEBAND, ) -ALLOWED_TIMEBAND = (300, 150) _LOGGER = logging.getLogger(__name__) @@ -31,14 +33,19 @@ def __init__( self.entry_id = entry_id self._title = title self._accounts = hub_config[CONF_ACCOUNTS] + self._protocol = hub_config[CONF_PROTOCOL] self._remove_shutdown_listener = None self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), ALLOWED_TIMEBAND) + SIAAccount( + a[CONF_ACCOUNT], + a.get(CONF_ENCRYPTION_KEY), + IGNORED_TIMEBAND if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, + ) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.async_create_and_fire_event + "", self._port, self.sia_accounts, self.async_create_and_fire_event, CommunicationsProtocol(self._protocol) ) async def async_setup_hub(self): From 5a8af955be560227811a50e0119e223259fc8cdf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:33:52 +0000 Subject: [PATCH 20/25] auto assign issues setup --- .github/auto_assign-issues.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/auto_assign-issues.yml diff --git a/.github/auto_assign-issues.yml b/.github/auto_assign-issues.yml new file mode 100644 index 0000000..34d6a88 --- /dev/null +++ b/.github/auto_assign-issues.yml @@ -0,0 +1,8 @@ +# If enabled, auto-assigns users when a new issue is created +# Defaults to true, allows you to install the app globally, and disable on a per-repo basis +addAssignees: true + +# The list of users to assign to new issues. +# If empty or not provided, the repository owner is assigned +assignees: + - eavanvalkenburg \ No newline at end of file From 0d68b63b05aea6db4f520586cd89f8fb229b6123 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:57:46 +0000 Subject: [PATCH 21/25] updated version in manifest --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 8268c82..27b4df9 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,6 +9,6 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b6", + "version": "0.5.0b8", "iot_class": "local_push" } From eb0fa8faa30c726ca3e98cfa6812658441d429ac Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 11 May 2021 06:29:43 +0000 Subject: [PATCH 22/25] fixed keyerror for timestamps --- custom_components/sia/__init__.py | 5 +++-- custom_components/sia/hub.py | 10 ++++++++-- custom_components/sia/manifest.json | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 1b28334..8e8a0bc 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN +from .const import DOMAIN, CONF_IGNORE_TIMESTAMPS from .hub import SIAHub PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] @@ -66,9 +66,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version == 1: + if config_entry.version < 2: data = config_entry.data.copy() data[CONF_PROTOCOL] = "TCP" + data[CONF_IGNORE_TIMESTAMPS] = False config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 8376abe..a4c93f2 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -40,12 +40,18 @@ def __init__( SIAAccount( a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), - IGNORED_TIMEBAND if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, + IGNORED_TIMEBAND + if a.get(CONF_IGNORE_TIMESTAMPS, False) + else DEFAULT_TIMEBAND, ) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.async_create_and_fire_event, CommunicationsProtocol(self._protocol) + "", + self._port, + self.sia_accounts, + self.async_create_and_fire_event, + CommunicationsProtocol(self._protocol), ) async def async_setup_hub(self): diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 27b4df9..fb8f87d 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,6 +9,6 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b8", + "version": "0.5.0b9", "iot_class": "local_push" } From 3b8f9072ddb09b5a23e149a526c3c92341611519 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 6 Aug 2021 16:20:15 +0200 Subject: [PATCH 23/25] callout for official itnegration --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8c3101..6a3f882 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ -[![hacs][hacsbadge]](hacs) + +## OFFICIAL INTEGRATION IS NOW IN HA! + +Make sure to delete the current integraiton, in your Integrations page, then delete the HACS custom component, reboot and then input your config in the official +integration config. There are some settings, most importantly ignoring timestamps, in a options flow (press configure after installing the integration). + + +## OFFICIAL INTEGRATION IS NOW IN HA! +---------------- + _Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ -_Latest beta will be suggested for inclusion as a official integration._ **This component will set up the following platforms.** From ab4af27dd2674b0d9237f18af2b3576802bba7cf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 20 Oct 2021 15:15:11 +0000 Subject: [PATCH 24/25] getting in sync with official --- .devcontainer/devcontainer.json | 2 +- README.md | 16 +- custom_components/sia/__init__.py | 65 +--- custom_components/sia/alarm_control_panel.py | 274 +++----------- custom_components/sia/binary_sensor.py | 358 ++++++------------- custom_components/sia/config_flow.py | 228 ++++++++---- custom_components/sia/const.py | 43 ++- custom_components/sia/hub.py | 158 +++++--- custom_components/sia/manifest.json | 10 +- custom_components/sia/sensor.py | 211 ++++------- custom_components/sia/sia_entity_base.py | 132 +++++++ custom_components/sia/strings.json | 20 +- custom_components/sia/utils.py | 114 +++--- 13 files changed, 763 insertions(+), 868 deletions(-) create mode 100644 custom_components/sia/sia_entity_base.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f7329d..2418999 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration-debian", + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", "context": "..", "appPort": [ "9123:8123" diff --git a/README.md b/README.md index 1f3a649..5417948 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,14 @@ _Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ -_Latest beta will be suggested for inclusion as a official integration._ +This is the new stream which will be kept in sync with the [official integration][official]. + +In order to switch from an earlier version of the custom component to do this do not install this version over an existing version of the custom component, delete your integration in the integrations page, then update and reenter your config in the integrations page. + +If you are running the official integration, then you can install this one and things will work. + +The config used by earlier versions (before 1.0) is different and will cause errors, it is in sync with the official integration though! + **This component will set up the following platforms.** @@ -25,8 +32,8 @@ Platform | Description ## Hub Setup (Ajax Systems Hub example) -1. Select "SIA Protocol". -2. Enable "Connect on demand". +1. Select "SIA Protocol". +2. Enable "Connect on demand". 3. Place Account Id - 3-16 ASCII hex characters. For example AAA. 4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. 5. Insert Home Assistant listening port. This port must not be used with anything else. @@ -64,7 +71,7 @@ To turn on debugging go into your `configuration.yaml` and add these lines: logger: default: error logs: - custom_components.sia: debug + custom_components.sia: debug pysiaalarm: debug ``` @@ -72,3 +79,4 @@ logger: [ch_sia]: https://github.com/Cheaterdev/sia-ha [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[official]: https://www.home-assistant.io/integrations/sia/ diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 8e8a0bc..9bca9a5 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,77 +1,34 @@ """The sia integration.""" -import asyncio -import logging - -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_IGNORE_TIMESTAMPS +from .const import DOMAIN, PLATFORMS from .hub import SIAHub -PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: dict): - """Set up the sia component.""" - hass.data.setdefault(DOMAIN, {}) - return True - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up sia from a config entry.""" - hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) + hub: SIAHub = SIAHub(hass, entry) await hub.async_setup_hub() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hub try: await hub.sia_client.start(reuse_port=True) - except OSError: + except OSError as exc: raise ConfigEntryNotReady( - "SIA Server at port %s could not start.", entry.data[CONF_PORT] - ) - - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) + f"SIA Server at port {entry.data[CONF_PORT]} could not start." + ) from exc + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] - ) - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) await hub.async_shutdown() return unload_ok - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): - """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - - if config_entry.version < 2: - data = config_entry.data.copy() - data[CONF_PROTOCOL] = "TCP" - data[CONF_IGNORE_TIMESTAMPS] = False - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=data) - - _LOGGER.info("Migration to version %s successful", config_entry.version) - return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 72bc612..5a6a4f6 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,59 +1,40 @@ """Module for SIA Alarm Control Panels.""" +from __future__ import annotations import logging -from typing import Callable +from typing import Any + from pysiaalarm import SIAEvent -from homeassistant.components.alarm_control_panel import ( - ENTITY_ID_FORMAT as ALARM_FORMAT, - AlarmControlPanelEntity, -) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_PORT, - CONF_ZONE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, - STATE_UNKNOWN, + STATE_UNAVAILABLE, ) -from homeassistant.core import callback, Event, HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.dt import utcnow +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import ( - CONF_ACCOUNT, - CONF_ACCOUNTS, - CONF_PING_INTERVAL, - CONF_ZONES, - DATA_UPDATED, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, - SIA_EVENT, - DOMAIN, - PING_INTERVAL_MARGIN, -) -from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr +from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, SIA_UNIQUE_ID_FORMAT_ALARM +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_ALARM = "alarm" PREVIOUS_STATE = "previous_state" -CODE_CONSEQUENCES = { - "BA": STATE_ALARM_TRIGGERED, +CODE_CONSEQUENCES: dict[str, StateType] = { "PA": STATE_ALARM_TRIGGERED, "JA": STATE_ALARM_TRIGGERED, "TA": STATE_ALARM_TRIGGERED, + "BA": STATE_ALARM_TRIGGERED, "CA": STATE_ALARM_ARMED_AWAY, + "CB": STATE_ALARM_ARMED_AWAY, "CG": STATE_ALARM_ARMED_AWAY, "CL": STATE_ALARM_ARMED_AWAY, "CP": STATE_ALARM_ARMED_AWAY, @@ -61,6 +42,7 @@ "CS": STATE_ALARM_ARMED_AWAY, "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, "OA": STATE_ALARM_DISARMED, + "OB": STATE_ALARM_DISARMED, "OG": STATE_ALARM_DISARMED, "OP": STATE_ALARM_DISARMED, "OQ": STATE_ALARM_DISARMED, @@ -75,214 +57,56 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_entities( - [ - SIAAlarmControlPanel( - *get_entity_and_name( - entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - zone, - acc[CONF_PING_INTERVAL], - ) - for acc in entry.data[CONF_ACCOUNTS] - for zone in range(1, acc[CONF_ZONES] + 1) - ] + SIAAlarmControlPanel(entry, account_data, zone) + for account_data in entry.data[CONF_ACCOUNTS] + for zone in range( + 1, + entry.options[CONF_ACCOUNTS][account_data[CONF_ACCOUNT]][CONF_ZONES] + 1, + ) ) - return True -class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): +class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): """Class for SIA Alarm Control Panels.""" def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, + entry: ConfigEntry, + account_data: dict[str, Any], zone: int, - ping_interval: int, - ): + ) -> None: """Create SIAAlarmControlPanel object.""" - self.entity_id = ALARM_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._is_available = True - self._remove_unavailability_tracker = None - self._state = None - self._old_state = None - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - EVENT_ACCOUNT: None, - EVENT_CODE: None, - EVENT_ID: None, - EVENT_ZONE: None, - EVENT_MESSAGE: None, - EVENT_TIMESTAMP: None, - } - - async def async_added_to_hass(self): - """Once the panel is added, see if it was there before and pull in that state.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - _LOGGER.debug( - "Loading last state: %s", - state.state if state is not None and state.state is not None else "None", - ) - if ( - state is not None - and state.state is not None - and state.state - in [ - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, - STATE_UNKNOWN, - ] - ): - self.state = state.state - else: - self.state = None - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event - ) - self.setup_sia_alarm() + super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) + self._attr_state: StateType = None + self._old_state: StateType = None - def setup_sia_alarm(self): - """Run the setup of the alarm control panel.""" - self.assume_available() - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( + self._entry.entry_id, self._account, self._zone ) - self.async_on_remove(self._async_sia_on_remove) - - @callback - def _async_sia_on_remove(self): - """Remove the unavailability and event listener.""" - if self._unsub: - self._unsub() - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - - async def async_handle_event(self, event: Event): - """Listen to events for this port and account and update states. - If the port and account combo receives any message it means it is online and can therefore be set to available. - """ - self.assume_available() - sia_event = SIAEvent.from_dict(event.data) - if int(sia_event.ri) != self._zone: - return - new_state = CODE_CONSEQUENCES.get(sia_event.code) - if not new_state: - return - self._attr.update(sia_event_to_attr(sia_event)) - self.state = new_state - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Get Name.""" - return self._name - - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - - @property - def state(self) -> str: - """Get state.""" - return self._state - - @state.setter - def state(self, state: str): - """Set state.""" - temp = self._old_state if state == PREVIOUS_STATE else state - self._old_state = self._state - self._state = temp - - @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def unique_id(self) -> str: - """Get unique_id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Get availability.""" - return self._is_available - - @property - def device_state_attributes(self) -> dict: - """Return device attributes.""" - return self._attr - - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False - - def assume_available(self): - """Reset unavalability tracker.""" - if not self.registry_entry.disabled: - self._async_track_unavailable() - - @callback - def _async_track_unavailable(self) -> bool: - """Reset unavailability.""" - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, - self._async_set_unavailable, - utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, - ) - if not self._is_available: - self._is_available = True - self.async_schedule_update_ha_state() - return True - return False - - @callback - def _async_set_unavailable(self, _): - """Set availability.""" - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the alarm control panel.""" + new_state = CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + if new_state == PREVIOUS_STATE: + new_state = self._old_state + self._attr_state, self._old_state = new_state, self._attr_state + + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None: + self._attr_state = last_state.state + if self.state == STATE_UNAVAILABLE: + self._attr_available = False @property def supported_features(self) -> int: """Return the list of supported features.""" - return None - - @property - def device_info(self) -> dict: - """Return the device_info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), - } + return 0 diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 642ad9f..eec4f9b 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,285 +1,163 @@ """Module for SIA Binary Sensors.""" +from __future__ import annotations +from collections.abc import Iterable import logging -from typing import Callable +from typing import Any + +from pysiaalarm import SIAEvent from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_POWER, DEVICE_CLASS_SMOKE, + BinarySensorEntity, ) -from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, -) -from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.dt import utcnow -from pysiaalarm import SIAEvent +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, - CONF_PING_INTERVAL, CONF_ZONES, - DATA_UPDATED, - DOMAIN, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, - HUB_ZONE, - PING_INTERVAL_MARGIN, - SIA_EVENT, + SIA_HUB_ZONE, + SIA_UNIQUE_ID_FORMAT_BINARY, ) -from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) -ZONE_DEVICES = [ - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, -] -CODE_CONSEQUENCES = { - "AT": (DEVICE_CLASS_POWER, False), - "AR": (DEVICE_CLASS_POWER, True), - "GA": (DEVICE_CLASS_SMOKE, True), - "GH": (DEVICE_CLASS_SMOKE, False), - "FA": (DEVICE_CLASS_SMOKE, True), - "FH": (DEVICE_CLASS_SMOKE, False), - "KA": (DEVICE_CLASS_SMOKE, True), - "KH": (DEVICE_CLASS_SMOKE, False), - "WA": (DEVICE_CLASS_MOISTURE, True), - "WH": (DEVICE_CLASS_MOISTURE, False), + +POWER_CODE_CONSEQUENCES: dict[str, bool] = { + "AT": False, + "AR": True, +} + +SMOKE_CODE_CONSEQUENCES: dict[str, bool] = { + "GA": True, + "GH": False, + "FA": True, + "FH": False, + "KA": True, + "KH": False, } +MOISTURE_CODE_CONSEQUENCES: dict[str, bool] = { + "WA": True, + "WH": False, +} -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: - """Set up sia_binary_sensor from a config entry.""" - devices = [ - SIABinarySensor( - *get_entity_and_name( - entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - zone, - acc[CONF_PING_INTERVAL], - device_class, - ) - for acc in entry.data[CONF_ACCOUNTS] - for zone in range(1, acc[CONF_ZONES] + 1) - for device_class in ZONE_DEVICES - ] - devices.extend( - [ - SIABinarySensor( - *get_entity_and_name( - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - DEVICE_CLASS_POWER, - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - acc[CONF_PING_INTERVAL], - DEVICE_CLASS_POWER, - ) - for acc in entry.data[CONF_ACCOUNTS] - ] - ) - async_add_entities(devices) - return True +def generate_binary_sensors(entry) -> Iterable[SIABinarySensorBase]: + """Generate binary sensors. + + For each Account there is one power sensor with zone == 0. + For each Zone in each Account there is one smoke and one moisture sensor. + """ + for account in entry.data[CONF_ACCOUNTS]: + yield SIABinarySensorPower(entry, account) + zones = entry.options[CONF_ACCOUNTS][account[CONF_ACCOUNT]][CONF_ZONES] + for zone in range(1, zones + 1): + yield SIABinarySensorSmoke(entry, account, zone) + yield SIABinarySensorMoisture(entry, account, zone) -class SIABinarySensor(BinarySensorEntity, RestoreEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SIA binary sensors from a config entry.""" + async_add_entities(generate_binary_sensors(entry)) + + +class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): """Class for SIA Binary Sensors.""" def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, + entry: ConfigEntry, + account_data: dict[str, Any], zone: int, - ping_interval: int, device_class: str, - ): - """Create SIABinarySensor object.""" - self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._device_class = device_class - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._is_on = None - self._is_available = True - self._remove_unavailability_tracker = None - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - EVENT_ACCOUNT: None, - EVENT_CODE: None, - EVENT_ID: None, - EVENT_ZONE: None, - EVENT_MESSAGE: None, - EVENT_TIMESTAMP: None, - } + ) -> None: + """Initialize a base binary sensor.""" + super().__init__(entry, account_data, zone, device_class) - async def async_added_to_hass(self): - """Add sensor to HASS.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state is not None: - if state.state == STATE_ON: - self._is_on = True - elif state.state == STATE_OFF: - self._is_on = False - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( + self._entry.entry_id, self._account, self._zone, self._attr_device_class ) - self.setup_sia_entity() - def setup_sia_entity(self): - """Run the setup of the alarm control panel.""" - self.assume_available() - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event - ) - self.async_on_remove(self._async_sia_on_remove) - - @callback - def _async_sia_on_remove(self): - """Remove the unavailability and event listener.""" - if self._unsub: - self._unsub() - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - - async def async_handle_event(self, event: Event): - """Listen to events for this port and account and update states. - - If the port and account combo receives any message it means it is online and can therefore be set to available. - """ - self.assume_available() - sia_event = SIAEvent.from_dict(event.data) - sia_event.message_type = sia_event.message_type.value - if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: - device_class, new_state = CODE_CONSEQUENCES.get( - sia_event.code, (None, None) - ) - if new_state is not None and device_class == self._device_class: - self._attr.update(sia_event_to_attr(sia_event)) - self.state = new_state - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Return name.""" - return self._name + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None and last_state.state is not None: + if last_state.state == STATE_ON: + self._attr_is_on = True + elif last_state.state == STATE_OFF: + self._attr_is_on = False + elif last_state.state == STATE_UNAVAILABLE: + self._attr_available = False - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id +class SIABinarySensorMoisture(SIABinarySensorBase): + """Class for Moisture Binary Sensors.""" - @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def available(self) -> bool: - """Return avalability.""" - return self._is_available - - @property - def device_state_attributes(self) -> dict: - """Return attributes.""" - return self._attr - - @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Moisture binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_MOISTURE) + self._attr_entity_registry_enabled_default = False - @property - def state(self) -> str: - """Return the state of the binary sensor.""" - if self.is_on is None: - return STATE_UNKNOWN - return STATE_ON if self.is_on else STATE_OFF + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = MOISTURE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - return self._is_on - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False +class SIABinarySensorSmoke(SIABinarySensorBase): + """Class for Smoke Binary Sensors.""" - @state.setter - def state(self, new_on: bool): - """Set state.""" - self._is_on = new_on + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Smoke binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_SMOKE) + self._attr_entity_registry_enabled_default = False - def assume_available(self): - """Reset unavalability tracker.""" - if not self.registry_entry.disabled: - self._async_track_unavailable() + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = SMOKE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state - @callback - def _async_track_unavailable(self) -> bool: - """Track availability.""" - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, - self._async_set_unavailable, - utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, - ) - if not self._is_available: - self._is_available = True - return True - return False - @callback - def _async_set_unavailable(self, now): - """Set unavailable.""" - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() +class SIABinarySensorPower(SIABinarySensorBase): + """Class for Power Binary Sensors.""" - @property - def device_info(self) -> dict: - """Return the device_info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), - } + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: + """Initialize a Power binary sensor.""" + super().__init__(entry, account_data, SIA_HUB_ZONE, DEVICE_CLASS_POWER) + self._attr_entity_registry_enabled_default = True + + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = POWER_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 9adf167..c43faf5 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,5 +1,10 @@ """Config flow for sia integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy import logging +from typing import Any from pysiaalarm import ( InvalidAccountFormatError, @@ -10,9 +15,10 @@ ) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import CONF_PORT, CONF_PROTOCOL -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ACCOUNT, @@ -23,7 +29,9 @@ CONF_PING_INTERVAL, CONF_ZONES, DOMAIN, + TITLE, ) +from .hub import SIAHub _LOGGER = logging.getLogger(__name__) @@ -36,7 +44,6 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, - vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -47,113 +54,176 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, - vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) +DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} -def validate_input(data: dict): - """Validate the input by the user.""" - SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) +def validate_input(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the input by the user.""" try: - ping = int(data[CONF_PING_INTERVAL]) - assert 1 <= ping <= 1440 - except AssertionError as invalid_ping: - raise InvalidPing from invalid_ping - try: - zones = int(data[CONF_ZONES]) - assert zones > 0 - except AssertionError as invalid_zone: - raise InvalidZones from invalid_zone + SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) + except InvalidKeyFormatError: + return {"base": "invalid_key_format"} + except InvalidKeyLengthError: + return {"base": "invalid_key_length"} + except InvalidAccountFormatError: + return {"base": "invalid_account_format"} + except InvalidAccountLengthError: + return {"base": "invalid_account_length"} + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception from SIAAccount: %s", exc) + return {"base": "unknown"} + if not 1 <= data[CONF_PING_INTERVAL] <= 1440: + return {"base": "invalid_ping"} + return validate_zones(data) + + +def validate_zones(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the zones field.""" + if data[CONF_ZONES] == 0: + return {"base": "invalid_zones"} + return None class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" - VERSION = 2 + VERSION: int = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return SIAOptionsFlowHandler(config_entry) def __init__(self): """Initialize the config flow.""" - self._data = {} - - async def async_step_add_account(self, user_input: dict = None): - """Handle the additional accounts steps.""" - if user_input is None: + self._data: dict[str, Any] = {} + self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}} + + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + """Handle the initial user step.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_input(user_input) + if user_input is None or errors is not None: return self.async_show_form( - step_id="user", - data_schema=ACCOUNT_SCHEMA, - errors={}, + step_id="user", data_schema=HUB_SCHEMA, errors=errors ) + return await self.async_handle_data_and_route(user_input) - async def async_step_user(self, user_input: dict = None): - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors={} - ) - errors = {} - try: - validate_input(user_input) - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - if errors: + async def async_step_add_account( + self, user_input: dict[str, Any] = None + ) -> FlowResult: + """Handle the additional accounts steps.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_input(user_input) + if user_input is None or errors is not None: return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors=errors + step_id="add_account", data_schema=ACCOUNT_SCHEMA, errors=errors ) - self.update_data(user_input) - await self.async_set_unique_id(f"{DOMAIN}_{self._data[CONF_PORT]}") - try: - self._abort_if_unique_id_configured() - except AbortFlow: - return self.async_abort(reason="already_configured") + return await self.async_handle_data_and_route(user_input) + + async def async_handle_data_and_route( + self, user_input: dict[str, Any] + ) -> FlowResult: + """Handle the user_input, check if configured and route to the right next step or create entry.""" + self._update_data(user_input) + + self._async_abort_entries_match({CONF_PORT: self._data[CONF_PORT]}) if user_input[CONF_ADDITIONAL_ACCOUNTS]: return await self.async_step_add_account() return self.async_create_entry( - title=f"SIA Alarm on port {self._data[CONF_PORT]}", + title=TITLE.format(self._data[CONF_PORT]), data=self._data, + options=self._options, ) - def update_data(self, user_input): - """Parse the user_input and store in self.data.""" + def _update_data(self, user_input: dict[str, Any]) -> None: + """Parse the user_input and store in data and options attributes. + + If there is a port in the input or no data, assume it is fully new and overwrite. + Add the default options and overwrite the zones in options. + """ if not self._data or user_input.get(CONF_PORT): self._data = { CONF_PORT: user_input[CONF_PORT], CONF_PROTOCOL: user_input[CONF_PROTOCOL], - CONF_ACCOUNTS: [ + CONF_ACCOUNTS: [], + } + account = user_input[CONF_ACCOUNT] + self._data[CONF_ACCOUNTS].append( + { + CONF_ACCOUNT: account, + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), + CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], + } + ) + self._options[CONF_ACCOUNTS].setdefault(account, deepcopy(DEFAULT_OPTIONS)) + self._options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] + + +class SIAOptionsFlowHandler(config_entries.OptionsFlow): + """Handle SIA options.""" + + def __init__(self, config_entry): + """Initialize SIA options flow.""" + self.config_entry = config_entry + self.options = deepcopy(dict(config_entry.options)) + self.hub: SIAHub | None = None + self.accounts_todo: list = [] + + async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: + """Manage the SIA options.""" + self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + assert self.hub is not None + assert self.hub.sia_accounts is not None + self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] + return await self.async_step_options() + + async def async_step_options(self, user_input: dict[str, Any] = None) -> FlowResult: + """Create the options step for a account.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_zones(user_input) + if user_input is None or errors is not None: + account = self.accounts_todo[0] + return self.async_show_form( + step_id="options", + description_placeholders={"account": account}, + data_schema=vol.Schema( { - CONF_ACCOUNT: user_input[CONF_ACCOUNT], - CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), - CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], - CONF_ZONES: user_input[CONF_ZONES], - CONF_IGNORE_TIMESTAMPS: user_input[CONF_IGNORE_TIMESTAMPS], + vol.Optional( + CONF_ZONES, + default=self.options[CONF_ACCOUNTS][account][CONF_ZONES], + ): int, + vol.Optional( + CONF_IGNORE_TIMESTAMPS, + default=self.options[CONF_ACCOUNTS][account][ + CONF_IGNORE_TIMESTAMPS + ], + ): bool, } - ], - } - else: - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self._data[CONF_ACCOUNTS].append(add_data) - - -class InvalidPing(exceptions.HomeAssistantError): - """Error to indicate there is invalid ping interval.""" - + ), + errors=errors, + last_step=self.last_step, + ) -class InvalidZones(exceptions.HomeAssistantError): - """Error to indicate there is invalid number of zones.""" + account = self.accounts_todo.pop(0) + self.options[CONF_ACCOUNTS][account][CONF_IGNORE_TIMESTAMPS] = user_input[ + CONF_IGNORE_TIMESTAMPS + ] + self.options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] + if self.accounts_todo: + return await self.async_step_options() + return self.async_create_entry(title="", data=self.options) + + @property + def last_step(self) -> bool: + """Return if this is the last step.""" + return len(self.accounts_todo) <= 1 diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 6e32d5b..6a63c33 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,29 +1,34 @@ """Constants for the sia integration.""" -from datetime import timedelta +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] +DOMAIN = "sia" + +ATTR_CODE = "last_code" +ATTR_ZONE = "last_zone" +ATTR_MESSAGE = "last_message" +ATTR_ID = "last_id" +ATTR_TIMESTAMP = "last_timestamp" + +TITLE = "SIA Alarm on port {}" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" CONF_ADDITIONAL_ACCOUNTS = "additional_account" -CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" -CONF_ZONES = "zones" CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" +CONF_PING_INTERVAL = "ping_interval" +CONF_ZONES = "zones" -DOMAIN = "sia" -DATA_UPDATED = f"{DOMAIN}_data_updated" -SIA_EVENT = "sia_event" -HUB_SENSOR_NAME = "last_heartbeat" -HUB_ZONE = 0 -PING_INTERVAL_MARGIN = timedelta(seconds=30) - -EVENT_CODE = "last_code" -EVENT_ACCOUNT = "last_account" -EVENT_ZONE = "zone" -EVENT_MESSAGE = "last_message" -EVENT_ID = "last_id" -EVENT_TIMESTAMP = "last_timestamp" - +SIA_NAME_FORMAT = "{} - {} - zone {} - {}" +SIA_NAME_FORMAT_SENSOR = "{} - {} - Last Ping" +SIA_UNIQUE_ID_FORMAT_ALARM = "{}_{}_{}" +SIA_UNIQUE_ID_FORMAT_BINARY = "{}_{}_{}_{}" +SIA_HUB_ZONE = 0 +SIA_UNIQUE_ID_FORMAT_SENSOR = "{}_{}_last_ping" -DEFAULT_TIMEBAND = (80, 40) -IGNORED_TIMEBAND = (3600, 1800) \ No newline at end of file +SIA_EVENT = "sia_event_{}_{}" diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index a4c93f2..af7d59d 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,85 +1,147 @@ """The sia hub.""" +from __future__ import annotations + +from copy import deepcopy import logging +from typing import Any + +from pysiaalarm.aio import CommunicationsProtocol +from pysiaalarm.aio import SIAAccount +from pysiaalarm.aio import SIAClient +from pysiaalarm.aio import SIAEvent +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, EventOrigin, HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent, CommunicationsProtocol +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, CONF_IGNORE_TIMESTAMPS, + CONF_ZONES, DOMAIN, + PLATFORMS, SIA_EVENT, - IGNORED_TIMEBAND, - DEFAULT_TIMEBAND, ) - +from .utils import get_event_data_from_sia_event _LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEBAND = (80, 40) +IGNORED_TIMEBAND = (3600, 1800) + + class SIAHub: """Class for SIA Hubs.""" def __init__( - self, hass: HomeAssistant, hub_config: dict, entry_id: str, title: str - ): + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: """Create the SIAHub.""" - self._hass = hass - self._port = int(hub_config[CONF_PORT]) - self.entry_id = entry_id - self._title = title - self._accounts = hub_config[CONF_ACCOUNTS] - self._protocol = hub_config[CONF_PROTOCOL] - - self._remove_shutdown_listener = None - self.sia_accounts = [ - SIAAccount( - a[CONF_ACCOUNT], - a.get(CONF_ENCRYPTION_KEY), - IGNORED_TIMEBAND - if a.get(CONF_IGNORE_TIMESTAMPS, False) - else DEFAULT_TIMEBAND, - ) - for a in self._accounts - ] - self.sia_client = SIAClient( - "", - self._port, - self.sia_accounts, - self.async_create_and_fire_event, - CommunicationsProtocol(self._protocol), - ) + self._hass: HomeAssistant = hass + self._entry: ConfigEntry = entry + self._port: int = entry.data[CONF_PORT] + self._title: str = entry.title + self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) + self._protocol: str = entry.data[CONF_PROTOCOL] + self.sia_accounts: list[SIAAccount] | None = None + self.sia_client: SIAClient = None - async def async_setup_hub(self): + async def async_setup_hub(self) -> None: """Add a device to the device_registry, register shutdown listener, load reactions.""" - _LOGGER.debug("Setting up SIA Hub.") + self.update_accounts() device_registry = await dr.async_get_registry(self._hass) - port = self._port for acc in self._accounts: account = acc[CONF_ACCOUNT] device_registry.async_get_or_create( - config_entry_id=self.entry_id, - identifiers={(DOMAIN, port, account)}, - name=f"{port} - {account}", + config_entry_id=self._entry.entry_id, + identifiers={(DOMAIN, f"{self._port}_{account}")}, + name=f"{self._port} - {account}", ) - self._remove_shutdown_listener = self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self.async_shutdown + self._entry.async_on_unload( + self._entry.add_update_listener(self.async_config_entry_updated) + ) + self._entry.async_on_unload( + self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) ) - async def async_shutdown(self, _: Event = None): + async def async_shutdown(self, _: Event = None) -> None: """Shutdown the SIA server.""" - if self._remove_shutdown_listener: - self._remove_shutdown_listener() await self.sia_client.stop() - async def async_create_and_fire_event(self, event: SIAEvent): - """Create a event on HA's bus, with the data from the SIAEvent.""" + async def async_create_and_fire_event(self, event: SIAEvent) -> None: + """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. + + The created event is handled by default for only a small subset for each platform (there are about 320 SIA Codes defined, only 22 of those are used in the alarm_control_panel), a user can choose to build other automation or even entities on the same event for SIA codes not handled by the built-in platforms. + + """ + _LOGGER.debug( + "Adding event to dispatch and bus for code %s for port %s and account %s", + event.code, + self._port, + event.account, + ) + async_dispatcher_send( + self._hass, SIA_EVENT.format(self._port, event.account), event + ) self._hass.bus.async_fire( - event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data=event.to_dict(encode_json=True), - origin=EventOrigin.remote, + event_type=SIA_EVENT.format(self._port, event.account), + event_data=get_event_data_from_sia_event(event), ) + + def update_accounts(self): + """Update the SIA_Accounts variable.""" + self._load_options() + self.sia_accounts = [ + SIAAccount( + account_id=a[CONF_ACCOUNT], + key=a.get(CONF_ENCRYPTION_KEY), + allowed_timeband=IGNORED_TIMEBAND + if a[CONF_IGNORE_TIMESTAMPS] + else DEFAULT_TIMEBAND, + ) + for a in self._accounts + ] + if self.sia_client is not None: + self.sia_client.accounts = self.sia_accounts + return + self.sia_client = SIAClient( + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), + ) + + def _load_options(self) -> None: + """Store attributes to avoid property call overhead since they are called frequently.""" + options = dict(self._entry.options) + for acc in self._accounts: + acc_id = acc[CONF_ACCOUNT] + if acc_id in options[CONF_ACCOUNTS]: + acc[CONF_IGNORE_TIMESTAMPS] = options[CONF_ACCOUNTS][acc_id][ + CONF_IGNORE_TIMESTAMPS + ] + acc[CONF_ZONES] = options[CONF_ACCOUNTS][acc_id][CONF_ZONES] + + @staticmethod + async def async_config_entry_updated( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Handle signals of config entry being updated. + + First, update the accounts, this will reflect any changes with ignore_timestamps. + Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. + + """ + if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): + return + hub.update_accounts() + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index fb8f87d..ab97121 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -3,12 +3,8 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": [ - "pysiaalarm==3.0.0b12" - ], - "codeowners": [ - "@eavanvalkenburg" - ], - "version": "0.5.0b9", + "requirements": ["pysiaalarm==3.0.3b1"], + "codeowners": ["@eavanvalkenburg"], + "version": "1.0.0", "iot_class": "local_push" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 6bfab53..3bea41f 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,54 +1,47 @@ """Module for SIA Sensors.""" -import datetime as dt +from __future__ import annotations + +from datetime import datetime as dt, timedelta import logging -from typing import Callable +from typing import Any + +from pysiaalarm import SIAEvent -from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import CONF_PORT, DEVICE_CLASS_TIMESTAMP +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from pysiaalarm import SIAEvent from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, - DATA_UPDATED, DOMAIN, - HUB_ZONE, SIA_EVENT, + SIA_NAME_FORMAT_SENSOR, + SIA_UNIQUE_ID_FORMAT_SENSOR, ) -from .utils import get_entity_and_name, get_ping_interval +from .utils import get_attr_from_sia_event _LOGGER = logging.getLogger(__name__) +REGULAR_ICON = "mdi:clock-check" +LATE_ICON = "mdi:clock-alert" + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up sia_sensor from a config entry.""" async_add_entities( - [ - SIASensor( - *get_entity_and_name( - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - DEVICE_CLASS_TIMESTAMP, - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - acc[CONF_PING_INTERVAL], - DEVICE_CLASS_TIMESTAMP, - ) - for acc in entry.data[CONF_ACCOUNTS] - ] + SIASensor(entry, account_data) for account_data in entry.data[CONF_ACCOUNTS] ) - return True class SIASensor(RestoreEntity): @@ -56,128 +49,82 @@ class SIASensor(RestoreEntity): def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, - zone: int, - ping_interval: int, - device_class: str, - ): + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: """Create SIASensor object.""" - self.entity_id = SENSOR_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._device_class = device_class - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._state = utcnow() - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - } + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: timedelta = timedelta( + minutes=self._account_data[CONF_PING_INTERVAL] + ) + + self._state: dt = utcnow() + self._cancel_icon_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes: dict[str, Any] = {} + self._attr_icon = REGULAR_ICON + self._attr_unit_of_measurement = "ISO8601" + self._attr_device_class = DEVICE_CLASS_TIMESTAMP + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT_SENSOR.format(self._port, self._account) + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_SENSOR.format( + self._entry.entry_id, self._account + ) async def async_added_to_hass(self) -> None: """Once the sensor is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state is not None: - self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") - - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state is not None: + self._state = dt.fromisoformat(last_state.state) + self.async_update_icon() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIA_EVENT.format(self._port, self._account), + self.async_handle_event, + ) ) - self.setup_sia_entity() - - def setup_sia_entity(self): - """Run the setup of the sensor.""" - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self.async_on_remove( + async_track_time_interval( + self.hass, self.async_update_icon, self._ping_interval + ) ) - self.async_on_remove(self._async_sia_on_remove) @callback - def _async_sia_on_remove(self): - """Remove the event listener.""" - if self._unsub: - self._unsub() - - async def async_handle_event(self, event: Event): + def async_handle_event(self, sia_event: SIAEvent): """Listen to events for this port and account and update the state and attributes.""" - sia_event = SIAEvent.from_dict(event.data) - self._attr.update(event.data) + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) if sia_event.code == "RP": - self.state = utcnow() - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Return name.""" - return self._name + self._state = utcnow() + self.async_update_icon() - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - - @property - def unique_id(self) -> str: - """Get unique_id.""" - return self._unique_id + @callback + def async_update_icon(self, *_) -> None: + """Update the icon.""" + if self._state < utcnow() - self._ping_interval: + self._attr_icon = LATE_ICON + else: + self._attr_icon = REGULAR_ICON + self.async_write_ha_state() @property - def state(self) -> str: + def state(self) -> StateType: """Return state.""" return self._state.isoformat() @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def device_state_attributes(self) -> dict: - """Return attributes.""" - return self._attr - - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False - - @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class - - @state.setter - def state(self, state: dt.datetime): - """Set state.""" - self._state = state - - @property - def icon(self) -> str: - """Return the icon to use in the frontend, if any.""" - return "mdi:alarm-light-outline" - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return "ISO8601" - - @property - def device_info(self) -> dict: + def device_info(self) -> DeviceInfo: """Return the device_info.""" + assert self._attr_unique_id is not None + assert self._attr_name is not None return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), + "name": self._attr_name, + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "via_device": (DOMAIN, f"{self._port}_{self._account}"), } diff --git a/custom_components/sia/sia_entity_base.py b/custom_components/sia/sia_entity_base.py new file mode 100644 index 0000000..0a84615 --- /dev/null +++ b/custom_components/sia/sia_entity_base.py @@ -0,0 +1,132 @@ +"""Module for SIA Base Entity.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import CALLBACK_TYPE, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DOMAIN, SIA_EVENT, SIA_NAME_FORMAT +from .utils import get_attr_from_sia_event, get_unavailability_interval + +_LOGGER = logging.getLogger(__name__) + + +class SIABaseEntity(RestoreEntity): + """Base class for SIA entities.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + device_class: str, + ) -> None: + """Create SIABaseEntity object.""" + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + self._zone: int = zone + self._attr_device_class: str = device_class + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: int = self._account_data[CONF_PING_INTERVAL] + + self._cancel_availability_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes = {} + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT.format( + self._port, self._account, self._zone, self._attr_device_class + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass. + + Overridden from Entity. + + 1. register the dispatcher and add the callback to on_remove + 2. get previous state from storage and pass to entity specific function + 3. if available: create availability cb + """ + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIA_EVENT.format(self._port, self._account), + self.async_handle_event, + ) + ) + self.handle_last_state(await self.async_get_last_state()) + if self._attr_available: + self.async_create_availability_cb() + + @abstractmethod + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + Overridden from Entity. + """ + if self._cancel_availability_cb: + self._cancel_availability_cb() + + @callback + def async_handle_event(self, sia_event: SIAEvent) -> None: + """Listen to dispatcher events for this port and account and update state and attributes. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + _LOGGER.debug("Received event: %s", sia_event) + if int(sia_event.ri) == self._zone: + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) + self.update_state(sia_event) + self.async_reset_availability_cb() + self.async_write_ha_state() + + @abstractmethod + def update_state(self, sia_event: SIAEvent) -> None: + """Do the entity specific state updates.""" + + @callback + def async_reset_availability_cb(self) -> None: + """Reset availability cb by cancelling the current and creating a new one.""" + self._attr_available = True + if self._cancel_availability_cb: + self._cancel_availability_cb() + self.async_create_availability_cb() + + def async_create_availability_cb(self) -> None: + """Create a availability cb and return the callback.""" + self._cancel_availability_cb = async_call_later( + self.hass, + get_unavailability_interval(self._ping_interval), + self.async_set_unavailable, + ) + + @callback + def async_set_unavailable(self, _) -> None: + """Set unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info.""" + assert self._attr_name is not None + assert self.unique_id is not None + return { + "name": self._attr_name, + "identifiers": {(DOMAIN, self.unique_id)}, + "via_device": (DOMAIN, f"{self._port}_{self._account}"), + } diff --git a/custom_components/sia/strings.json b/custom_components/sia/strings.json index 1d7aeba..fe648c2 100644 --- a/custom_components/sia/strings.json +++ b/custom_components/sia/strings.json @@ -1,5 +1,4 @@ { - "title": "SIA Alarm Systems", "config": { "step": { "user": { @@ -10,7 +9,6 @@ "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", - "ignore_timestamps": "Ignore the timestamp check", "additional_account": "Additional accounts" }, "title": "Create a connection for SIA based alarm systems." @@ -21,7 +19,6 @@ "encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]", "ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]", "zones": "[%key:component::sia::config::step::user::data::zones%]", - "ignore_timestamps": "[%key:component::sia::config::step::user::data::ignore_timestamps%]", "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" }, "title": "Add another account to the current port." @@ -29,15 +26,24 @@ }, "error": { "invalid_key_format": "The key is not a hex value, please use only 0-9 and A-F.", - "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 characters hex characters.", + "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 hex characters.", "invalid_account_format": "The account is not a hex value, please use only 0-9 and A-F.", "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", "invalid_zones": "There needs to be at least 1 zone.", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Ignore the timestamp check of the SIA events", + "zones": "[%key:component::sia::config::step::user::data::zones%]" + }, + "description": "Set the options for account: {account}", + "title": "Options for the SIA Setup." + } } } } diff --git a/custom_components/sia/utils.py b/custom_components/sia/utils.py index ca51e02..9150099 100644 --- a/custom_components/sia/utils.py +++ b/custom_components/sia/utils.py @@ -1,66 +1,76 @@ +"""Helper functions for the SIA integration.""" +from __future__ import annotations + from datetime import timedelta -from typing import Tuple +from typing import Any -from homeassistant.const import DEVICE_CLASS_TIMESTAMP from pysiaalarm import SIAEvent -from .const import ( - HUB_SENSOR_NAME, - HUB_ZONE, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, -) +from homeassistant.util.dt import utcnow +from .const import ATTR_CODE, ATTR_ID, ATTR_MESSAGE, ATTR_TIMESTAMP, ATTR_ZONE -def get_entity_and_name( - port: int, account: str, zone: int = 0, entity_type: str = None -) -> Tuple[str, str]: - """Give back a entity_id and name according to the variables.""" - if zone == HUB_ZONE: - entity_type_name = ( - "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" - ) - return ( - get_entity_id(port, account, zone, entity_type), - f"{port} - {account} - {entity_type_name}", - ) - if entity_type: - return ( - get_entity_id(port, account, zone, entity_type), - f"{port} - {account} - zone {zone} - {entity_type}", - ) - return None +PING_INTERVAL_MARGIN = 30 -def get_ping_interval(ping: int) -> timedelta: - """Return the ping interval as timedelta.""" - return timedelta(minutes=ping) +def get_unavailability_interval(ping: int) -> float: + """Return the interval to the next unavailability check.""" + return timedelta(minutes=ping, seconds=PING_INTERVAL_MARGIN).total_seconds() -def get_entity_id( - port: int, account: str, zone: int = 0, entity_type: str = None -) -> str: - """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == HUB_ZONE: - if entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{port}_{account}_{HUB_SENSOR_NAME}" - return f"{port}_{account}_{entity_type}" - if entity_type: - return f"{port}_{account}_{zone}_{entity_type}" - return None +def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: + """Create the attributes dict from a SIAEvent.""" + return { + ATTR_ZONE: event.ri, + ATTR_CODE: event.code, + ATTR_MESSAGE: event.message, + ATTR_ID: event.id, + ATTR_TIMESTAMP: event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), + } -def sia_event_to_attr(event: SIAEvent) -> dict: - """Create the attributes dict from a SIAEvent.""" +def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: + """Create a dict from the SIA Event for the HA Event.""" return { - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, + "message_type": event.message_type.value, + "receiver": event.receiver, + "line": event.line, + "account": event.account, + "sequence": event.sequence, + "content": event.content, + "ti": event.ti, + "id": event.id, + "ri": event.ri, + "code": event.code, + "message": event.message, + "x_data": event.x_data, + "timestamp": event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), + "event_qualifier": event.event_qualifier, + "event_type": event.event_type, + "partition": event.partition, + "extended_data": [ + { + "identifier": xd.identifier, + "name": xd.name, + "description": xd.description, + "length": xd.length, + "characters": xd.characters, + "value": xd.value, + } + for xd in event.extended_data + ] + if event.extended_data is not None + else None, + "sia_code": { + "code": event.sia_code.code, + "type": event.sia_code.type, + "description": event.sia_code.description, + "concerns": event.sia_code.concerns, + } + if event.sia_code is not None + else None, } From 2c68efad2b685e98199e7493d4acb196ac378a23 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 7 Jul 2022 09:45:01 +0200 Subject: [PATCH 25/25] prep for archive --- README.md | 86 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 12eb645..fa01b83 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,10 @@ -## OFFICIAL INTEGRATION IS NOW IN HA! +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) Make sure to delete the current integraiton, in your Integrations page, then delete the HACS custom component, reboot and then input your config in the official integration config. There are some settings, most importantly ignoring timestamps, in a options flow (press configure after installing the integration). -## OFFICIAL INTEGRATION IS NOW IN HA! ----------------- +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) - -_Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ - -This is the new stream which will be kept in sync with the [official integration][official]. - -In order to switch from an earlier version of the custom component to do this do not install this version over an existing version of the custom component, delete your integration in the integrations page, then update and reenter your config in the integrations page. - -If you are running the official integration, then you can install this one and things will work. - -The config used by earlier versions (before 1.0) is different and will cause errors, it is in sync with the official integration though! - - -**This component will set up the following platforms.** - -## WARNING -This integration may be unsecure. You can use it, but it's at your own risk. -This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. - -Platform | Description --- | -- -`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. Power sensor for the hub. You can disable these sensors if you do not have those devices. -`alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. You can disable this sensor if you have zones defined with just sensors and no alarm. -`sensor` | Sensor with the last heartbeat message from your system, one per account. Please do not disable this sensor as it will show you the status of the connection. - -## Features -- Alarm tracking with a alarm_control_panel component, but no alarm setting -- Fire/gas tracker -- Water leak tracker -- Hub Power status tracker -- AES-128 CBC encryption support - -## Hub Setup (Ajax Systems Hub example) - -1. Select "SIA Protocol". -2. Enable "Connect on demand". -3. Place Account Id/Object number - 3-16 ASCII hex characters. For example AAA. -4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. -5. Insert Home Assistant listening port. This port must not be used with anything else. -6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. -7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. -8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. -9. Keep in mind that Monitoring Station will say "Connected" in the app if configured correctly. The sensors will have state "Unknown" until they get a new state. Arm/disarm to update the alarm sensor as an example. - -## Installation - -1. Click install. -1. The latest version is only available through a config flow. -1. After clicking the add button in the Integration pane, you full in the below fields. - -If you have multiple accounts that you want to monitor you can choose to have both communicating with the same port, in that case, use the additional accounts checkbox in the config so setup the second (and more) accounts. You can also choose to have both running on a different port, in that case setup the component twice. - -After setup you will see one entity per account for the heartbeat, and 3 entities for each zone per account, alarm, smoke sensor and moisture sensor. This means at least four entities are added, each will also have a device associated with it, so allow you to use the area feature. Unwanted sensors should be hidden in the interface. - -## Configuration options - -Key | Type | Required | Description --- | -- | -- | -- -`port` | `int` | `True` | Port that SIA will listen on. -`account` | `string` | `True` | Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. -`encryption_key` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. -`ping_interval` | `int` | `True` | Ping interval in minutes that the alarm system uses to send "Automatic communication test report" messages, the HA component adds 30 seconds before marking a device unavailable. Must be between 1 and 1440 minutes, default is 1. -`zones` | `int` | `True` | The number of zones present for the account, default is 1. -`additional_account` | `bool` | `True` | Used to ask for additional accounts in multiple steps during setup, default is False. - -ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. -*** - -## Debugging -To turn on debugging go into your `configuration.yaml` and add these lines: -```yaml -logger: - default: error - logs: - custom_components.sia: debug - pysiaalarm: debug -``` - -[SIA]: https://github.com/eavanvalkenburg/sia-ha -[ch_sia]: https://github.com/Cheaterdev/sia-ha -[hacs]: https://github.com/custom-components/hacs -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge [official]: https://www.home-assistant.io/integrations/sia/