diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..de5ad0e --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,7 @@ +default_config: +debugpy: + +logger: + default: info + logs: + custom_components.sia: debug diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2418999 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "runArgs": [ + "-v", + "${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", + "redhat.vscode-yaml", + "esbenp.prettier-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, + "remote.autoForwardPorts": false + }, + "forwardPorts": [5678, 8126] +} \ 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/.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 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/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 new file mode 100644 index 0000000..678c1b6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.associations": { + "*.yaml": "home-assistant" + }, + "python.pythonPath": "/usr/local/bin/python" +} \ 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": [] } ] diff --git a/README.md b/README.md index 30a9974..fa01b83 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,10 @@ -[![hacs][hacsbadge]](hacs) -_Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) -_Latest beta will be suggested for inclusion as a official integration._ +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). -**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. +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) -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. -`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 -- AES-128 CBC encryption support - -## Hub Setup (Ajax Systems Hub example) - -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. -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. - -## 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/ diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index ba14212..9bca9a5 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,46 +1,34 @@ """The sia integration.""" -import asyncio - 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, PLATFORMS from .hub import SIAHub -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 - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - hub.sia_client.start(reuse_port=True) + try: + await hub.sia_client.start(reuse_port=True) + except OSError as exc: + raise ConfigEntryNotReady( + 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: - 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) - + 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 c61fc5d..5a6a4f6 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,208 +1,112 @@ """Module for SIA Alarm Control Panels.""" +from __future__ import annotations import logging -from typing import Callable +from typing import Any -from homeassistant.components.alarm_control_panel import ( - ENTITY_ID_FORMAT as ALARM_FORMAT, - AlarmControlPanelEntity, -) +from pysiaalarm import SIAEvent + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_ZONE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, - STATE_UNKNOWN, -) -from homeassistant.core import 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 .const import ( - CONF_ACCOUNT, - CONF_PING_INTERVAL, - DATA_UPDATED, - DOMAIN, - PING_INTERVAL_MARGIN, - PREVIOUS_STATE, + STATE_UNAVAILABLE, ) +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_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: 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, + "CQ": STATE_ALARM_ARMED_AWAY, + "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, + "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] -) -> bool: - """Set up sia_alarm_control_panel from a config entry.""" - async_add_devices( - [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIAAlarmControlPanel) - ] + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SIA alarm_control_panel(s) from a config entry.""" + async_add_entities( + 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 = ping_interval - - self._should_poll = False - 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: str(self._ping_interval), - CONF_ZONE: self._zone, - } - - 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 - await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) - - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) - - @property - def name(self) -> str: - """Get Name.""" - return self._name + super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) + self._attr_state: StateType = None + self._old_state: StateType = None - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - - @property - def state(self) -> str: - """Get state.""" - return self._state - - @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 True if entity has to be polled for state. - - 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 - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() - - async def assume_available(self): - """Reset unavalability tracker.""" - if not self.registry_entry.disabled: - await self._async_track_unavailable() - - @callback - async 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, + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( + self._entry.entry_id, self._account, self._zone ) - if not self._is_available: - self._is_available = True - 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 f99dce0..eec4f9b 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,192 +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 ( - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SMOKE, BinarySensorEntity, ) 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.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.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_PING_INTERVAL, - DATA_UPDATED, - DOMAIN, - PING_INTERVAL_MARGIN, + CONF_ACCOUNTS, + CONF_ZONES, + SIA_HUB_ZONE, + SIA_UNIQUE_ID_FORMAT_BINARY, ) +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass, entry: ConfigEntry, async_add_devices: Callable[[], None] -) -> bool: - """Set up sia_binary_sensor from a config entry.""" - async_add_devices( - [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIABinarySensor) - ] - ) +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, +} + + +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) + - return True +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 SIABinarySensor(BinarySensorEntity, RestoreEntity): +class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): """Class for SIA Binary Sensors.""" def __init__( self, - entity_id: str, - name: str, - device_class: str, - port: int, - account: str, + entry: ConfigEntry, + account_data: dict[str, Any], zone: int, - ping_interval: int, - ): - """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 = ping_interval - - self._should_poll = False - self._is_on = None - self._is_available = True - self._remove_unavailability_tracker = None - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: str(self._ping_interval), - CONF_ZONE: self._zone, - } - - 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 - await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + device_class: str, + ) -> None: + """Initialize a base binary sensor.""" + super().__init__(entry, account_data, zone, device_class) - @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - - @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: - """Return unique id.""" - return self._unique_id - - @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 - - @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 - - @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 True if entity has to be polled for state. - - 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.""" - if not self.registry_entry.disabled: - await self._async_track_unavailable() - - @callback - async 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, + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( + self._entry.entry_id, self._account, self._zone, self._attr_device_class ) - 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() - - @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 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 + + +class SIABinarySensorMoisture(SIABinarySensorBase): + """Class for Moisture Binary Sensors.""" + + 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 + + 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 + + +class SIABinarySensorSmoke(SIABinarySensorBase): + """Class for Smoke Binary Sensors.""" + + 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 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 + + +class SIABinarySensorPower(SIABinarySensorBase): + """Class for Power Binary Sensors.""" + + 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 4398c01..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,19 +15,23 @@ ) import voluptuous as vol -from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_PORT -from homeassistant.data_entry_flow import AbortFlow +from homeassistant import config_entries +from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ADDITIONAL_ACCOUNTS, CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, CONF_PING_INTERVAL, CONF_ZONES, DOMAIN, + TITLE, ) +from .hub import SIAHub _LOGGER = logging.getLogger(__name__) @@ -30,6 +39,7 @@ 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, @@ -48,119 +58,172 @@ } ) +DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} -def validate_input(data: dict) -> bool: - """Validate the input by the user.""" - SIAAccount(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: - raise InvalidPing - try: - zones = int(data[CONF_ZONES]) - assert zones > 0 - except AssertionError: - raise InvalidZones - - return True + 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 = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - data = None + 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: dict[str, Any] = {} + self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}} - async def async_step_add_account(self, user_input: dict = None): + 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=HUB_SCHEMA, errors=errors + ) + return await self.async_handle_data_and_route(user_input) + + async def async_step_add_account( + self, user_input: dict[str, Any] = None + ) -> FlowResult: """Handle the additional accounts steps.""" - errors = {} + errors: dict[str, str] | None = None 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() - 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, + errors = validate_input(user_input) + if user_input is None or errors is not None: + return self.async_show_form( + step_id="add_account", data_schema=ACCOUNT_SCHEMA, errors=errors + ) + 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=TITLE.format(self._data[CONF_PORT]), + data=self._data, + options=self._options, ) - async def async_step_user(self, user_input: dict = None): - """Handle the initial step.""" - errors = {} + 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: [], + } + 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: - 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], - } + 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( + { + 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 ], - } - 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() - 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 - ) - - -class InvalidPing(exceptions.HomeAssistantError): - """Error to indicate there is invalid ping interval.""" - - -class InvalidZones(exceptions.HomeAssistantError): - """Error to indicate there is invalid number of zones.""" + ): bool, + } + ), + errors=errors, + last_step=self.last_step, + ) + + 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 aafb346..6a63c33 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,78 +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_IGNORE_TIMESTAMPS = "ignore_timestamps" +CONF_PING_INTERVAL = "ping_interval" CONF_ZONES = "zones" -DOMAIN = "sia" -DATA_UPDATED = f"{DOMAIN}_data_updated" -DEFAULT_NAME = "SIA Alarm" -DEVICE_CLASS_ALARM = "alarm" -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": "timestamp", "attr": "last_message"}, - "AR": {"type": "timestamp", "attr": "last_message"}, - "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"}, -} +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" +SIA_EVENT = "sia_event_{}_{}" diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 6593068..af7d59d 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,219 +1,147 @@ """The sia hub.""" -import asyncio -from datetime import timedelta +from __future__ import annotations + +from copy import deepcopy import logging +from typing import Any -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent +from pysiaalarm.aio import CommunicationsProtocol +from pysiaalarm.aio import SIAAccount +from pysiaalarm.aio import SIAClient +from pysiaalarm.aio import SIAEvent -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, -) -from homeassistant.const import ( - CONF_PORT, - CONF_SENSORS, - CONF_ZONE, - DEVICE_CLASS_TIMESTAMP, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util.dt import utcnow +from homeassistant.helpers.dispatcher import async_dispatcher_send -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_IGNORE_TIMESTAMPS, CONF_ZONES, - DEVICE_CLASS_ALARM, DOMAIN, - HUB_SENSOR_NAME, - HUB_ZONE, - LAST_MESSAGE, - REACTIONS, - UTCNOW, + PLATFORMS, + SIA_EVENT, ) -from .sensor import SIASensor +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.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], - } - 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)) - for a in self._accounts - ] - self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.update_states - ) - self._create_sensors() - - async def async_setup_hub(self): + 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) -> None: """Add a device to the device_registry, register shutdown listener, load reactions.""" + 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.shutdown_remove_listener = self._hass.bus.async_listen_once( - 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): + async def async_shutdown(self, _: Event = None) -> None: """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 - ) - 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): - 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: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Last Heartbeat", - ) - 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 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 - - 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. + 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. - 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. + 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. """ - - # 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, + _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=SIA_EVENT.format(self._port, event.account), + event_data=get_event_data_from_sia_event(event), ) - # 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, + 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, ) - 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"] + 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), ) - 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() - 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}" - } - ) + 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 ee12b67..ab97121 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -3,11 +3,8 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": [ - "pysiaalarm==2.0.9beta-3" - ], - "codeowners": [ - "@eavanvalkenburg" - ], - "version": "0.3.11" -} \ No newline at end of file + "requirements": ["pysiaalarm==3.0.3b1"], + "codeowners": ["@eavanvalkenburg"], + "version": "1.0.0", + "iot_class": "local_push" +} 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..3bea41f 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,149 +1,130 @@ """Module for SIA Sensors.""" +from __future__ import annotations -import datetime as dt +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_ZONE -from homeassistant.core import 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 .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DATA_UPDATED, DOMAIN +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_PING_INTERVAL, + DOMAIN, + SIA_EVENT, + SIA_NAME_FORMAT_SENSOR, + SIA_UNIQUE_ID_FORMAT_SENSOR, +) +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_devices: Callable[[], None] -) -> bool: + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """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) - ] + async_add_entities( + SIASensor(entry, account_data) for account_data in entry.data[CONF_ACCOUNTS] ) - return True - class SIASensor(RestoreEntity): """Class for SIA Sensors.""" def __init__( self, - entity_id: str, - name: str, - device_class: str, - port: int, - account: str, - zone: int, - ping_interval: int, - ): + 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 = str(ping_interval) - - self._state = utcnow() - self._should_poll = False - 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): + 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 + 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.async_on_remove( + async_track_time_interval( + self.hass, self.async_update_icon, self._ping_interval + ) ) @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - - @property - def name(self) -> str: - """Return name.""" - return self._name + def async_handle_event(self, sia_event: SIAEvent): + """Listen to events for this port and account and update the state and attributes.""" + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) + if sia_event.code == "RP": + self._state = utcnow() + self.async_update_icon() - @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 - - 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 - - @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 - if not self.registry_entry.disabled: - self.async_schedule_update_ha_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 1eed82a..fe648c2 100644 --- a/custom_components/sia/strings.json +++ b/custom_components/sia/strings.json @@ -1,31 +1,49 @@ { - "title": "SIA Alarm Systems", "config": { "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?" + "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%]", + "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" + }, + "title": "Add another account to the current port." } }, "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": "Unexpected error" - }, - "abort": { - "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "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/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." } } }, diff --git a/custom_components/sia/utils.py b/custom_components/sia/utils.py new file mode 100644 index 0000000..9150099 --- /dev/null +++ b/custom_components/sia/utils.py @@ -0,0 +1,76 @@ +"""Helper functions for the SIA integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.util.dt import utcnow + +from .const import ATTR_CODE, ATTR_ID, ATTR_MESSAGE, ATTR_TIMESTAMP, ATTR_ZONE + +PING_INTERVAL_MARGIN = 30 + + +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_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 get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: + """Create a dict from the SIA Event for the HA Event.""" + return { + "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, + } 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 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