├── todo.md ├── codes.pdf ├── SIA Codes.xlsx ├── SIA_code.pdf ├── .gitattributes ├── hacs.json ├── config └── configuration.yaml ├── .devcontainer ├── configuration.yaml ├── devcontainer.json └── readme.md ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── custom_components └── sia │ ├── manifest.json │ ├── const.py │ ├── __init__.py │ ├── translations │ └── en.json │ ├── strings.json │ ├── utils.py │ ├── alarm_control_panel.py │ ├── sensor.py │ ├── sia_entity_base.py │ ├── binary_sensor.py │ ├── hub.py │ └── config_flow.py ├── .github ├── auto_assign-issues.yml ├── settings.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── issue.md │ └── unhandled_event_type.md ├── README.md ├── test_zc.py ├── LICENSE ├── CONTRIBUTING.md ├── .gitignore ├── info.md └── codes.csv /todo.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codes.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eavanvalkenburg/sia/HEAD/codes.pdf -------------------------------------------------------------------------------- /SIA Codes.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eavanvalkenburg/sia/HEAD/SIA Codes.xlsx -------------------------------------------------------------------------------- /SIA_code.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eavanvalkenburg/sia/HEAD/SIA_code.pdf -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SIA", 3 | "domains": ["binary_sensor", "alarm_control_panel", "sensor"] 4 | } -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: error 5 | logs: 6 | custom_components.sia: debug -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | debugpy: 3 | 4 | logger: 5 | default: info 6 | logs: 7 | custom_components.sia: debug 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | }, 5 | "python.pythonPath": "/usr/local/bin/python" 6 | } -------------------------------------------------------------------------------- /custom_components/sia/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "sia", 3 | "name": "SIA Alarm Systems", 4 | "config_flow": true, 5 | "documentation": "https://www.home-assistant.io/integrations/sia", 6 | "requirements": ["pysiaalarm==3.0.3b1"], 7 | "codeowners": ["@eavanvalkenburg"], 8 | "version": "1.0.0", 9 | "iot_class": "local_push" 10 | } 11 | -------------------------------------------------------------------------------- /.github/auto_assign-issues.yml: -------------------------------------------------------------------------------- 1 | # If enabled, auto-assigns users when a new issue is created 2 | # Defaults to true, allows you to install the app globally, and disable on a per-repo basis 3 | addAssignees: true 4 | 5 | # The list of users to assign to new issues. 6 | # If empty or not provided, the repository owner is assigned 7 | assignees: 8 | - eavanvalkenburg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## [OFFICIAL INTEGRATION IS NOW IN HA!](official) 3 | 4 | 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 5 | integration config. There are some settings, most importantly ignoring timestamps, in a options flow (press configure after installing the integration). 6 | 7 | 8 | ## [OFFICIAL INTEGRATION IS NOW IN HA!](official) 9 | 10 | [official]: https://www.home-assistant.io/integrations/sia/ 11 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | private: false 3 | has_issues: true 4 | has_projects: false 5 | has_wiki: false 6 | has_downloads: false 7 | default_branch: master 8 | allow_squash_merge: true 9 | allow_merge_commit: false 10 | allow_rebase_merge: false 11 | labels: 12 | - name: "Feature Request" 13 | color: "fbca04" 14 | - name: "Bug" 15 | color: "b60205" 16 | - name: "Wont Fix" 17 | color: "ffffff" 18 | - name: "Enhancement" 19 | color: a2eeef 20 | - name: "Documentation" 21 | color: "008672" 22 | - name: "Stale" 23 | color: "930191" -------------------------------------------------------------------------------- /test_zc.py: -------------------------------------------------------------------------------- 1 | from zeroconf import ServiceBrowser, Zeroconf 2 | 3 | 4 | class MyListener: 5 | 6 | def remove_service(self, zeroconf, type, name): 7 | print("Service %s removed" % (name,)) 8 | 9 | def add_service(self, zeroconf, type, name): 10 | info = zeroconf.get_service_info(type, name) 11 | print("Service %s added, service info: %s" % (name, info)) 12 | 13 | 14 | zeroconf = Zeroconf(apple_p2p=False) 15 | listener = MyListener() 16 | browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) 17 | try: 18 | input("Press enter to exit...\n\n") 19 | finally: 20 | zeroconf.close() -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | // Debug by attaching to local Home Asistant server using Remote Python Debugger. 9 | // See https://www.home-assistant.io/integrations/debugpy/ 10 | "name": "Home Assistant: Attach Local", 11 | "type": "python", 12 | "request": "attach", 13 | "port": 5678, 14 | "host": "localhost", 15 | "pathMappings": [ 16 | { 17 | "localRoot": "${workspaceFolder}", 18 | "remoteRoot": "." 19 | } 20 | ], 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component and HA setup (version, OS, etc) 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | 26 | Add your configuration here. 27 | 28 | ``` 29 | 30 | ## Describe the bug 31 | A clear and concise description of what the bug is. 32 | 33 | 34 | ## Debug log 35 | 36 | 37 | 38 | ```text 39 | 40 | Add your logs here. 41 | 42 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ghcr.io/ludeeus/devcontainer/integration:latest", 4 | "context": "..", 5 | "appPort": [ 6 | "9123:8123" 7 | ], 8 | "postCreateCommand": "container install", 9 | "runArgs": [ 10 | "-v", 11 | "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh", 12 | // "--network=host", 13 | "--add-host=host.docker.internal:host-gateway" 14 | ], 15 | "extensions": [ 16 | "ms-python.vscode-pylance", 17 | "visualstudioexptteam.vscodeintellicode", 18 | "github.vscode-pull-request-github", 19 | "redhat.vscode-yaml", 20 | "esbenp.prettier-vscode" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "terminal.integrated.shell.linux": "/bin/bash", 26 | "python.pythonPath": "/usr/bin/python3", 27 | "python.linting.pylintEnabled": true, 28 | "python.linting.enabled": true, 29 | "python.formatting.provider": "black", 30 | "editor.formatOnPaste": false, 31 | "editor.formatOnSave": true, 32 | "editor.formatOnType": true, 33 | "files.trimTrailingWhitespace": true, 34 | "remote.autoForwardPorts": false 35 | }, 36 | "forwardPorts": [5678, 8126] 37 | } -------------------------------------------------------------------------------- /custom_components/sia/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the sia integration.""" 2 | from homeassistant.components.alarm_control_panel import ( 3 | DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, 4 | ) 5 | from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 6 | from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN 7 | 8 | PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] 9 | 10 | DOMAIN = "sia" 11 | 12 | ATTR_CODE = "last_code" 13 | ATTR_ZONE = "last_zone" 14 | ATTR_MESSAGE = "last_message" 15 | ATTR_ID = "last_id" 16 | ATTR_TIMESTAMP = "last_timestamp" 17 | 18 | TITLE = "SIA Alarm on port {}" 19 | CONF_ACCOUNT = "account" 20 | CONF_ACCOUNTS = "accounts" 21 | CONF_ADDITIONAL_ACCOUNTS = "additional_account" 22 | CONF_ENCRYPTION_KEY = "encryption_key" 23 | CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" 24 | CONF_PING_INTERVAL = "ping_interval" 25 | CONF_ZONES = "zones" 26 | 27 | SIA_NAME_FORMAT = "{} - {} - zone {} - {}" 28 | SIA_NAME_FORMAT_SENSOR = "{} - {} - Last Ping" 29 | SIA_UNIQUE_ID_FORMAT_ALARM = "{}_{}_{}" 30 | SIA_UNIQUE_ID_FORMAT_BINARY = "{}_{}_{}_{}" 31 | SIA_HUB_ZONE = 0 32 | SIA_UNIQUE_ID_FORMAT_SENSOR = "{}_{}_last_ping" 33 | 34 | SIA_EVENT = "sia_event_{}_{}" 35 | -------------------------------------------------------------------------------- /custom_components/sia/__init__.py: -------------------------------------------------------------------------------- 1 | """The sia integration.""" 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.const import CONF_PORT 4 | from homeassistant.core import HomeAssistant 5 | from homeassistant.exceptions import ConfigEntryNotReady 6 | 7 | from .const import DOMAIN, PLATFORMS 8 | from .hub import SIAHub 9 | 10 | 11 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 12 | """Set up sia from a config entry.""" 13 | hub: SIAHub = SIAHub(hass, entry) 14 | await hub.async_setup_hub() 15 | 16 | hass.data.setdefault(DOMAIN, {}) 17 | hass.data[DOMAIN][entry.entry_id] = hub 18 | try: 19 | await hub.sia_client.start(reuse_port=True) 20 | except OSError as exc: 21 | raise ConfigEntryNotReady( 22 | f"SIA Server at port {entry.data[CONF_PORT]} could not start." 23 | ) from exc 24 | hass.config_entries.async_setup_platforms(entry, PLATFORMS) 25 | return True 26 | 27 | 28 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 29 | """Unload a config entry.""" 30 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 31 | if unload_ok: 32 | hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) 33 | await hub.async_shutdown() 34 | return unload_ok 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/unhandled_event_type.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Unhandled event type 3 | about: Create a report to help us support more event types 4 | 5 | --- 6 | 7 | 16 | 17 | ## Version of the custom_component 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | 26 | Add your configuration here. 27 | 28 | ``` 29 | 30 | ## Fill in the below info about the unhandled event 31 | SIA codes from your logs or from [SIA](SIA_code.pdf) and [supported alarm states](https://developers.home-assistant.io/docs/en/entity_alarm_control_panel.html) or [supported states for binary_sensors](https://developers.home-assistant.io/docs/en/entity_binary_sensor.html) 32 | 33 | SIA Code | sensor_type (alarm, smoke, moisture) | expected state 34 | -- | -- | -- 35 | 36 | ## Debug logs with the requested codes 37 | 38 | 39 | 40 | ```text 41 | 42 | Add your logs here. 43 | 44 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using black). 19 | 4. Issue that pull request! 20 | 21 | ## Any contributions you make will be under the MIT Software License 22 | 23 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 24 | 25 | ## Report bugs using Github's [issues](../../issues) 26 | 27 | GitHub issues are used to track public bugs. 28 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 29 | 30 | ## Write bug reports with detail, background, and sample code 31 | 32 | **Great Bug Reports** tend to have: 33 | 34 | - A quick summary and/or background 35 | - Steps to reproduce 36 | - Be specific! 37 | - Give sample code if you can. 38 | - What you expected would happen 39 | - What actually happens 40 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 41 | 42 | People *love* thorough bug reports. I'm not even kidding. 43 | 44 | ## Use a Consistent Coding Style 45 | 46 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 47 | 48 | ## License 49 | 50 | By contributing, you agree that your contributions will be licensed under its MIT License. 51 | -------------------------------------------------------------------------------- /custom_components/sia/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." 5 | }, 6 | "error": { 7 | "invalid_key_format": "The key is not a hex value, please use only 0-9 and A-F.", 8 | "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 characters hex characters.", 9 | "invalid_account_format": "The account is not a hex value, please use only 0-9 and A-F.", 10 | "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", 11 | "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", 12 | "invalid_zones": "There needs to be at least 1 zone.", 13 | "unknown": "Unexpected error" 14 | }, 15 | "step": { 16 | "user": { 17 | "data": { 18 | "name": "Name", 19 | "port": "Port", 20 | "account": "Account", 21 | "encryption_key": "Encryption Key", 22 | "ping_interval": "Ping Interval (min)", 23 | "zones": "Number of zones for the account", 24 | "ignore_timestamps": "Ignore the timestamp check", 25 | "additional_account": "Add more accounts?" 26 | }, 27 | "title": "Create a connection for SIA DC-09 based alarm systems." 28 | }, 29 | "additional_account": { 30 | "data": { 31 | "account": "Account", 32 | "encryption_key": "Encryption Key", 33 | "ping_interval": "Ping Interval (min)", 34 | "zones": "Number of zones for the account", 35 | "ignore_timestamps": "Ignore the timestamp check", 36 | "additional_account": "Add more accounts?" 37 | }, 38 | "title": "Add another account to the current port." 39 | } 40 | } 41 | }, 42 | "title": "SIA Alarm Systems" 43 | } -------------------------------------------------------------------------------- /custom_components/sia/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "port": "[%key:common::config_flow::data::port%]", 7 | "protocol": "Protocol", 8 | "account": "Account ID", 9 | "encryption_key": "Encryption Key", 10 | "ping_interval": "Ping Interval (min)", 11 | "zones": "Number of zones for the account", 12 | "additional_account": "Additional accounts" 13 | }, 14 | "title": "Create a connection for SIA based alarm systems." 15 | }, 16 | "additional_account": { 17 | "data": { 18 | "account": "[%key:component::sia::config::step::user::data::account%]", 19 | "encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]", 20 | "ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]", 21 | "zones": "[%key:component::sia::config::step::user::data::zones%]", 22 | "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" 23 | }, 24 | "title": "Add another account to the current port." 25 | } 26 | }, 27 | "error": { 28 | "invalid_key_format": "The key is not a hex value, please use only 0-9 and A-F.", 29 | "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 hex characters.", 30 | "invalid_account_format": "The account is not a hex value, please use only 0-9 and A-F.", 31 | "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", 32 | "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", 33 | "invalid_zones": "There needs to be at least 1 zone.", 34 | "unknown": "[%key:common::config_flow::error::unknown%]" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "options": { 40 | "data": { 41 | "ignore_timestamps": "Ignore the timestamp check of the SIA events", 42 | "zones": "[%key:component::sia::config::step::user::data::zones%]" 43 | }, 44 | "description": "Set the options for account: {account}", 45 | "title": "Options for the SIA Setup." 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # PyCharm stuff: 69 | .idea/ 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # HA Config directory for local testing 135 | /Config/ 136 | 137 | **/.DS_Store -------------------------------------------------------------------------------- /custom_components/sia/utils.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the SIA integration.""" 2 | from __future__ import annotations 3 | 4 | from datetime import timedelta 5 | from typing import Any 6 | 7 | from pysiaalarm import SIAEvent 8 | 9 | from homeassistant.util.dt import utcnow 10 | 11 | from .const import ATTR_CODE, ATTR_ID, ATTR_MESSAGE, ATTR_TIMESTAMP, ATTR_ZONE 12 | 13 | PING_INTERVAL_MARGIN = 30 14 | 15 | 16 | def get_unavailability_interval(ping: int) -> float: 17 | """Return the interval to the next unavailability check.""" 18 | return timedelta(minutes=ping, seconds=PING_INTERVAL_MARGIN).total_seconds() 19 | 20 | 21 | def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: 22 | """Create the attributes dict from a SIAEvent.""" 23 | return { 24 | ATTR_ZONE: event.ri, 25 | ATTR_CODE: event.code, 26 | ATTR_MESSAGE: event.message, 27 | ATTR_ID: event.id, 28 | ATTR_TIMESTAMP: event.timestamp.isoformat() 29 | if event.timestamp 30 | else utcnow().isoformat(), 31 | } 32 | 33 | 34 | def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: 35 | """Create a dict from the SIA Event for the HA Event.""" 36 | return { 37 | "message_type": event.message_type.value, 38 | "receiver": event.receiver, 39 | "line": event.line, 40 | "account": event.account, 41 | "sequence": event.sequence, 42 | "content": event.content, 43 | "ti": event.ti, 44 | "id": event.id, 45 | "ri": event.ri, 46 | "code": event.code, 47 | "message": event.message, 48 | "x_data": event.x_data, 49 | "timestamp": event.timestamp.isoformat() 50 | if event.timestamp 51 | else utcnow().isoformat(), 52 | "event_qualifier": event.event_qualifier, 53 | "event_type": event.event_type, 54 | "partition": event.partition, 55 | "extended_data": [ 56 | { 57 | "identifier": xd.identifier, 58 | "name": xd.name, 59 | "description": xd.description, 60 | "length": xd.length, 61 | "characters": xd.characters, 62 | "value": xd.value, 63 | } 64 | for xd in event.extended_data 65 | ] 66 | if event.extended_data is not None 67 | else None, 68 | "sia_code": { 69 | "code": event.sia_code.code, 70 | "type": event.sia_code.type, 71 | "description": event.sia_code.description, 72 | "concerns": event.sia_code.concerns, 73 | } 74 | if event.sia_code is not None 75 | else None, 76 | } 77 | -------------------------------------------------------------------------------- /.devcontainer/readme.md: -------------------------------------------------------------------------------- 1 | ## Developing with Visual Studio Code + devcontainer 2 | 3 | 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. 4 | 5 | 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. 6 | 7 | **Prerequisites** 8 | 9 | - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 10 | - Docker 11 | - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) 12 | - 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. 13 | - [Visual Studio code](https://code.visualstudio.com/) 14 | - [Remote - Containers (VSC Extension)][extension-link] 15 | 16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) 17 | 18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers 19 | 20 | **Getting started:** 21 | 22 | 1. Fork the repository. 23 | 2. Clone the repository to your computer. 24 | 3. Open the repository using Visual Studio code. 25 | 26 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. 27 | 28 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ 29 | 30 | ### Tasks 31 | 32 | 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. 33 | 34 | 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. 35 | 36 | The available tasks are: 37 | 38 | Task | Description 39 | -- | -- 40 | Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. 41 | Run Home Assistant configuration against /config | Check the configuration. 42 | Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. 43 | Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![hacs][hacs_badge]](hacs) 2 | 3 | _Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ 4 | 5 | **This component will set up the following platforms.** 6 | 7 | ## WARNING 8 | This integration may be unsecure. You can use it, but it's at your own risk. 9 | This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. 10 | 11 | Platform | Description 12 | -- | -- 13 | `binary_sensor` | A smoke or moisture sensor. 14 | `alarm_control_panel` | Alarm panel with the state of the alarm. 15 | `sensor` | Sensor with the last heartbeat message from your system. 16 | 17 | ## Features 18 | - Alarm tracking with a alarm_control_panel component 19 | - Optional Fire/gas tracker 20 | - Optional Water leak tracker 21 | - AES-128 CBC encryption support 22 | 23 | ## Hub Setup(Ajax Systems Hub example) 24 | 25 | 1. Select "SIA Protocol". 26 | 2. Enable "Connect on demand". 27 | 3. Place Account Id - 3-16 ASCII hex characters. For example AAA. 28 | 4. Insert Home Assistant IP adress. It must be visible to hub. There is no cloud connection to it. 29 | 5. Insert Home Assistant listening port. This port must not be used with anything else. 30 | 6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. 31 | 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. 32 | 8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. 33 | {% if not installed %} 34 | ## Installation 35 | 36 | 1. Click install. 37 | 1. Add at least the minimum configuration to your HA configuration, see below. 38 | 39 | 40 | ## Configuration options 41 | 42 | 43 | Key | Type | Required | Description 44 | -- | -- | -- | -- 45 | `port` | `int` | `True` | Port that SIA will listen on. 46 | `account` | `string` | `True` | Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. 47 | `encryption_key` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. 48 | `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. 49 | `zones` | `int` | `True` | The number of zones present for the account, default is 1. 50 | `additional_account` | `bool` | `True` | Used to ask for additional accounts in multiple steps during setup, default is False. 51 | 52 | ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. 53 | *** 54 | 55 | [sia]: https://github.com/eavanvalkenburg/sia-ha 56 | [ch_sia]: https://github.com/Cheaterdev/sia-ha 57 | [hacs]: https://github.com/custom-components/hacs 58 | [hacs_badge]: https://img.shields.io/badge/HACS-Default-orange.svg) -------------------------------------------------------------------------------- /custom_components/sia/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | """Module for SIA Alarm Control Panels.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | 7 | from pysiaalarm import SIAEvent 8 | 9 | from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import ( 12 | STATE_ALARM_ARMED_AWAY, 13 | STATE_ALARM_ARMED_CUSTOM_BYPASS, 14 | STATE_ALARM_ARMED_NIGHT, 15 | STATE_ALARM_DISARMED, 16 | STATE_ALARM_TRIGGERED, 17 | STATE_UNAVAILABLE, 18 | ) 19 | from homeassistant.core import HomeAssistant, State 20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 21 | from homeassistant.helpers.typing import StateType 22 | 23 | from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, SIA_UNIQUE_ID_FORMAT_ALARM 24 | from .sia_entity_base import SIABaseEntity 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | DEVICE_CLASS_ALARM = "alarm" 29 | PREVIOUS_STATE = "previous_state" 30 | 31 | CODE_CONSEQUENCES: dict[str, StateType] = { 32 | "PA": STATE_ALARM_TRIGGERED, 33 | "JA": STATE_ALARM_TRIGGERED, 34 | "TA": STATE_ALARM_TRIGGERED, 35 | "BA": STATE_ALARM_TRIGGERED, 36 | "CA": STATE_ALARM_ARMED_AWAY, 37 | "CB": STATE_ALARM_ARMED_AWAY, 38 | "CG": STATE_ALARM_ARMED_AWAY, 39 | "CL": STATE_ALARM_ARMED_AWAY, 40 | "CP": STATE_ALARM_ARMED_AWAY, 41 | "CQ": STATE_ALARM_ARMED_AWAY, 42 | "CS": STATE_ALARM_ARMED_AWAY, 43 | "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, 44 | "OA": STATE_ALARM_DISARMED, 45 | "OB": STATE_ALARM_DISARMED, 46 | "OG": STATE_ALARM_DISARMED, 47 | "OP": STATE_ALARM_DISARMED, 48 | "OQ": STATE_ALARM_DISARMED, 49 | "OR": STATE_ALARM_DISARMED, 50 | "OS": STATE_ALARM_DISARMED, 51 | "NC": STATE_ALARM_ARMED_NIGHT, 52 | "NL": STATE_ALARM_ARMED_NIGHT, 53 | "BR": PREVIOUS_STATE, 54 | "NP": PREVIOUS_STATE, 55 | "NO": PREVIOUS_STATE, 56 | } 57 | 58 | 59 | async def async_setup_entry( 60 | hass: HomeAssistant, 61 | entry: ConfigEntry, 62 | async_add_entities: AddEntitiesCallback, 63 | ) -> None: 64 | """Set up SIA alarm_control_panel(s) from a config entry.""" 65 | async_add_entities( 66 | SIAAlarmControlPanel(entry, account_data, zone) 67 | for account_data in entry.data[CONF_ACCOUNTS] 68 | for zone in range( 69 | 1, 70 | entry.options[CONF_ACCOUNTS][account_data[CONF_ACCOUNT]][CONF_ZONES] + 1, 71 | ) 72 | ) 73 | 74 | 75 | class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): 76 | """Class for SIA Alarm Control Panels.""" 77 | 78 | def __init__( 79 | self, 80 | entry: ConfigEntry, 81 | account_data: dict[str, Any], 82 | zone: int, 83 | ) -> None: 84 | """Create SIAAlarmControlPanel object.""" 85 | super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) 86 | self._attr_state: StateType = None 87 | self._old_state: StateType = None 88 | 89 | self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( 90 | self._entry.entry_id, self._account, self._zone 91 | ) 92 | 93 | def update_state(self, sia_event: SIAEvent) -> None: 94 | """Update the state of the alarm control panel.""" 95 | new_state = CODE_CONSEQUENCES.get(sia_event.code, None) 96 | if new_state is not None: 97 | _LOGGER.debug("New state will be %s", new_state) 98 | if new_state == PREVIOUS_STATE: 99 | new_state = self._old_state 100 | self._attr_state, self._old_state = new_state, self._attr_state 101 | 102 | def handle_last_state(self, last_state: State | None) -> None: 103 | """Handle the last state.""" 104 | if last_state is not None: 105 | self._attr_state = last_state.state 106 | if self.state == STATE_UNAVAILABLE: 107 | self._attr_available = False 108 | 109 | @property 110 | def supported_features(self) -> int: 111 | """Return the list of supported features.""" 112 | return 0 113 | -------------------------------------------------------------------------------- /custom_components/sia/sensor.py: -------------------------------------------------------------------------------- 1 | """Module for SIA Sensors.""" 2 | from __future__ import annotations 3 | 4 | from datetime import datetime as dt, timedelta 5 | import logging 6 | from typing import Any 7 | 8 | from pysiaalarm import SIAEvent 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_PORT, DEVICE_CLASS_TIMESTAMP 12 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback 13 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 14 | from homeassistant.helpers.entity import DeviceInfo 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.event import async_track_time_interval 17 | from homeassistant.helpers.restore_state import RestoreEntity 18 | from homeassistant.helpers.typing import StateType 19 | from homeassistant.util.dt import utcnow 20 | 21 | from .const import ( 22 | CONF_ACCOUNT, 23 | CONF_ACCOUNTS, 24 | CONF_PING_INTERVAL, 25 | DOMAIN, 26 | SIA_EVENT, 27 | SIA_NAME_FORMAT_SENSOR, 28 | SIA_UNIQUE_ID_FORMAT_SENSOR, 29 | ) 30 | from .utils import get_attr_from_sia_event 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | REGULAR_ICON = "mdi:clock-check" 35 | LATE_ICON = "mdi:clock-alert" 36 | 37 | 38 | async def async_setup_entry( 39 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 40 | ) -> None: 41 | """Set up sia_sensor from a config entry.""" 42 | async_add_entities( 43 | SIASensor(entry, account_data) for account_data in entry.data[CONF_ACCOUNTS] 44 | ) 45 | 46 | 47 | class SIASensor(RestoreEntity): 48 | """Class for SIA Sensors.""" 49 | 50 | def __init__( 51 | self, 52 | entry: ConfigEntry, 53 | account_data: dict[str, Any], 54 | ) -> None: 55 | """Create SIASensor object.""" 56 | self._entry: ConfigEntry = entry 57 | self._account_data: dict[str, Any] = account_data 58 | 59 | self._port: int = self._entry.data[CONF_PORT] 60 | self._account: str = self._account_data[CONF_ACCOUNT] 61 | self._ping_interval: timedelta = timedelta( 62 | minutes=self._account_data[CONF_PING_INTERVAL] 63 | ) 64 | 65 | self._state: dt = utcnow() 66 | self._cancel_icon_cb: CALLBACK_TYPE | None = None 67 | 68 | self._attr_extra_state_attributes: dict[str, Any] = {} 69 | self._attr_icon = REGULAR_ICON 70 | self._attr_unit_of_measurement = "ISO8601" 71 | self._attr_device_class = DEVICE_CLASS_TIMESTAMP 72 | self._attr_should_poll = False 73 | self._attr_name = SIA_NAME_FORMAT_SENSOR.format(self._port, self._account) 74 | self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_SENSOR.format( 75 | self._entry.entry_id, self._account 76 | ) 77 | 78 | async def async_added_to_hass(self) -> None: 79 | """Once the sensor is added, see if it was there before and pull in that state.""" 80 | await super().async_added_to_hass() 81 | last_state = await self.async_get_last_state() 82 | if last_state is not None and last_state.state is not None: 83 | self._state = dt.fromisoformat(last_state.state) 84 | self.async_update_icon() 85 | 86 | self.async_on_remove( 87 | async_dispatcher_connect( 88 | self.hass, 89 | SIA_EVENT.format(self._port, self._account), 90 | self.async_handle_event, 91 | ) 92 | ) 93 | self.async_on_remove( 94 | async_track_time_interval( 95 | self.hass, self.async_update_icon, self._ping_interval 96 | ) 97 | ) 98 | 99 | @callback 100 | def async_handle_event(self, sia_event: SIAEvent): 101 | """Listen to events for this port and account and update the state and attributes.""" 102 | self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) 103 | if sia_event.code == "RP": 104 | self._state = utcnow() 105 | self.async_update_icon() 106 | 107 | @callback 108 | def async_update_icon(self, *_) -> None: 109 | """Update the icon.""" 110 | if self._state < utcnow() - self._ping_interval: 111 | self._attr_icon = LATE_ICON 112 | else: 113 | self._attr_icon = REGULAR_ICON 114 | self.async_write_ha_state() 115 | 116 | @property 117 | def state(self) -> StateType: 118 | """Return state.""" 119 | return self._state.isoformat() 120 | 121 | @property 122 | def device_info(self) -> DeviceInfo: 123 | """Return the device_info.""" 124 | assert self._attr_unique_id is not None 125 | assert self._attr_name is not None 126 | return { 127 | "name": self._attr_name, 128 | "identifiers": {(DOMAIN, self._attr_unique_id)}, 129 | "via_device": (DOMAIN, f"{self._port}_{self._account}"), 130 | } 131 | -------------------------------------------------------------------------------- /custom_components/sia/sia_entity_base.py: -------------------------------------------------------------------------------- 1 | """Module for SIA Base Entity.""" 2 | from __future__ import annotations 3 | 4 | from abc import abstractmethod 5 | import logging 6 | from typing import Any 7 | 8 | from pysiaalarm import SIAEvent 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_PORT 12 | from homeassistant.core import CALLBACK_TYPE, State, callback 13 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 14 | from homeassistant.helpers.entity import DeviceInfo 15 | from homeassistant.helpers.event import async_call_later 16 | from homeassistant.helpers.restore_state import RestoreEntity 17 | 18 | from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DOMAIN, SIA_EVENT, SIA_NAME_FORMAT 19 | from .utils import get_attr_from_sia_event, get_unavailability_interval 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class SIABaseEntity(RestoreEntity): 25 | """Base class for SIA entities.""" 26 | 27 | def __init__( 28 | self, 29 | entry: ConfigEntry, 30 | account_data: dict[str, Any], 31 | zone: int, 32 | device_class: str, 33 | ) -> None: 34 | """Create SIABaseEntity object.""" 35 | self._entry: ConfigEntry = entry 36 | self._account_data: dict[str, Any] = account_data 37 | self._zone: int = zone 38 | self._attr_device_class: str = device_class 39 | 40 | self._port: int = self._entry.data[CONF_PORT] 41 | self._account: str = self._account_data[CONF_ACCOUNT] 42 | self._ping_interval: int = self._account_data[CONF_PING_INTERVAL] 43 | 44 | self._cancel_availability_cb: CALLBACK_TYPE | None = None 45 | 46 | self._attr_extra_state_attributes = {} 47 | self._attr_should_poll = False 48 | self._attr_name = SIA_NAME_FORMAT.format( 49 | self._port, self._account, self._zone, self._attr_device_class 50 | ) 51 | 52 | async def async_added_to_hass(self) -> None: 53 | """Run when entity about to be added to hass. 54 | 55 | Overridden from Entity. 56 | 57 | 1. register the dispatcher and add the callback to on_remove 58 | 2. get previous state from storage and pass to entity specific function 59 | 3. if available: create availability cb 60 | """ 61 | self.async_on_remove( 62 | async_dispatcher_connect( 63 | self.hass, 64 | SIA_EVENT.format(self._port, self._account), 65 | self.async_handle_event, 66 | ) 67 | ) 68 | self.handle_last_state(await self.async_get_last_state()) 69 | if self._attr_available: 70 | self.async_create_availability_cb() 71 | 72 | @abstractmethod 73 | def handle_last_state(self, last_state: State | None) -> None: 74 | """Handle the last state.""" 75 | 76 | async def async_will_remove_from_hass(self) -> None: 77 | """Run when entity will be removed from hass. 78 | 79 | Overridden from Entity. 80 | """ 81 | if self._cancel_availability_cb: 82 | self._cancel_availability_cb() 83 | 84 | @callback 85 | def async_handle_event(self, sia_event: SIAEvent) -> None: 86 | """Listen to dispatcher events for this port and account and update state and attributes. 87 | 88 | If the port and account combo receives any message it means it is online and can therefore be set to available. 89 | """ 90 | _LOGGER.debug("Received event: %s", sia_event) 91 | if int(sia_event.ri) == self._zone: 92 | self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) 93 | self.update_state(sia_event) 94 | self.async_reset_availability_cb() 95 | self.async_write_ha_state() 96 | 97 | @abstractmethod 98 | def update_state(self, sia_event: SIAEvent) -> None: 99 | """Do the entity specific state updates.""" 100 | 101 | @callback 102 | def async_reset_availability_cb(self) -> None: 103 | """Reset availability cb by cancelling the current and creating a new one.""" 104 | self._attr_available = True 105 | if self._cancel_availability_cb: 106 | self._cancel_availability_cb() 107 | self.async_create_availability_cb() 108 | 109 | def async_create_availability_cb(self) -> None: 110 | """Create a availability cb and return the callback.""" 111 | self._cancel_availability_cb = async_call_later( 112 | self.hass, 113 | get_unavailability_interval(self._ping_interval), 114 | self.async_set_unavailable, 115 | ) 116 | 117 | @callback 118 | def async_set_unavailable(self, _) -> None: 119 | """Set unavailable.""" 120 | self._attr_available = False 121 | self.async_write_ha_state() 122 | 123 | @property 124 | def device_info(self) -> DeviceInfo: 125 | """Return the device_info.""" 126 | assert self._attr_name is not None 127 | assert self.unique_id is not None 128 | return { 129 | "name": self._attr_name, 130 | "identifiers": {(DOMAIN, self.unique_id)}, 131 | "via_device": (DOMAIN, f"{self._port}_{self._account}"), 132 | } 133 | -------------------------------------------------------------------------------- /custom_components/sia/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Module for SIA Binary Sensors.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Iterable 5 | import logging 6 | from typing import Any 7 | 8 | from pysiaalarm import SIAEvent 9 | 10 | from homeassistant.components.binary_sensor import ( 11 | DEVICE_CLASS_MOISTURE, 12 | DEVICE_CLASS_POWER, 13 | DEVICE_CLASS_SMOKE, 14 | BinarySensorEntity, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE 18 | from homeassistant.core import HomeAssistant, State 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | 21 | from .const import ( 22 | CONF_ACCOUNT, 23 | CONF_ACCOUNTS, 24 | CONF_ZONES, 25 | SIA_HUB_ZONE, 26 | SIA_UNIQUE_ID_FORMAT_BINARY, 27 | ) 28 | from .sia_entity_base import SIABaseEntity 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | POWER_CODE_CONSEQUENCES: dict[str, bool] = { 34 | "AT": False, 35 | "AR": True, 36 | } 37 | 38 | SMOKE_CODE_CONSEQUENCES: dict[str, bool] = { 39 | "GA": True, 40 | "GH": False, 41 | "FA": True, 42 | "FH": False, 43 | "KA": True, 44 | "KH": False, 45 | } 46 | 47 | MOISTURE_CODE_CONSEQUENCES: dict[str, bool] = { 48 | "WA": True, 49 | "WH": False, 50 | } 51 | 52 | 53 | def generate_binary_sensors(entry) -> Iterable[SIABinarySensorBase]: 54 | """Generate binary sensors. 55 | 56 | For each Account there is one power sensor with zone == 0. 57 | For each Zone in each Account there is one smoke and one moisture sensor. 58 | """ 59 | for account in entry.data[CONF_ACCOUNTS]: 60 | yield SIABinarySensorPower(entry, account) 61 | zones = entry.options[CONF_ACCOUNTS][account[CONF_ACCOUNT]][CONF_ZONES] 62 | for zone in range(1, zones + 1): 63 | yield SIABinarySensorSmoke(entry, account, zone) 64 | yield SIABinarySensorMoisture(entry, account, zone) 65 | 66 | 67 | async def async_setup_entry( 68 | hass: HomeAssistant, 69 | entry: ConfigEntry, 70 | async_add_entities: AddEntitiesCallback, 71 | ) -> None: 72 | """Set up SIA binary sensors from a config entry.""" 73 | async_add_entities(generate_binary_sensors(entry)) 74 | 75 | 76 | class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): 77 | """Class for SIA Binary Sensors.""" 78 | 79 | def __init__( 80 | self, 81 | entry: ConfigEntry, 82 | account_data: dict[str, Any], 83 | zone: int, 84 | device_class: str, 85 | ) -> None: 86 | """Initialize a base binary sensor.""" 87 | super().__init__(entry, account_data, zone, device_class) 88 | 89 | self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( 90 | self._entry.entry_id, self._account, self._zone, self._attr_device_class 91 | ) 92 | 93 | def handle_last_state(self, last_state: State | None) -> None: 94 | """Handle the last state.""" 95 | if last_state is not None and last_state.state is not None: 96 | if last_state.state == STATE_ON: 97 | self._attr_is_on = True 98 | elif last_state.state == STATE_OFF: 99 | self._attr_is_on = False 100 | elif last_state.state == STATE_UNAVAILABLE: 101 | self._attr_available = False 102 | 103 | 104 | class SIABinarySensorMoisture(SIABinarySensorBase): 105 | """Class for Moisture Binary Sensors.""" 106 | 107 | def __init__( 108 | self, 109 | entry: ConfigEntry, 110 | account_data: dict[str, Any], 111 | zone: int, 112 | ) -> None: 113 | """Initialize a Moisture binary sensor.""" 114 | super().__init__(entry, account_data, zone, DEVICE_CLASS_MOISTURE) 115 | self._attr_entity_registry_enabled_default = False 116 | 117 | def update_state(self, sia_event: SIAEvent) -> None: 118 | """Update the state of the binary sensor.""" 119 | new_state = MOISTURE_CODE_CONSEQUENCES.get(sia_event.code, None) 120 | if new_state is not None: 121 | _LOGGER.debug("New state will be %s", new_state) 122 | self._attr_is_on = new_state 123 | 124 | 125 | class SIABinarySensorSmoke(SIABinarySensorBase): 126 | """Class for Smoke Binary Sensors.""" 127 | 128 | def __init__( 129 | self, 130 | entry: ConfigEntry, 131 | account_data: dict[str, Any], 132 | zone: int, 133 | ) -> None: 134 | """Initialize a Smoke binary sensor.""" 135 | super().__init__(entry, account_data, zone, DEVICE_CLASS_SMOKE) 136 | self._attr_entity_registry_enabled_default = False 137 | 138 | def update_state(self, sia_event: SIAEvent) -> None: 139 | """Update the state of the binary sensor.""" 140 | new_state = SMOKE_CODE_CONSEQUENCES.get(sia_event.code, None) 141 | if new_state is not None: 142 | _LOGGER.debug("New state will be %s", new_state) 143 | self._attr_is_on = new_state 144 | 145 | 146 | class SIABinarySensorPower(SIABinarySensorBase): 147 | """Class for Power Binary Sensors.""" 148 | 149 | def __init__( 150 | self, 151 | entry: ConfigEntry, 152 | account_data: dict[str, Any], 153 | ) -> None: 154 | """Initialize a Power binary sensor.""" 155 | super().__init__(entry, account_data, SIA_HUB_ZONE, DEVICE_CLASS_POWER) 156 | self._attr_entity_registry_enabled_default = True 157 | 158 | def update_state(self, sia_event: SIAEvent) -> None: 159 | """Update the state of the binary sensor.""" 160 | new_state = POWER_CODE_CONSEQUENCES.get(sia_event.code, None) 161 | if new_state is not None: 162 | _LOGGER.debug("New state will be %s", new_state) 163 | self._attr_is_on = new_state 164 | -------------------------------------------------------------------------------- /custom_components/sia/hub.py: -------------------------------------------------------------------------------- 1 | """The sia hub.""" 2 | from __future__ import annotations 3 | 4 | from copy import deepcopy 5 | import logging 6 | from typing import Any 7 | 8 | from pysiaalarm.aio import CommunicationsProtocol 9 | from pysiaalarm.aio import SIAAccount 10 | from pysiaalarm.aio import SIAClient 11 | from pysiaalarm.aio import SIAEvent 12 | 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP 15 | from homeassistant.core import Event, HomeAssistant 16 | from homeassistant.helpers import device_registry as dr 17 | from homeassistant.helpers.dispatcher import async_dispatcher_send 18 | 19 | from .const import ( 20 | CONF_ACCOUNT, 21 | CONF_ACCOUNTS, 22 | CONF_ENCRYPTION_KEY, 23 | CONF_IGNORE_TIMESTAMPS, 24 | CONF_ZONES, 25 | DOMAIN, 26 | PLATFORMS, 27 | SIA_EVENT, 28 | ) 29 | from .utils import get_event_data_from_sia_event 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | DEFAULT_TIMEBAND = (80, 40) 35 | IGNORED_TIMEBAND = (3600, 1800) 36 | 37 | 38 | class SIAHub: 39 | """Class for SIA Hubs.""" 40 | 41 | def __init__( 42 | self, 43 | hass: HomeAssistant, 44 | entry: ConfigEntry, 45 | ) -> None: 46 | """Create the SIAHub.""" 47 | self._hass: HomeAssistant = hass 48 | self._entry: ConfigEntry = entry 49 | self._port: int = entry.data[CONF_PORT] 50 | self._title: str = entry.title 51 | self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) 52 | self._protocol: str = entry.data[CONF_PROTOCOL] 53 | self.sia_accounts: list[SIAAccount] | None = None 54 | self.sia_client: SIAClient = None 55 | 56 | async def async_setup_hub(self) -> None: 57 | """Add a device to the device_registry, register shutdown listener, load reactions.""" 58 | self.update_accounts() 59 | device_registry = await dr.async_get_registry(self._hass) 60 | for acc in self._accounts: 61 | account = acc[CONF_ACCOUNT] 62 | device_registry.async_get_or_create( 63 | config_entry_id=self._entry.entry_id, 64 | identifiers={(DOMAIN, f"{self._port}_{account}")}, 65 | name=f"{self._port} - {account}", 66 | ) 67 | self._entry.async_on_unload( 68 | self._entry.add_update_listener(self.async_config_entry_updated) 69 | ) 70 | self._entry.async_on_unload( 71 | self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) 72 | ) 73 | 74 | async def async_shutdown(self, _: Event = None) -> None: 75 | """Shutdown the SIA server.""" 76 | await self.sia_client.stop() 77 | 78 | async def async_create_and_fire_event(self, event: SIAEvent) -> None: 79 | """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. 80 | 81 | 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. 82 | 83 | """ 84 | _LOGGER.debug( 85 | "Adding event to dispatch and bus for code %s for port %s and account %s", 86 | event.code, 87 | self._port, 88 | event.account, 89 | ) 90 | async_dispatcher_send( 91 | self._hass, SIA_EVENT.format(self._port, event.account), event 92 | ) 93 | self._hass.bus.async_fire( 94 | event_type=SIA_EVENT.format(self._port, event.account), 95 | event_data=get_event_data_from_sia_event(event), 96 | ) 97 | 98 | def update_accounts(self): 99 | """Update the SIA_Accounts variable.""" 100 | self._load_options() 101 | self.sia_accounts = [ 102 | SIAAccount( 103 | account_id=a[CONF_ACCOUNT], 104 | key=a.get(CONF_ENCRYPTION_KEY), 105 | allowed_timeband=IGNORED_TIMEBAND 106 | if a[CONF_IGNORE_TIMESTAMPS] 107 | else DEFAULT_TIMEBAND, 108 | ) 109 | for a in self._accounts 110 | ] 111 | if self.sia_client is not None: 112 | self.sia_client.accounts = self.sia_accounts 113 | return 114 | self.sia_client = SIAClient( 115 | host="", 116 | port=self._port, 117 | accounts=self.sia_accounts, 118 | function=self.async_create_and_fire_event, 119 | protocol=CommunicationsProtocol(self._protocol), 120 | ) 121 | 122 | def _load_options(self) -> None: 123 | """Store attributes to avoid property call overhead since they are called frequently.""" 124 | options = dict(self._entry.options) 125 | for acc in self._accounts: 126 | acc_id = acc[CONF_ACCOUNT] 127 | if acc_id in options[CONF_ACCOUNTS]: 128 | acc[CONF_IGNORE_TIMESTAMPS] = options[CONF_ACCOUNTS][acc_id][ 129 | CONF_IGNORE_TIMESTAMPS 130 | ] 131 | acc[CONF_ZONES] = options[CONF_ACCOUNTS][acc_id][CONF_ZONES] 132 | 133 | @staticmethod 134 | async def async_config_entry_updated( 135 | hass: HomeAssistant, config_entry: ConfigEntry 136 | ) -> None: 137 | """Handle signals of config entry being updated. 138 | 139 | First, update the accounts, this will reflect any changes with ignore_timestamps. 140 | Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. 141 | 142 | """ 143 | if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): 144 | return 145 | hub.update_accounts() 146 | await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 147 | hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) 148 | -------------------------------------------------------------------------------- /codes.csv: -------------------------------------------------------------------------------- 1 | CODE,DESCRIPTION 2 | AN,ANALOG RESTORAL 3 | AR,AC RESTORAL 4 | AS,ANALOG SERVICE 5 | AT,AC TROUBLE 6 | BA,BURGLARY ALARM 7 | BB,BURGLARY BYPASS 8 | BC,BURGLARY CANCEL 9 | BD,SWINGER TROUBLE 10 | BE,SWINGER TRBL RESTORE 11 | BH,BURG ALARM RESTORE 12 | BJ,BURG TROUBLE RESTORE 13 | BM,BURG ALARM CROSS PNT 14 | BR,BURGLARY RESTORAL 15 | BS,BURGLARY SUPERVISORY 16 | BT,BURGLARY TROUBLE 17 | BU,BURGLARY UNBYPASS 18 | BV,BURGLARY VERIFIED 19 | BX,BURGLARY TEST 20 | BZ,MISSING SUPERVISION 21 | CA,AUTOMATIC CLOSING 22 | CD,CLOSING DELINQUENT 23 | CE,CLOSING EXTEND 24 | CF,FORCED CLOSING 25 | CG,CLOSE AREA 26 | CI,FAIL TO CLOSE 27 | CJ,LATE CLOSE 28 | CK,EARLY CLOSE 29 | CL,CLOSING REPORT 30 | CM,MISSING AL-RECNT CLS 31 | CP,AUTOMATIC CLOSING 32 | CR,RECENT CLOSING 33 | CS,CLOSE KEY SWITCH 34 | CT,LATE TO OPEN 35 | CW,WAS FORCE ARMED 36 | CZ,POINT CLOSING 37 | DA,CARD ASSIGNED 38 | DB,CARD DELETED 39 | DC,ACCESS CLOSED 40 | DD,ACCESS DENIED 41 | DE,REQUEST TO ENTER 42 | DF,DOOR FORCED 43 | DG,ACCESS GRANTED 44 | DH,DOOR LEFT OPEN-RSTRL 45 | DJ,DOOR FORCED-TROUBLE 46 | DK,ACCESS LOCKOUT 47 | DL,DOOR LEFT OPEN-ALARM 48 | DM,DOOR LEFT OPEN-TRBL 49 | DN,DOOR LEFT OPEN 50 | DO,ACCESS OPEN 51 | DP,ACCESS DENIED-BAD TM 52 | DQ,ACCESS DENIED-UN ARM 53 | DR,DOOR RESTORAL 54 | DS,DOOR STATION 55 | DT,ACCESS TROUBLE 56 | DU,DEALER ID# 57 | DV,ACCESS DENIED-UN ENT 58 | DW,ACCESS DENIED-INTRLK 59 | DX,REQUEST TO EXIT 60 | DY,DOOR LOCKED 61 | DZ,ACCESS CLOSED STATE 62 | EA,EXIT ALARM 63 | EE,EXIT_ERROR 64 | ER,EXPANSION RESTORAL 65 | ET,EXPANSION TROUBLE 66 | EX,EXTRNL DEVICE STATE 67 | EZ,MISSING ALARM-EXT ER 68 | FA,FIRE ALARM 69 | FB,FIRE BYPASS 70 | FC,FIRE CANCEL 71 | FH,FIRE ALARM RESTORE 72 | FI,FIRE TEST BEGIN 73 | FJ,FIRE TROUBLE RESTORE 74 | FK,FIRE TEST END 75 | FM,FIRE ALARM CROSS PNT 76 | FR,FIRE RESTORAL 77 | FS,FIRE SUPERVISORY 78 | FT,FIRE TROUBLE 79 | FU,FIRE UNBYPASS 80 | FX,FIRE TEST 81 | FY,MISSING FIRE TROUBLE 82 | FZ,MISSING FIRE SPRV 83 | GA,GAS ALARM 84 | GB,GAS BYPASS 85 | GH,GAS ALARM RESTORE 86 | GJ,GAS TROUBLE RESTORE 87 | GR,GAS RESTORAL 88 | GS,GAS SUPERVISORY 89 | GT,GAS TROUBLE 90 | GU,GAS UNBYPASS 91 | GX,GAS TEST 92 | HA,HOLDUP ALARM 93 | HB,HOLDUP BYPASS 94 | HH,HOLDUP ALARM RESTORE 95 | HJ,HOLDUP TRBL RESTORE 96 | HR,HOLDUP RESTORAL 97 | HS,HOLDUP SUPERVISORY 98 | HT,HOLDUP TROUBLE 99 | HU,HOLDUP UNBYPASS 100 | IA,EQPMT FAIL CONDITION 101 | IR,EQPMT FAIL RESTORE 102 | JA,USER CODE TAMPER 103 | JD,DATE CHANGED 104 | JH,HOLIDAY CHANGED 105 | JK,LATCHKEY ALERT 106 | JL,LOG THRESHOLD 107 | JO,LOG OVERFLOW 108 | JP,USER ON PREMISES 109 | JR,SCHEDULE EXECUTED 110 | JS,SCHEDULE CHANGED 111 | JT,TIME CHANGED 112 | JV,USER CODE CHANGED 113 | JX,USER CODE DELETED 114 | JY,USER CODE ADDED 115 | JZ,USER LEVEL SET 116 | KA,HEAT ALARM 117 | KB,HEAT BYPASS 118 | KH,HEAT ALARM RESTORE 119 | KJ,HEAT TROUBLE RESTORE 120 | KR,HEAT RESTORAL 121 | KS,HEAT SUPERVISORY 122 | KT,HEAT TROUBLE 123 | KU,HEAT UNBYPASS 124 | L_,LISTEN IN + SECONDS 125 | LB,LOCAL PROG. BEGIN 126 | LD,LOCAL PROG. DENIED 127 | LE,LISTEN IN ENDED 128 | LF,LISTEN IN BEGIN 129 | LR,PHONE LINE RESTORAL 130 | LS,LOCAL PROG. SUCCESS 131 | LT,PHONE LINE TROUBLE 132 | LU,LOCAL PROG. FAIL 133 | LX,LOCAL PROG. ENDED 134 | MA,MEDICAL ALARM 135 | MB,MEDICAL BYPASS 136 | MH,MEDIC ALARM RESTORE 137 | MJ,MEDICAL TRBL RESTORE 138 | MR,MEDICAL RESTORAL 139 | MS,MEDICAL SUPERVISORY 140 | MT,MEDICAL TROUBLE 141 | MU,MEDICAL UNBYPASS 142 | NA,NO ACTIVITY 143 | NC,NETWORK CONDITION 144 | NF,FORCED PERIMETER ARM 145 | NL,PERIMETER ARMED 146 | NR,NETWORK RESTORAL 147 | NS,ACTIVITY RESUMED 148 | NT,NETWORK FAILURE 149 | OA,AUTOMATIC OPENING 150 | OC,CANCEL REPORT 151 | OG,OPEN AREA 152 | OH,EARLY TO OPN FROM AL 153 | OI,FAIL TO OPEN 154 | OJ,LATE OPEN 155 | OK,EARLY OPEN 156 | OL,LATE TO OPEN FROM AL 157 | OP,OPENING REPORT 158 | OR,DISARM FROM ALARM 159 | OS,OPEN KEY SWITCH 160 | OT,LATE TO CLOSE 161 | OZ,POINT OPENING 162 | PA,PANIC ALARM 163 | PB,PANIC BYPASS 164 | PH,PANIC ALARM RESTORE 165 | PJ,PANIC TRBL RESTORE 166 | PR,PANIC RESTORAL 167 | PS,PANIC SUPERVISORY 168 | PT,PANIC TROUBLE 169 | PU,PANIC UNBYPASS 170 | QA,EMERGENCY ALARM 171 | QB,EMERGENCY BYPASS 172 | QH,EMRGCY ALARM RESTORE 173 | QJ,EMRGCY TRBL RESTORE 174 | QR,EMERGENCY RESTORAL 175 | QS,EMRGCY SUPERVISORY 176 | QT,EMERGENCY TROUBLE 177 | QU,EMERGENCY UNBYPASS 178 | RA,RMOTE PROG CALL FAIL 179 | RB,REMOTE PROG. BEGIN 180 | RC,RELAY CLOSE 181 | RD,REMOTE PROG. DENIED 182 | RN,REMOTE RESET 183 | RO,RELAY OPEN 184 | RP,AUTOMATIC TEST 185 | RR,RESTORE POWER 186 | RS,REMOTE PROG. SUCCESS 187 | RT,DATA LOST 188 | RU,REMOTE PROG. FAIL 189 | RX,MANUAL TEST 190 | RY,TEST OFF NORMAL 191 | SA,SPRINKLER ALARM 192 | SB,SPRINKLER BYPASS 193 | SH,SPRKLR ALARM RESTORE 194 | SJ,SPRKLR TRBL RESTORE 195 | SR,SPRINKLER RESTORAL 196 | SS,SPRINKLER SUPERVISRY 197 | ST,SPRINKLER TROUBLE 198 | SU,SPRINKLER UNBYPASS 199 | TA,TAMPER ALARM 200 | TB,TAMPER BYPASS 201 | TC,ALL POINTS TESTED 202 | TE,TEST END 203 | TH,TAMPER ALRM RESTORE 204 | TJ,TAMPER TRBL RESTORE 205 | TP,WALK TEST POINT 206 | TR,TAMPER RESTORAL 207 | TS,TEST START 208 | TT,TAMPER TROUBLE 209 | TU,TAMPER UNBYPASS 210 | TX,TEST REPORT 211 | UA,UNTYPED ZONE ALARM 212 | UB,UNTYPED ZONE BYPASS 213 | UH,UNTYPD ALARM RESTORE 214 | UJ,UNTYPED TRBL RESTORE 215 | UR,UNTYPED ZONE RESTORE 216 | US,UNTYPED ZONE SUPRVRY 217 | UT,UNTYPED ZONE TROUBLE 218 | UU,UNTYPED ZONE UNBYPSS 219 | UX,UNDEFINED ALARM 220 | UY,UNTYPED MISSING TRBL 221 | UZ,UNTYPED MISSING ALRM 222 | VI,PRINTER PAPER IN 223 | VO,PRINTER PAPER OUT 224 | VR,PRINTER RESTORE 225 | VT,PRINTER TROUBLE 226 | VX,PRINTER TEST 227 | VY,PRINTER ONLINE 228 | VZ,PRINTER OFFLINE 229 | WA,WATER ALARM 230 | WB,WATER BYPASS 231 | WH,WATER ALARM RESTORE 232 | WJ,WATER TRBL RESTORE 233 | WR,WATER RESTORAL 234 | WS,WATER SUPERVISORY 235 | WT,WATER TROUBLE 236 | WU,WATER UNBYPASS 237 | XA,EXTRA ACCNT REPORT 238 | XE,EXTRA POINT 239 | XF,EXTRA RF POINT 240 | XH,RF INTERFERENCE RST 241 | XI,SENSOR RESET 242 | XJ,RF RCVR TAMPER RST 243 | XL,LOW RF SIGNAL 244 | XM,MISSING ALRM-X POINT 245 | XQ,RF INTERFERENCE 246 | XR,TRANS. BAT. RESTORAL 247 | XS,RF RECEIVER TAMPER 248 | XT,TRANS. BAT. TROUBLE 249 | XW,FORCED POINT 250 | XX,FAIL TO TEST 251 | YA,BELL FAULT 252 | YB,BUSY SECONDS 253 | YC,COMMUNICATIONS FAIL 254 | YD,RCV LINECARD TROUBLE 255 | YE,RCV LINECARD RESTORE 256 | YF,PARA CHECKSUM FAIL 257 | YG,PARAMETER CHANGED 258 | YH,BELL RESTORED 259 | YI,OVERCURRENT TROUBLE 260 | YJ,OVERCURRENT RESTORE 261 | YK,COMM. RESTORAL 262 | YM,SYSTEM BATT MISSING 263 | YN,INVALID REPORT 264 | YO,UNKNOWN MESSAGE 265 | YP,PWR SUPPLY TROUBLE 266 | YQ,PWR SUPPLY RESTORE 267 | YR,SYSTEM BAT. RESTORAL 268 | YS,COMMUNICATIONS TRBL 269 | YT,SYSTEM BAT. TROUBLE 270 | YW,WATCHDOG RESET 271 | YX,SERVICE REQUIRED 272 | YY,STATUS REPORT 273 | YZ,SERVICE COMPLETED 274 | ZA,FREEZE ALARM 275 | ZB,FREEZE BYPASS 276 | ZH,FREEZE ALARM RESTORE 277 | ZJ,FREEZE TRBL RESTORE 278 | ZR,FREEZE RESTORAL 279 | ZS,FREEZE SUPERVISORY 280 | ZT,FREEZE TROUBLE 281 | ZU,FREEZE UNBYPASS 282 | -------------------------------------------------------------------------------- /custom_components/sia/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for sia integration.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Mapping 5 | from copy import deepcopy 6 | import logging 7 | from typing import Any 8 | 9 | from pysiaalarm import ( 10 | InvalidAccountFormatError, 11 | InvalidAccountLengthError, 12 | InvalidKeyFormatError, 13 | InvalidKeyLengthError, 14 | SIAAccount, 15 | ) 16 | import voluptuous as vol 17 | 18 | from homeassistant import config_entries 19 | from homeassistant.const import CONF_PORT, CONF_PROTOCOL 20 | from homeassistant.core import callback 21 | from homeassistant.data_entry_flow import FlowResult 22 | 23 | from .const import ( 24 | CONF_ACCOUNT, 25 | CONF_ACCOUNTS, 26 | CONF_ADDITIONAL_ACCOUNTS, 27 | CONF_ENCRYPTION_KEY, 28 | CONF_IGNORE_TIMESTAMPS, 29 | CONF_PING_INTERVAL, 30 | CONF_ZONES, 31 | DOMAIN, 32 | TITLE, 33 | ) 34 | from .hub import SIAHub 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | HUB_SCHEMA = vol.Schema( 40 | { 41 | vol.Required(CONF_PORT): int, 42 | vol.Optional(CONF_PROTOCOL, default="TCP"): vol.In(["TCP", "UDP"]), 43 | vol.Required(CONF_ACCOUNT): str, 44 | vol.Optional(CONF_ENCRYPTION_KEY): str, 45 | vol.Required(CONF_PING_INTERVAL, default=1): int, 46 | vol.Required(CONF_ZONES, default=1): int, 47 | vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, 48 | } 49 | ) 50 | 51 | ACCOUNT_SCHEMA = vol.Schema( 52 | { 53 | vol.Required(CONF_ACCOUNT): str, 54 | vol.Optional(CONF_ENCRYPTION_KEY): str, 55 | vol.Required(CONF_PING_INTERVAL, default=1): int, 56 | vol.Required(CONF_ZONES, default=1): int, 57 | vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, 58 | } 59 | ) 60 | 61 | DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} 62 | 63 | 64 | def validate_input(data: dict[str, Any]) -> dict[str, str] | None: 65 | """Validate the input by the user.""" 66 | try: 67 | SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) 68 | except InvalidKeyFormatError: 69 | return {"base": "invalid_key_format"} 70 | except InvalidKeyLengthError: 71 | return {"base": "invalid_key_length"} 72 | except InvalidAccountFormatError: 73 | return {"base": "invalid_account_format"} 74 | except InvalidAccountLengthError: 75 | return {"base": "invalid_account_length"} 76 | except Exception as exc: # pylint: disable=broad-except 77 | _LOGGER.exception("Unexpected exception from SIAAccount: %s", exc) 78 | return {"base": "unknown"} 79 | if not 1 <= data[CONF_PING_INTERVAL] <= 1440: 80 | return {"base": "invalid_ping"} 81 | return validate_zones(data) 82 | 83 | 84 | def validate_zones(data: dict[str, Any]) -> dict[str, str] | None: 85 | """Validate the zones field.""" 86 | if data[CONF_ZONES] == 0: 87 | return {"base": "invalid_zones"} 88 | return None 89 | 90 | 91 | class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 92 | """Handle a config flow for sia.""" 93 | 94 | VERSION: int = 1 95 | 96 | @staticmethod 97 | @callback 98 | def async_get_options_flow(config_entry): 99 | """Get the options flow for this handler.""" 100 | return SIAOptionsFlowHandler(config_entry) 101 | 102 | def __init__(self): 103 | """Initialize the config flow.""" 104 | self._data: dict[str, Any] = {} 105 | self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}} 106 | 107 | async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: 108 | """Handle the initial user step.""" 109 | errors: dict[str, str] | None = None 110 | if user_input is not None: 111 | errors = validate_input(user_input) 112 | if user_input is None or errors is not None: 113 | return self.async_show_form( 114 | step_id="user", data_schema=HUB_SCHEMA, errors=errors 115 | ) 116 | return await self.async_handle_data_and_route(user_input) 117 | 118 | async def async_step_add_account( 119 | self, user_input: dict[str, Any] = None 120 | ) -> FlowResult: 121 | """Handle the additional accounts steps.""" 122 | errors: dict[str, str] | None = None 123 | if user_input is not None: 124 | errors = validate_input(user_input) 125 | if user_input is None or errors is not None: 126 | return self.async_show_form( 127 | step_id="add_account", data_schema=ACCOUNT_SCHEMA, errors=errors 128 | ) 129 | return await self.async_handle_data_and_route(user_input) 130 | 131 | async def async_handle_data_and_route( 132 | self, user_input: dict[str, Any] 133 | ) -> FlowResult: 134 | """Handle the user_input, check if configured and route to the right next step or create entry.""" 135 | self._update_data(user_input) 136 | 137 | self._async_abort_entries_match({CONF_PORT: self._data[CONF_PORT]}) 138 | 139 | if user_input[CONF_ADDITIONAL_ACCOUNTS]: 140 | return await self.async_step_add_account() 141 | return self.async_create_entry( 142 | title=TITLE.format(self._data[CONF_PORT]), 143 | data=self._data, 144 | options=self._options, 145 | ) 146 | 147 | def _update_data(self, user_input: dict[str, Any]) -> None: 148 | """Parse the user_input and store in data and options attributes. 149 | 150 | If there is a port in the input or no data, assume it is fully new and overwrite. 151 | Add the default options and overwrite the zones in options. 152 | """ 153 | if not self._data or user_input.get(CONF_PORT): 154 | self._data = { 155 | CONF_PORT: user_input[CONF_PORT], 156 | CONF_PROTOCOL: user_input[CONF_PROTOCOL], 157 | CONF_ACCOUNTS: [], 158 | } 159 | account = user_input[CONF_ACCOUNT] 160 | self._data[CONF_ACCOUNTS].append( 161 | { 162 | CONF_ACCOUNT: account, 163 | CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), 164 | CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], 165 | } 166 | ) 167 | self._options[CONF_ACCOUNTS].setdefault(account, deepcopy(DEFAULT_OPTIONS)) 168 | self._options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] 169 | 170 | 171 | class SIAOptionsFlowHandler(config_entries.OptionsFlow): 172 | """Handle SIA options.""" 173 | 174 | def __init__(self, config_entry): 175 | """Initialize SIA options flow.""" 176 | self.config_entry = config_entry 177 | self.options = deepcopy(dict(config_entry.options)) 178 | self.hub: SIAHub | None = None 179 | self.accounts_todo: list = [] 180 | 181 | async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: 182 | """Manage the SIA options.""" 183 | self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] 184 | assert self.hub is not None 185 | assert self.hub.sia_accounts is not None 186 | self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] 187 | return await self.async_step_options() 188 | 189 | async def async_step_options(self, user_input: dict[str, Any] = None) -> FlowResult: 190 | """Create the options step for a account.""" 191 | errors: dict[str, str] | None = None 192 | if user_input is not None: 193 | errors = validate_zones(user_input) 194 | if user_input is None or errors is not None: 195 | account = self.accounts_todo[0] 196 | return self.async_show_form( 197 | step_id="options", 198 | description_placeholders={"account": account}, 199 | data_schema=vol.Schema( 200 | { 201 | vol.Optional( 202 | CONF_ZONES, 203 | default=self.options[CONF_ACCOUNTS][account][CONF_ZONES], 204 | ): int, 205 | vol.Optional( 206 | CONF_IGNORE_TIMESTAMPS, 207 | default=self.options[CONF_ACCOUNTS][account][ 208 | CONF_IGNORE_TIMESTAMPS 209 | ], 210 | ): bool, 211 | } 212 | ), 213 | errors=errors, 214 | last_step=self.last_step, 215 | ) 216 | 217 | account = self.accounts_todo.pop(0) 218 | self.options[CONF_ACCOUNTS][account][CONF_IGNORE_TIMESTAMPS] = user_input[ 219 | CONF_IGNORE_TIMESTAMPS 220 | ] 221 | self.options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] 222 | if self.accounts_todo: 223 | return await self.async_step_options() 224 | return self.async_create_entry(title="", data=self.options) 225 | 226 | @property 227 | def last_step(self) -> bool: 228 | """Return if this is the last step.""" 229 | return len(self.accounts_todo) <= 1 230 | --------------------------------------------------------------------------------