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/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6bcce42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..da55566 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component and HA setup (version, OS, etc) + + +## Configuration + +```yaml + +Add your configuration here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/unhandled_event_type.md b/.github/ISSUE_TEMPLATE/unhandled_event_type.md new file mode 100644 index 0000000..9ca0bb1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/unhandled_event_type.md @@ -0,0 +1,44 @@ +--- +name: Unhandled event type +about: Create a report to help us support more event types + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your configuration here. + +``` + +## Fill in the below info about the unhandled event +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) + +SIA Code | sensor_type (alarm, smoke, moisture) | expected state +-- | -- | -- + +## Debug logs with the requested codes + + + +```text + +Add your logs here. + +``` \ 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/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..6b75ccc --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,23 @@ +repository: + private: false + has_issues: true + has_projects: false + has_wiki: false + has_downloads: false + default_branch: master + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: false +labels: + - name: "Feature Request" + color: "fbca04" + - name: "Bug" + color: "b60205" + - name: "Wont Fix" + color: "ffffff" + - name: "Enhancement" + color: a2eeef + - name: "Documentation" + color: "008672" + - name: "Stale" + color: "930191" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49260ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +# 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 new file mode 100644 index 0000000..7ab4ba8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e91c221 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +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. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eadefd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Joakim Sørensen @ludeeus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 3b17c6e..fa01b83 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,10 @@ -# sia-ha -SIA alarm systems integration into Home Assistant -Based on https://github.com/bitblaster/alarmreceiver -## 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) -## Features -- Fire/gas tracker -- Water leak tracker -- Alarm tracking -- Armed state tracking -- Partial armed state tracking -- AES-128 CBC encryption support +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). -## 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 adress. It must be 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. It must be smaller than 5 mins. If more - HA will mark hub as unavailable. -8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. - - -## Home Assistant Setup - -Place "sia" folder in **/custom_components** folder - -```yaml -# configuration.yaml - -sia: - port: **port** - hubs: - - name: **name** - account: **account** - password: *password* - -``` - -Configuration variables: -- **port** (*Required*): Listeting port -- **hubs** (*Required*): List of hubs -- **name** (*Required*): Used to generate sensor ids. -- **account** (*Required*): Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. -- **password** (*Optional*): Encoding key. 16 ASCII characters. Must be same, as in hub properties. - -## Disclaimer -This software is supplied "AS IS" without any warranties and support. +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) +[official]: https://www.home-assistant.io/integrations/sia/ diff --git a/SIA Codes.xlsx b/SIA Codes.xlsx new file mode 100644 index 0000000..6e8d2ba Binary files /dev/null and b/SIA Codes.xlsx differ diff --git a/SIA_code.pdf b/SIA_code.pdf new file mode 100644 index 0000000..37b9a5c Binary files /dev/null and b/SIA_code.pdf differ diff --git a/codes.csv b/codes.csv new file mode 100644 index 0000000..f8216d0 --- /dev/null +++ b/codes.csv @@ -0,0 +1,281 @@ +CODE,DESCRIPTION +AN,ANALOG RESTORAL +AR,AC RESTORAL +AS,ANALOG SERVICE +AT,AC TROUBLE +BA,BURGLARY ALARM +BB,BURGLARY BYPASS +BC,BURGLARY CANCEL +BD,SWINGER TROUBLE +BE,SWINGER TRBL RESTORE +BH,BURG ALARM RESTORE +BJ,BURG TROUBLE RESTORE +BM,BURG ALARM CROSS PNT +BR,BURGLARY RESTORAL +BS,BURGLARY SUPERVISORY +BT,BURGLARY TROUBLE +BU,BURGLARY UNBYPASS +BV,BURGLARY VERIFIED +BX,BURGLARY TEST +BZ,MISSING SUPERVISION +CA,AUTOMATIC CLOSING +CD,CLOSING DELINQUENT +CE,CLOSING EXTEND +CF,FORCED CLOSING +CG,CLOSE AREA +CI,FAIL TO CLOSE +CJ,LATE CLOSE +CK,EARLY CLOSE +CL,CLOSING REPORT +CM,MISSING AL-RECNT CLS +CP,AUTOMATIC CLOSING +CR,RECENT CLOSING +CS,CLOSE KEY SWITCH +CT,LATE TO OPEN +CW,WAS FORCE ARMED +CZ,POINT CLOSING +DA,CARD ASSIGNED +DB,CARD DELETED +DC,ACCESS CLOSED +DD,ACCESS DENIED +DE,REQUEST TO ENTER +DF,DOOR FORCED +DG,ACCESS GRANTED +DH,DOOR LEFT OPEN-RSTRL +DJ,DOOR FORCED-TROUBLE +DK,ACCESS LOCKOUT +DL,DOOR LEFT OPEN-ALARM +DM,DOOR LEFT OPEN-TRBL +DN,DOOR LEFT OPEN +DO,ACCESS OPEN +DP,ACCESS DENIED-BAD TM +DQ,ACCESS DENIED-UN ARM +DR,DOOR RESTORAL +DS,DOOR STATION +DT,ACCESS TROUBLE +DU,DEALER ID# +DV,ACCESS DENIED-UN ENT +DW,ACCESS DENIED-INTRLK +DX,REQUEST TO EXIT +DY,DOOR LOCKED +DZ,ACCESS CLOSED STATE +EA,EXIT ALARM +EE,EXIT_ERROR +ER,EXPANSION RESTORAL +ET,EXPANSION TROUBLE +EX,EXTRNL DEVICE STATE +EZ,MISSING ALARM-EXT ER +FA,FIRE ALARM +FB,FIRE BYPASS +FC,FIRE CANCEL +FH,FIRE ALARM RESTORE +FI,FIRE TEST BEGIN +FJ,FIRE TROUBLE RESTORE +FK,FIRE TEST END +FM,FIRE ALARM CROSS PNT +FR,FIRE RESTORAL +FS,FIRE SUPERVISORY +FT,FIRE TROUBLE +FU,FIRE UNBYPASS +FX,FIRE TEST +FY,MISSING FIRE TROUBLE +FZ,MISSING FIRE SPRV +GA,GAS ALARM +GB,GAS BYPASS +GH,GAS ALARM RESTORE +GJ,GAS TROUBLE RESTORE +GR,GAS RESTORAL +GS,GAS SUPERVISORY +GT,GAS TROUBLE +GU,GAS UNBYPASS +GX,GAS TEST +HA,HOLDUP ALARM +HB,HOLDUP BYPASS +HH,HOLDUP ALARM RESTORE +HJ,HOLDUP TRBL RESTORE +HR,HOLDUP RESTORAL +HS,HOLDUP SUPERVISORY +HT,HOLDUP TROUBLE +HU,HOLDUP UNBYPASS +IA,EQPMT FAIL CONDITION +IR,EQPMT FAIL RESTORE +JA,USER CODE TAMPER +JD,DATE CHANGED +JH,HOLIDAY CHANGED +JK,LATCHKEY ALERT +JL,LOG THRESHOLD +JO,LOG OVERFLOW +JP,USER ON PREMISES +JR,SCHEDULE EXECUTED +JS,SCHEDULE CHANGED +JT,TIME CHANGED +JV,USER CODE CHANGED +JX,USER CODE DELETED +JY,USER CODE ADDED +JZ,USER LEVEL SET +KA,HEAT ALARM +KB,HEAT BYPASS +KH,HEAT ALARM RESTORE +KJ,HEAT TROUBLE RESTORE +KR,HEAT RESTORAL +KS,HEAT SUPERVISORY +KT,HEAT TROUBLE +KU,HEAT UNBYPASS +L_,LISTEN IN + SECONDS +LB,LOCAL PROG. BEGIN +LD,LOCAL PROG. DENIED +LE,LISTEN IN ENDED +LF,LISTEN IN BEGIN +LR,PHONE LINE RESTORAL +LS,LOCAL PROG. SUCCESS +LT,PHONE LINE TROUBLE +LU,LOCAL PROG. FAIL +LX,LOCAL PROG. ENDED +MA,MEDICAL ALARM +MB,MEDICAL BYPASS +MH,MEDIC ALARM RESTORE +MJ,MEDICAL TRBL RESTORE +MR,MEDICAL RESTORAL +MS,MEDICAL SUPERVISORY +MT,MEDICAL TROUBLE +MU,MEDICAL UNBYPASS +NA,NO ACTIVITY +NC,NETWORK CONDITION +NF,FORCED PERIMETER ARM +NL,PERIMETER ARMED +NR,NETWORK RESTORAL +NS,ACTIVITY RESUMED +NT,NETWORK FAILURE +OA,AUTOMATIC OPENING +OC,CANCEL REPORT +OG,OPEN AREA +OH,EARLY TO OPN FROM AL +OI,FAIL TO OPEN +OJ,LATE OPEN +OK,EARLY OPEN +OL,LATE TO OPEN FROM AL +OP,OPENING REPORT +OR,DISARM FROM ALARM +OS,OPEN KEY SWITCH +OT,LATE TO CLOSE +OZ,POINT OPENING +PA,PANIC ALARM +PB,PANIC BYPASS +PH,PANIC ALARM RESTORE +PJ,PANIC TRBL RESTORE +PR,PANIC RESTORAL +PS,PANIC SUPERVISORY +PT,PANIC TROUBLE +PU,PANIC UNBYPASS +QA,EMERGENCY ALARM +QB,EMERGENCY BYPASS +QH,EMRGCY ALARM RESTORE +QJ,EMRGCY TRBL RESTORE +QR,EMERGENCY RESTORAL +QS,EMRGCY SUPERVISORY +QT,EMERGENCY TROUBLE +QU,EMERGENCY UNBYPASS +RA,RMOTE PROG CALL FAIL +RB,REMOTE PROG. BEGIN +RC,RELAY CLOSE +RD,REMOTE PROG. DENIED +RN,REMOTE RESET +RO,RELAY OPEN +RP,AUTOMATIC TEST +RR,RESTORE POWER +RS,REMOTE PROG. SUCCESS +RT,DATA LOST +RU,REMOTE PROG. FAIL +RX,MANUAL TEST +RY,TEST OFF NORMAL +SA,SPRINKLER ALARM +SB,SPRINKLER BYPASS +SH,SPRKLR ALARM RESTORE +SJ,SPRKLR TRBL RESTORE +SR,SPRINKLER RESTORAL +SS,SPRINKLER SUPERVISRY +ST,SPRINKLER TROUBLE +SU,SPRINKLER UNBYPASS +TA,TAMPER ALARM +TB,TAMPER BYPASS +TC,ALL POINTS TESTED +TE,TEST END +TH,TAMPER ALRM RESTORE +TJ,TAMPER TRBL RESTORE +TP,WALK TEST POINT +TR,TAMPER RESTORAL +TS,TEST START +TT,TAMPER TROUBLE +TU,TAMPER UNBYPASS +TX,TEST REPORT +UA,UNTYPED ZONE ALARM +UB,UNTYPED ZONE BYPASS +UH,UNTYPD ALARM RESTORE +UJ,UNTYPED TRBL RESTORE +UR,UNTYPED ZONE RESTORE +US,UNTYPED ZONE SUPRVRY +UT,UNTYPED ZONE TROUBLE +UU,UNTYPED ZONE UNBYPSS +UX,UNDEFINED ALARM +UY,UNTYPED MISSING TRBL +UZ,UNTYPED MISSING ALRM +VI,PRINTER PAPER IN +VO,PRINTER PAPER OUT +VR,PRINTER RESTORE +VT,PRINTER TROUBLE +VX,PRINTER TEST +VY,PRINTER ONLINE +VZ,PRINTER OFFLINE +WA,WATER ALARM +WB,WATER BYPASS +WH,WATER ALARM RESTORE +WJ,WATER TRBL RESTORE +WR,WATER RESTORAL +WS,WATER SUPERVISORY +WT,WATER TROUBLE +WU,WATER UNBYPASS +XA,EXTRA ACCNT REPORT +XE,EXTRA POINT +XF,EXTRA RF POINT +XH,RF INTERFERENCE RST +XI,SENSOR RESET +XJ,RF RCVR TAMPER RST +XL,LOW RF SIGNAL +XM,MISSING ALRM-X POINT +XQ,RF INTERFERENCE +XR,TRANS. BAT. RESTORAL +XS,RF RECEIVER TAMPER +XT,TRANS. BAT. TROUBLE +XW,FORCED POINT +XX,FAIL TO TEST +YA,BELL FAULT +YB,BUSY SECONDS +YC,COMMUNICATIONS FAIL +YD,RCV LINECARD TROUBLE +YE,RCV LINECARD RESTORE +YF,PARA CHECKSUM FAIL +YG,PARAMETER CHANGED +YH,BELL RESTORED +YI,OVERCURRENT TROUBLE +YJ,OVERCURRENT RESTORE +YK,COMM. RESTORAL +YM,SYSTEM BATT MISSING +YN,INVALID REPORT +YO,UNKNOWN MESSAGE +YP,PWR SUPPLY TROUBLE +YQ,PWR SUPPLY RESTORE +YR,SYSTEM BAT. RESTORAL +YS,COMMUNICATIONS TRBL +YT,SYSTEM BAT. TROUBLE +YW,WATCHDOG RESET +YX,SERVICE REQUIRED +YY,STATUS REPORT +YZ,SERVICE COMPLETED +ZA,FREEZE ALARM +ZB,FREEZE BYPASS +ZH,FREEZE ALARM RESTORE +ZJ,FREEZE TRBL RESTORE +ZR,FREEZE RESTORAL +ZS,FREEZE SUPERVISORY +ZT,FREEZE TROUBLE +ZU,FREEZE UNBYPASS diff --git a/codes.pdf b/codes.pdf new file mode 100644 index 0000000..b763ad8 Binary files /dev/null and b/codes.pdf differ diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..062f434 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,6 @@ +default_config: + +logger: + default: error + logs: + custom_components.sia: debug \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py new file mode 100644 index 0000000..9bca9a5 --- /dev/null +++ b/custom_components/sia/__init__.py @@ -0,0 +1,34 @@ +"""The sia integration.""" +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_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up sia from a config entry.""" + hub: SIAHub = SIAHub(hass, entry) + await hub.async_setup_hub() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = hub + try: + await hub.sia_client.start(reuse_port=True) + except OSError 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) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) + await hub.async_shutdown() + return unload_ok diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py new file mode 100644 index 0000000..5a6a4f6 --- /dev/null +++ b/custom_components/sia/alarm_control_panel.py @@ -0,0 +1,112 @@ +"""Module for SIA Alarm Control Panels.""" +from __future__ import annotations + +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + 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: 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, + ) + ) + + +class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): + """Class for SIA Alarm Control Panels.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Create SIAAlarmControlPanel object.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) + self._attr_state: StateType = None + self._old_state: StateType = None + + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( + self._entry.entry_id, self._account, self._zone + ) + + 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 0 diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py new file mode 100644 index 0000000..eec4f9b --- /dev/null +++ b/custom_components/sia/binary_sensor.py @@ -0,0 +1,163 @@ +"""Module for SIA Binary Sensors.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ZONES, + SIA_HUB_ZONE, + SIA_UNIQUE_ID_FORMAT_BINARY, +) +from .sia_entity_base import SIABaseEntity + +_LOGGER = logging.getLogger(__name__) + + +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) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SIA binary sensors from a config entry.""" + async_add_entities(generate_binary_sensors(entry)) + + +class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): + """Class for SIA Binary Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + device_class: str, + ) -> None: + """Initialize a base binary sensor.""" + super().__init__(entry, account_data, zone, device_class) + + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( + self._entry.entry_id, self._account, self._zone, self._attr_device_class + ) + + 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 new file mode 100644 index 0000000..c43faf5 --- /dev/null +++ b/custom_components/sia/config_flow.py @@ -0,0 +1,229 @@ +"""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, + InvalidAccountLengthError, + InvalidKeyFormatError, + InvalidKeyLengthError, + SIAAccount, +) +import voluptuous as vol + +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__) + + +HUB_SCHEMA = vol.Schema( + { + vol.Required(CONF_PORT): int, + vol.Optional(CONF_PROTOCOL, default="TCP"): vol.In(["TCP", "UDP"]), + vol.Required(CONF_ACCOUNT): str, + vol.Optional(CONF_ENCRYPTION_KEY): str, + vol.Required(CONF_PING_INTERVAL, default=1): int, + vol.Required(CONF_ZONES, default=1): int, + vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, + } +) + +ACCOUNT_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACCOUNT): str, + vol.Optional(CONF_ENCRYPTION_KEY): str, + vol.Required(CONF_PING_INTERVAL, default=1): int, + vol.Required(CONF_ZONES, default=1): int, + vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, + } +) + +DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} + + +def validate_input(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the input by the user.""" + try: + 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: 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_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: 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="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, + ) + + 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: + 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 + ], + ): 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 new file mode 100644 index 0000000..6a63c33 --- /dev/null +++ b/custom_components/sia/const.py @@ -0,0 +1,34 @@ +"""Constants for the sia integration.""" +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_ENCRYPTION_KEY = "encryption_key" +CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" +CONF_PING_INTERVAL = "ping_interval" +CONF_ZONES = "zones" + +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 new file mode 100644 index 0000000..af7d59d --- /dev/null +++ b/custom_components/sia/hub.py @@ -0,0 +1,147 @@ +"""The sia hub.""" +from __future__ import annotations + +from copy import deepcopy +import logging +from typing import Any + +from pysiaalarm.aio import CommunicationsProtocol +from pysiaalarm.aio import SIAAccount +from pysiaalarm.aio import SIAClient +from pysiaalarm.aio import SIAEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, + CONF_ZONES, + DOMAIN, + PLATFORMS, + SIA_EVENT, +) +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, + entry: ConfigEntry, + ) -> None: + """Create the SIAHub.""" + 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) + for acc in self._accounts: + account = acc[CONF_ACCOUNT] + device_registry.async_get_or_create( + config_entry_id=self._entry.entry_id, + identifiers={(DOMAIN, f"{self._port}_{account}")}, + name=f"{self._port} - {account}", + ) + self._entry.async_on_unload( + self._entry.add_update_listener(self.async_config_entry_updated) + ) + self._entry.async_on_unload( + self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) + ) + + async def async_shutdown(self, _: Event = None) -> None: + """Shutdown the SIA server.""" + await self.sia_client.stop() + + async def async_create_and_fire_event(self, event: SIAEvent) -> None: + """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. + + The created event is handled by default for only a small subset for each platform (there are about 320 SIA Codes defined, only 22 of those are used in the alarm_control_panel), a user can choose to build other automation or even entities on the same event for SIA codes not handled by the built-in platforms. + + """ + _LOGGER.debug( + "Adding event to dispatch and bus for code %s for port %s and account %s", + event.code, + self._port, + event.account, + ) + async_dispatcher_send( + self._hass, SIA_EVENT.format(self._port, event.account), event + ) + self._hass.bus.async_fire( + event_type=SIA_EVENT.format(self._port, event.account), + event_data=get_event_data_from_sia_event(event), + ) + + def update_accounts(self): + """Update the SIA_Accounts variable.""" + self._load_options() + self.sia_accounts = [ + SIAAccount( + account_id=a[CONF_ACCOUNT], + key=a.get(CONF_ENCRYPTION_KEY), + allowed_timeband=IGNORED_TIMEBAND + if a[CONF_IGNORE_TIMESTAMPS] + else DEFAULT_TIMEBAND, + ) + for a in self._accounts + ] + if self.sia_client is not None: + self.sia_client.accounts = self.sia_accounts + return + self.sia_client = SIAClient( + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), + ) + + def _load_options(self) -> None: + """Store attributes to avoid property call overhead since they are called frequently.""" + options = dict(self._entry.options) + for acc in self._accounts: + acc_id = acc[CONF_ACCOUNT] + if acc_id in options[CONF_ACCOUNTS]: + acc[CONF_IGNORE_TIMESTAMPS] = options[CONF_ACCOUNTS][acc_id][ + CONF_IGNORE_TIMESTAMPS + ] + acc[CONF_ZONES] = options[CONF_ACCOUNTS][acc_id][CONF_ZONES] + + @staticmethod + async def async_config_entry_updated( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Handle signals of config entry being updated. + + First, update the accounts, this will reflect any changes with ignore_timestamps. + Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. + + """ + if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): + return + hub.update_accounts() + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json new file mode 100644 index 0000000..ab97121 --- /dev/null +++ b/custom_components/sia/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sia", + "name": "SIA Alarm Systems", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sia", + "requirements": ["pysiaalarm==3.0.3b1"], + "codeowners": ["@eavanvalkenburg"], + "version": "1.0.0", + "iot_class": "local_push" +} diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py new file mode 100644 index 0000000..3bea41f --- /dev/null +++ b/custom_components/sia/sensor.py @@ -0,0 +1,130 @@ +"""Module for SIA Sensors.""" +from __future__ import annotations + +from datetime import datetime as dt, timedelta +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.config_entries import ConfigEntry +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_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_entities: AddEntitiesCallback +) -> None: + """Set up sia_sensor from a config entry.""" + async_add_entities( + SIASensor(entry, account_data) for account_data in entry.data[CONF_ACCOUNTS] + ) + + +class SIASensor(RestoreEntity): + """Class for SIA Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: + """Create SIASensor object.""" + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: timedelta = timedelta( + minutes=self._account_data[CONF_PING_INTERVAL] + ) + + self._state: dt = utcnow() + self._cancel_icon_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes: dict[str, Any] = {} + self._attr_icon = REGULAR_ICON + self._attr_unit_of_measurement = "ISO8601" + self._attr_device_class = DEVICE_CLASS_TIMESTAMP + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT_SENSOR.format(self._port, self._account) + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_SENSOR.format( + self._entry.entry_id, self._account + ) + + async def async_added_to_hass(self) -> None: + """Once the sensor is added, see if it was there before and pull in that state.""" + await super().async_added_to_hass() + 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 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() + + @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) -> StateType: + """Return state.""" + return self._state.isoformat() + + @property + 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 { + "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 new file mode 100644 index 0000000..fe648c2 --- /dev/null +++ b/custom_components/sia/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "step": { + "user": { + "data": { + "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": "Additional accounts" + }, + "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 hex characters.", + "invalid_account_format": "The account is not a hex value, please use only 0-9 and A-F.", + "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", + "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", + "invalid_zones": "There needs to be at least 1 zone.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "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 new file mode 100644 index 0000000..c46bc9e --- /dev/null +++ b/custom_components/sia/translations/en.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." + }, + "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_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" + }, + "step": { + "user": { + "data": { + "name": "Name", + "port": "Port", + "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": "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." + } + } + }, + "title": "SIA Alarm Systems" +} \ No newline at end of file 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 new file mode 100644 index 0000000..159968d --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "SIA", + "domains": ["binary_sensor", "alarm_control_panel", "sensor"] +} \ No newline at end of file diff --git a/info.md b/info.md new file mode 100644 index 0000000..d38def9 --- /dev/null +++ b/info.md @@ -0,0 +1,58 @@ +[![hacs][hacs_badge]](hacs) + +_Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ + +**This component will set up the following platforms.** + +## WARNING +This integration may be unsecure. You can use it, but it's at your own risk. +This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. + +Platform | Description +-- | -- +`binary_sensor` | A smoke or moisture sensor. +`alarm_control_panel` | Alarm panel with the state of the alarm. +`sensor` | Sensor with the last heartbeat message from your system. + +## Features +- Alarm tracking with a alarm_control_panel component +- Optional Fire/gas tracker +- Optional 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 adress. It must be 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. +{% if not installed %} +## Installation + +1. Click install. +1. Add at least the minimum configuration to your HA configuration, see below. + + +## 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. +*** + +[sia]: https://github.com/eavanvalkenburg/sia-ha +[ch_sia]: https://github.com/Cheaterdev/sia-ha +[hacs]: https://github.com/custom-components/hacs +[hacs_badge]: https://img.shields.io/badge/HACS-Default-orange.svg) \ No newline at end of file diff --git a/sia/__init__.py b/sia/__init__.py deleted file mode 100644 index 68108b5..0000000 --- a/sia/__init__.py +++ /dev/null @@ -1,338 +0,0 @@ -import asyncio -import logging -import json -import voluptuous as vol -import sseclient -import requests -import time -from collections import defaultdict -from requests_toolbelt.utils import dump -from homeassistant.core import callback -import voluptuous as vol -from datetime import timedelta -from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change - -from threading import Thread -from homeassistant.helpers import discovery -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.helpers.restore_state import RestoreEntity -_LOGGER = logging.getLogger(__name__) -from homeassistant.const import (STATE_ON, STATE_OFF) - -from homeassistant.const import ( - CONF_NAME, CONF_PORT, CONF_PASSWORD) -import socketserver -from datetime import datetime -import time -import logging -import threading -import sys -import re - -from Crypto.Cipher import AES -from binascii import unhexlify,hexlify -from Crypto import Random -import random, string, base64 -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow - -DOMAIN = 'sia' -CONF_HUBS = 'hubs' -CONF_ACCOUNT = 'account' - -HUB_CONFIG = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ACCOUNT): cv.string, - vol.Optional(CONF_PASSWORD):cv.string, -}) - - - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_PORT): cv.string, - vol.Required(CONF_HUBS, default={}): - vol.All(cv.ensure_list, [HUB_CONFIG]), - }), -}, extra=vol.ALLOW_EXTRA) - -ID_STRING = '"SIA-DCS"'.encode() -ID_STRING_ENCODED = '"*SIA-DCS"'.encode() - -TIME_TILL_UNAVAILABLE = timedelta(minutes=3) - -ID_R='\r'.encode() - -hass_platform = None - - -def setup(hass, config): - global hass_platform - socketserver.TCPServer.allow_reuse_address = True - hass_platform = hass - - hass_platform.data[DOMAIN] = {} - - port = int(config[DOMAIN][CONF_PORT]) - - for hub_config in config[DOMAIN][CONF_HUBS]: - if CONF_PASSWORD in hub_config: - hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = EncryptedHub(hass, hub_config) - else: - hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) - - for component in ['binary_sensor']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - server = socketserver.TCPServer(("", port), AlarmTCPHandler) - - t = threading.Thread(target=server.serve_forever) - t.start() - - return True - -class Hub: - reactions = { - "BA" : [{"state":"ALARM","value":True}], - "TA" : [{"state":"ALARM" ,"value":True}], - "CL" : [{"state":"STATUS" ,"value":False},{"state":"STATUS_TEMP" ,"value":False}], - "NL" : [{"state":"STATUS" ,"value":True},{"state":"STATUS_TEMP" ,"value":False}], - "WA": [{"state":"LEAK","value":True}], - "WH": [{"state":"LEAK" ,"value":False}], - "GA": [{"state":"GAS","value":True}], - "GH": [{"state":"GAS" ,"value":False}], - "BR" : [{"state":"ALARM","value":False}], - "OP" : [{"state":"STATUS","value":True},{"state":"STATUS_TEMP","value":True}], - "RP" : [] - } - - def __init__(self, hass, hub_config): - self._name = hub_config[CONF_NAME] - self._accountId = hub_config[CONF_ACCOUNT] - self._hass = hass - self._states = {} - self._states["LEAK"] = SIABinarySensor("sia_leak_" + self._name,"moisture" , hass) - self._states["GAS"] = SIABinarySensor("sia_gas_" + self._name,"smoke", hass) - self._states["ALARM"] = SIABinarySensor("sia_alarm_" + self._name,"safety", hass) - self._states["STATUS"] = SIABinarySensor("sia_status_" + self._name, "lock", hass) - self._states["STATUS_TEMP"] = SIABinarySensor("sia_status_temporal_" + self._name, "lock", hass) - - def manage_string(self, msg): - _LOGGER.debug("manage_string: " + msg) - - pos = msg.find('/') - assert pos>=0, "Can't find '/', message is possibly encrypted" - tipo = msg[pos+1:pos+3] - - if tipo in self.reactions: - reactions = self.reactions[tipo] - for reaction in reactions: - state = reaction["state"] - value = reaction["value"] - - self._states[state].new_state(value) - else: - _LOGGER.error("unknown event: " + tipo ) - - for device in self._states: - self._states[device].assume_available() - - - - def process_line(self, line): - _LOGGER.debug("Hub.process_line" + line.decode()) - pos = line.find(ID_STRING) - assert pos>=0, "Can't find ID_STRING, check encryption configs" - seq = line[pos+len(ID_STRING) : pos+len(ID_STRING)+4] - data = line[line.index(b'[') :] - _LOGGER.debug("Hub.process_line found data: " + data.decode()) - self.manage_string(data.decode()) - return '"ACK"' + (seq.decode()) + 'L0#' + (self._accountId) + '[]' - - -class EncryptedHub(Hub): - def __init__(self, hass, hub_config): - self._key = hub_config[CONF_PASSWORD].encode("utf8") - iv = Random.new().read(AES.block_size) - _cipher = AES.new(self._key, AES.MODE_CBC, iv) - self.iv2 = None - self._ending = hexlify(_cipher.encrypt( "00000000000000|]".encode("utf8") )).decode(encoding='UTF-8').upper() - Hub.__init__(self, hass, hub_config) - - def manage_string(self, msg): - iv = unhexlify("00000000000000000000000000000000") #where i need to find proper IV ? Only this works good. - _cipher = AES.new(self._key, AES.MODE_CBC, iv) - data = _cipher.decrypt(unhexlify(msg[1:])) - _LOGGER.debug("EncryptedHub.manage_string data: " + data.decode(encoding='UTF-8',errors='replace')) - - data = data[data.index(b'|'):] - resmsg = data.decode(encoding='UTF-8',errors='replace') - - Hub.manage_string(self, resmsg) - - def process_line(self, line): - _LOGGER.debug("EncryptedHub.process_line" + line.decode()) - pos = line.find(ID_STRING_ENCODED) - assert pos>=0, "Can't find ID_STRING_ENCODED, is SIA encryption enabled?" - seq = line[pos+len(ID_STRING_ENCODED) : pos+len(ID_STRING_ENCODED)+4] - data = line[line.index(b'[') :] - _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode()) - self.manage_string(data.decode()) - return '"*ACK"' + (seq.decode()) + 'L0#' + (self._accountId) + '[' + self._ending - - - -class SIABinarySensor( RestoreEntity): - def __init__(self, name, device_class, hass): - self._device_class = device_class - self._should_poll = False - self._name = name - self.hass = hass - self._is_available = True - self._remove_unavailability_tracker = None - - async def async_added_to_hass(self): - 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 = state.state == STATE_ON - else: - self._state = None - self._async_track_unavailable() - - @property - def name(self): - return self._name - - @property - def state(self): - return STATE_ON if self.is_on else STATE_OFF - - @property - def unique_id(self) -> str: - return self._name - - @property - def available(self): - return self._is_available - - @property - def device_state_attributes(self): - attrs = {} - return attrs - - @property - def device_class(self): - return self._device_class - - @property - def is_on(self): - return self._state - - def new_state(self, state): - self._state = state - self.async_schedule_update_ha_state() - - def assume_available(self): - self._async_track_unavailable() - - @callback - def _async_track_unavailable(self): - 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() + TIME_TILL_UNAVAILABLE) - if not self._is_available: - self._is_available = True - return True - return False - - @callback - def _async_set_unavailable(self, now): - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() - -class AlarmTCPHandler(socketserver.BaseRequestHandler): - _received_data = "".encode() - - def handle_line(self, line): - _LOGGER.debug("Income raw string: " + line.decode()) - accountId = line[line.index(b'#') +1: line.index(b'[')].decode() - - pos = line.find(b'"') - assert pos>=0, "Can't find message beginning" - inputMessage=line[pos:] - msgcrc = line[0:4] - codecrc = str.encode(AlarmTCPHandler.CRCCalc(inputMessage)) - try: - if msgcrc != codecrc: - raise Exception('CRC mismatch') - if(accountId not in hass_platform.data[DOMAIN]): - raise Exception('Not supported account ' + accountId) - response = hass_platform.data[DOMAIN][accountId].process_line(line) - except Exception as e: - _LOGGER.error(str(e)) - timestamp = datetime.fromtimestamp(time.time()).strftime('_%H:%M:%S,%m-%d-%Y') - response = '"NAK"0000L0R0A0[]' + timestamp - - header = ('%04x' % len(response)).upper() - CRC = AlarmTCPHandler.CRCCalc2(response) - response="\n" + CRC + header + response + "\r" - - byte_response = str.encode(response) - self.request.sendall(byte_response) - - - def handle(self): - line = b'' - try: - while True: - raw = self.request.recv(1024) - if (not raw) or (len(raw) == 0): - return - raw = bytearray(raw) - while True: - splitter = raw.find(b'\r') - if splitter> -1: - line = raw[1:splitter] - raw = raw[splitter+1:] - else: - break - - self.handle_line(line) - except Exception as e: - _LOGGER.error(str(e)+" last line: " + line.decode()) - return - - @staticmethod - def CRCCalc(msg): - CRC=0 - for letter in msg: - temp=(letter) - for j in range(0,8): # @UnusedVariable - temp ^= CRC & 1 - CRC >>= 1 - if (temp & 1) != 0: - CRC ^= 0xA001 - temp >>= 1 - - return ('%x' % CRC).upper().zfill(4) - - @staticmethod - def CRCCalc2(msg): - CRC=0 - for letter in msg: - temp=ord(letter) - for j in range(0,8): # @UnusedVariable - temp ^= CRC & 1 - CRC >>= 1 - if (temp & 1) != 0: - CRC ^= 0xA001 - temp >>= 1 - - return ('%x' % CRC).upper().zfill(4) \ No newline at end of file diff --git a/sia/binary_sensor.py b/sia/binary_sensor.py deleted file mode 100644 index 9c99b97..0000000 --- a/sia/binary_sensor.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging -import json - -DOMAIN = 'sia' -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): - devices = [] - for account in hass.data[DOMAIN]: - for device in hass.data[DOMAIN][account]._states: - devices.append(hass.data[DOMAIN][account]._states[device]) - add_entities(devices) - diff --git a/sia/manifest.json b/sia/manifest.json deleted file mode 100644 index 0919449..0000000 --- a/sia/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "sia", - "name": "Sia", - "documentation": "", - "dependencies": [], - "codeowners": ["@cheater.dev"], - "requirements": [] -} \ 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