From e3131c2d9a5b74403e7b381b0f38f724e30f5681 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 15:55:08 +0200 Subject: [PATCH 01/63] mapped to hacs blueprint --- .github/ISSUE_TEMPLATE/feature_request.md | 17 ++++++ .github/ISSUE_TEMPLATE/issue.md | 42 +++++++++++++ .github/settings.yml | 23 +++++++ .vscode/tasks.json | 61 +++++++++++++++++++ CONTRIBUTING.md | 50 +++++++++++++++ LICENSE | 21 +++++++ {sia => custom_components/sia}/__init__.py | 0 .../sia}/binary_sensor.py | 0 {sia => custom_components/sia}/manifest.json | 0 9 files changed, 214 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/issue.md create mode 100644 .github/settings.yml create mode 100644 .vscode/tasks.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE rename {sia => custom_components/sia}/__init__.py (100%) rename {sia => custom_components/sia}/binary_sensor.py (100%) rename {sia => custom_components/sia}/manifest.json (100%) 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..bbd0345 --- /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 + + +## Configuration + +```yaml + +Add your logs 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/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/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..0749072 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,61 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Home Assistant on port 8124", + "type": "shell", + "command": "source .devcontainer/custom_component_helper && StartHomeAssistant", + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "source .devcontainer/custom_component_helper && UpdgradeHomeAssistantDev", + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Set Home Assistant Version", + "type": "shell", + "command": "source .devcontainer/custom_component_helper && SetHomeAssistantVersion", + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Home Assistant Config Check", + "type": "shell", + "command": "source .devcontainer/custom_component_helper && HomeAssistantConfigCheck", + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "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/sia/__init__.py b/custom_components/sia/__init__.py similarity index 100% rename from sia/__init__.py rename to custom_components/sia/__init__.py diff --git a/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py similarity index 100% rename from sia/binary_sensor.py rename to custom_components/sia/binary_sensor.py diff --git a/sia/manifest.json b/custom_components/sia/manifest.json similarity index 100% rename from sia/manifest.json rename to custom_components/sia/manifest.json From f16474a505be85e736698e54d6de9d389f71008a Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 16:06:17 +0200 Subject: [PATCH 02/63] added info --- info.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 info.md diff --git a/info.md b/info.md new file mode 100644 index 0000000..11f85de --- /dev/null +++ b/info.md @@ -0,0 +1,54 @@ +[![hacs][hacsbadge]](hacs) + +_Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | Show something `True` or `False`. + +## Features +- Fire/gas tracker +- Water leak tracker +- Alarm tracking +- Armed state tracking +- Partial armed state tracking +- AES-128 CBC encryption support + +{% if not installed %} +## Installation + +1. Click install. +1. Add `sia:` to your HA configuration. + +{% endif %} +## Example configuration.yaml + +```yaml +sia: + port: **port** + hubs: + - name: **name** + account: **account** + password: *password* +``` + +## Configuration options + +Key | Type | Required | Description +-- | -- | -- | -- +`port` | `int` | `True` | Port that SIA will listen on. +`hubs` | `list` | `True` | List of all hubs to connect to. +`name` | `string` | `True` | Used to generate sensor ids. +`account` | `string` | `True` | Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. +`password` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. + +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 \ No newline at end of file From 1d5bd8ca9aaeb867df4632e281aa8df49ff2ea66 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 16:07:11 +0200 Subject: [PATCH 03/63] added badge link --- info.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/info.md b/info.md index 11f85de..720fe33 100644 --- a/info.md +++ b/info.md @@ -51,4 +51,5 @@ ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and [sia]: https://github.com/eavanvalkenburg/sia-ha [ch_sia]: https://github.com/Cheaterdev/sia-ha -[hacs]: https://github.com/custom-components/hacs \ No newline at end of file +[hacs]: https://github.com/custom-components/hacs +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file From dc1ae74d656191ae9275f868d50091cd50480d84 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 16:17:12 +0200 Subject: [PATCH 04/63] added hacs.json --- hacs.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 hacs.json diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4117330 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "SIA", + "domains": ["binary_sensor"] +} \ No newline at end of file From 1eecddb2337e3193f434fab68dbe0a8c6f8e80ce Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 17:07:54 +0200 Subject: [PATCH 05/63] black and changed logging --- codes.csv | 281 +++++++++++++++++++++++++ codes.pdf | Bin 0 -> 15906 bytes custom_components/sia/__init__.py | 265 +++++++++++++---------- custom_components/sia/binary_sensor.py | 6 +- 4 files changed, 434 insertions(+), 118 deletions(-) create mode 100644 codes.csv create mode 100644 codes.pdf 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 0000000000000000000000000000000000000000..b763ad806ef219b13a60c6ce0c512264a388b82e GIT binary patch literal 15906 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, "Can't find '/', message is possibly encrypted" - tipo = msg[pos+1:pos+3] + _LOGGER.info("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() - + _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()) + 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.info("Hub.process_line found data: " + data.decode()) self.manage_string(data.decode()) - return '"ACK"' + (seq.decode()) + 'L0#' + (self._accountId) + '[]' - + return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" + class EncryptedHub(Hub): def __init__(self, hass, hub_config): @@ -159,34 +182,44 @@ def __init__(self, hass, hub_config): 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() + 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. + 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')) + _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") - 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'[') :] + 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 - - + return ( + '"*ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[" + self._ending + ) + -class SIABinarySensor( RestoreEntity): - def __init__(self, name, device_class, hass): +class SIABinarySensor(RestoreEntity): + def __init__(self, name, device_class, hass): self._device_class = device_class self._should_poll = False self._name = name @@ -196,7 +229,7 @@ def __init__(self, name, device_class, hass): async def async_added_to_hass(self): await super().async_added_to_hass() - state = await self.async_get_last_state() + 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: @@ -232,7 +265,7 @@ def device_class(self): def is_on(self): return self._state - def new_state(self, state): + def new_state(self, state): self._state = state self.async_schedule_update_ha_state() @@ -244,8 +277,8 @@ 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) + self.hass, self._async_set_unavailable, utcnow() + TIME_TILL_UNAVAILABLE + ) if not self._is_available: self._is_available = True return True @@ -257,39 +290,41 @@ def _async_set_unavailable(self, now): 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() + 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)) + 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) + 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') + timestamp = datetime.fromtimestamp(time.time()).strftime( + "_%H:%M:%S,%m-%d-%Y" + ) response = '"NAK"0000L0R0A0[]' + timestamp - header = ('%04x' % len(response)).upper() + header = ("%04x" % len(response)).upper() CRC = AlarmTCPHandler.CRCCalc2(response) - response="\n" + CRC + header + response + "\r" + response = "\n" + CRC + header + response + "\r" byte_response = str.encode(response) self.request.sendall(byte_response) - def handle(self): - line = b'' + line = b"" try: while True: raw = self.request.recv(1024) @@ -297,42 +332,42 @@ def handle(self): return raw = bytearray(raw) while True: - splitter = raw.find(b'\r') - if splitter> -1: + splitter = raw.find(b"\r") + if splitter > -1: line = raw[1:splitter] - raw = raw[splitter+1:] + raw = raw[splitter + 1 :] else: break - + self.handle_line(line) - except Exception as e: - _LOGGER.error(str(e)+" last line: " + line.decode()) + except Exception as e: + _LOGGER.error(str(e) + " last line: " + line.decode()) return @staticmethod def CRCCalc(msg): - CRC=0 + CRC = 0 for letter in msg: - temp=(letter) - for j in range(0,8): # @UnusedVariable + 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) - + + return ("%x" % CRC).upper().zfill(4) + @staticmethod def CRCCalc2(msg): - CRC=0 + CRC = 0 for letter in msg: - temp=ord(letter) - for j in range(0,8): # @UnusedVariable + 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 + + return ("%x" % CRC).upper().zfill(4) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 9c99b97..d1a8018 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,13 +1,13 @@ import logging import json -DOMAIN = 'sia' +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]) + devices.append(hass.data[DOMAIN][account]._states[device]) add_entities(devices) - From a38b711ee3e82dffe2b6364d82f352e627edd67a Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 21:27:46 +0200 Subject: [PATCH 06/63] v1 with control panel, so you can use the visuals --- SIA_code.pdf | Bin 0 -> 133582 bytes custom_components/sia/__init__.py | 160 +++++++++++++++---- custom_components/sia/alarm_control_panel.py | 13 ++ custom_components/sia/manifest.json | 2 +- 4 files changed, 142 insertions(+), 33 deletions(-) create mode 100644 SIA_code.pdf create mode 100644 custom_components/sia/alarm_control_panel.py diff --git a/SIA_code.pdf b/SIA_code.pdf new file mode 100644 index 0000000000000000000000000000000000000000..37b9a5cb9ce2f9159174fee2705819cd2b3a1e07 GIT binary patch literal 133582 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 z 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 Ee<)CG`2YX_ literal 0 HcmV?d00001 diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index ab691c3..a0a77ec 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,57 +1,64 @@ import asyncio -import logging +import base64 +from binascii import hexlify, unhexlify +from collections import defaultdict +from datetime import datetime, timedelta import json -import voluptuous as vol -import sseclient -import requests +import logging +import random +import re +import socketserver +import string +import sys +import threading +from threading import Thread import time -from collections import defaultdict + +from Crypto import Random +from Crypto.Cipher import AES +import requests from requests_toolbelt.utils import dump -from homeassistant.core import callback +import sseclient import voluptuous as vol -from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + STATE_OFF, + STATE_ON, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, +) +from homeassistant.core import callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import discovery 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.event import ( + async_track_point_in_utc_time, + async_track_state_change, +) from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.dt import utcnow _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" +CONF_ENCRYPTION_KEY = "encryption_key" HUB_CONFIG = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ACCOUNT): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ENCRYPTION_KEY): cv.string, } ) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -86,7 +93,7 @@ def setup(hass, config): port = int(config[DOMAIN][CONF_PORT]) for hub_config in config[DOMAIN][CONF_HUBS]: - if CONF_PASSWORD in hub_config: + if CONF_ENCRYPTION_KEY 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) @@ -109,10 +116,12 @@ class Hub: "CL": [ {"state": "STATUS", "value": False}, {"state": "STATUS_TEMP", "value": False}, + {"state": "ALARMCONTROL", "value": STATE_ALARM_ARMED_AWAY}, ], "NL": [ {"state": "STATUS", "value": True}, {"state": "STATUS_TEMP", "value": False}, + {"state": "ALARMCONTROL", "value": STATE_ALARM_ARMED_NIGHT}, ], "WA": [{"state": "LEAK", "value": True}], "WH": [{"state": "LEAK", "value": False}], @@ -122,6 +131,7 @@ class Hub: "OP": [ {"state": "STATUS", "value": True}, {"state": "STATUS_TEMP", "value": True}, + {"state": "ALARMCONTROL", "value": STATE_ALARM_DISARMED}, ], "RP": [], } @@ -144,6 +154,9 @@ def __init__(self, hass, hub_config): self._states["STATUS_TEMP"] = SIABinarySensor( "sia_status_temporal_" + self._name, "lock", hass ) + self._states["ALARMCONTROL"] = SIAAlarmControlPanel( + "sia_alarmcontrol_" + self._name, STATE_ALARM_DISARMED, hass + ) def manage_string(self, msg): _LOGGER.info("manage_string: " + msg) @@ -178,7 +191,7 @@ def process_line(self, line): class EncryptedHub(Hub): def __init__(self, hass, hub_config): - self._key = hub_config[CONF_PASSWORD].encode("utf8") + self._key = hub_config[CONF_ENCRYPTION_KEY].encode("utf8") iv = Random.new().read(AES.block_size) _cipher = AES.new(self._key, AES.MODE_CBC, iv) self.iv2 = None @@ -218,6 +231,89 @@ def process_line(self, line): ) +class SIAAlarmControlPanel(RestoreEntity): + def __init__(self, name, state, hass): + self._should_poll = False + self._name = name + self.hass = hass + self._is_available = True + self._state = state + 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_ALARM_DISARMED + else: + self._state = None + self._async_track_unavailable() + + @property + def name(self): + return self._name + + @property + def state(self): + return self._state + + @property + def unique_id(self) -> str: + return self._name + + @property + def available(self): + return self._is_available + + def alarm_disarm(self, code=None): + _LOGGER.info("Not implemented.") + + def alarm_arm_home(self, code=None): + _LOGGER.info("Not implemented.") + + def alarm_arm_away(self, code=None): + _LOGGER.info("Not implemented.") + + def alarm_arm_night(self, code=None): + _LOGGER.info("Not implemented.") + + def alarm_trigger(self, code=None): + _LOGGER.info("Not implemented.") + + def alarm_arm_custom_bypass(self, code=None): + _LOGGER.info("Not implemented.") + + @property + def device_state_attributes(self): + attrs = {} + return attrs + + 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 SIABinarySensor(RestoreEntity): def __init__(self, name, device_class, hass): self._device_class = device_class diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py new file mode 100644 index 0000000..d1a8018 --- /dev/null +++ b/custom_components/sia/alarm_control_panel.py @@ -0,0 +1,13 @@ +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/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 0919449..f9dd24a 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -3,6 +3,6 @@ "name": "Sia", "documentation": "", "dependencies": [], - "codeowners": ["@cheater.dev"], + "codeowners": ["@cheater.dev", "@eavanvalkenburg"], "requirements": [] } \ No newline at end of file From c4d7ad2e871235d3b3b9ca2b37f9c8c0e97fd88e Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 22:03:27 +0200 Subject: [PATCH 07/63] v1 of alarm panel (view only) --- README.md | 4 ++-- custom_components/sia/__init__.py | 2 +- custom_components/sia/alarm_control_panel.py | 3 ++- custom_components/sia/binary_sensor.py | 3 ++- info.md | 10 +++++----- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3b17c6e..01eec68 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ sia: hubs: - name: **name** account: **account** - password: *password* + encryption_key: *password* ``` @@ -47,7 +47,7 @@ Configuration variables: - **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. +- **encryption_key** (*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. diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index a0a77ec..22e9db3 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -98,7 +98,7 @@ def setup(hass, config): else: hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) - for component in ["binary_sensor"]: + for component in ["binary_sensor", "alarm_control_panel"]: discovery.load_platform(hass, component, DOMAIN, {}, config) server = socketserver.TCPServer(("", port), AlarmTCPHandler) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index d1a8018..235a425 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -9,5 +9,6 @@ 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]) + if device == "ALARMCONTROL": + devices.append(hass.data[DOMAIN][account]._states[device]) add_entities(devices) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index d1a8018..bec1d74 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -9,5 +9,6 @@ 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]) + if device != "ALARMCONTROL": + devices.append(hass.data[DOMAIN][account]._states[device]) add_entities(devices) diff --git a/info.md b/info.md index 720fe33..4ae09d6 100644 --- a/info.md +++ b/info.md @@ -27,11 +27,11 @@ Platform | Description ```yaml sia: - port: **port** + port: port hubs: - - name: **name** - account: **account** - password: *password* + - name: name + account: account + encryption_key: password ``` ## Configuration options @@ -42,7 +42,7 @@ Key | Type | Required | Description `hubs` | `list` | `True` | List of all hubs to connect to. `name` | `string` | `True` | Used to generate sensor ids. `account` | `string` | `True` | Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. -`password` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. +`encryption_key` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. From f374e6fedb46087922325df18bb8f751f40d2147 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 5 Sep 2019 22:04:50 +0200 Subject: [PATCH 08/63] updated info --- info.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/info.md b/info.md index 4ae09d6..7daff5a 100644 --- a/info.md +++ b/info.md @@ -7,6 +7,7 @@ _Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia] Platform | Description -- | -- `binary_sensor` | Show something `True` or `False`. +`alarm_control_panel` | Alarm panel ## Features - Fire/gas tracker @@ -14,6 +15,7 @@ Platform | Description - Alarm tracking - Armed state tracking - Partial armed state tracking +- Alarm panel for status in Alarm Panel visual - AES-128 CBC encryption support {% if not installed %} From 0289409be7f02c928843205ba4502cf0410a37b7 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Mon, 9 Sep 2019 19:11:37 +0200 Subject: [PATCH 09/63] major updates to the component, with breaking config changes --- .metals/metals.h2.db | Bin 0 -> 5361664 bytes .metals/metals.lock.db | 6 + .metals/metals.log | 0 README.md | 89 +- SIA Codes.xlsx | Bin 0 -> 58035 bytes custom_components/sia/__init__.py | 343 ++- .../sia/__pycache__/sia_codes.cpython-37.pyc | Bin 0 -> 29212 bytes custom_components/sia/alarm_control_panel.py | 8 +- custom_components/sia/binary_sensor.py | 7 +- custom_components/sia/sensor.py | 17 + custom_components/sia/sia_codes.py | 1865 +++++++++++++++++ info.md | 58 +- 12 files changed, 2269 insertions(+), 124 deletions(-) create mode 100644 .metals/metals.h2.db create mode 100644 .metals/metals.lock.db create mode 100644 .metals/metals.log create mode 100644 SIA Codes.xlsx create mode 100644 custom_components/sia/__pycache__/sia_codes.cpython-37.pyc create mode 100644 custom_components/sia/sensor.py create mode 100644 custom_components/sia/sia_codes.py diff --git a/.metals/metals.h2.db b/.metals/metals.h2.db new file mode 100644 index 0000000000000000000000000000000000000000..d4c4e77c5835bf9b981afe90f197c0dc6c7e62a6 GIT binary patch literal 5361664 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 z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM Y7%*VKfB^#r3>YwAz<>b*1`K=#0%`i5O#lD@ literal 0 HcmV?d00001 diff --git a/.metals/metals.lock.db b/.metals/metals.lock.db new file mode 100644 index 0000000..d55fc02 --- /dev/null +++ b/.metals/metals.lock.db @@ -0,0 +1,6 @@ +#FileLock +#Sun Sep 08 14:39:55 CEST 2019 +hostName=localhost +id=16d10e34db621fb628123ae9ed2e15c0ab0a6fae115 +method=file +server=localhost\:64444 diff --git a/.metals/metals.log b/.metals/metals.log new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 01eec68..0df8d58 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,23 @@ -# sia-ha -SIA alarm systems integration into Home Assistant -Based on https://github.com/bitblaster/alarmreceiver +[![hacs][hacsbadge]](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 -- Fire/gas tracker -- Water leak tracker -- Alarm tracking -- Armed state tracking -- Partial armed state tracking +- 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) @@ -24,31 +30,60 @@ This integration was tested with Ajax Systems security hub only. Other SIA hubs 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. - +{% if not installed %} +## Installation -## Home Assistant Setup +1. Click install. +1. Add at least the minimum configuration to your HA configuration, see below. + +### Minimum config +This is the least amount of information that needs to be in your config. This will result in a `sensor.hubname_last_heartbeat` being added after reboot. Dynamically any other sensors are added. -Place "sia" folder in **/custom_components** folder - ```yaml -# configuration.yaml - sia: - port: **port** + port: port hubs: - - name: **name** - account: **account** - encryption_key: *password* - + - name: hubname + account: account ``` -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. -- **encryption_key** (*Optional*): Encoding key. 16 ASCII characters. Must be same, as in hub properties. +{% endif %} +## Full configuration + +```yaml +sia: + port: port + hubs: + - name: hubname + account: account + encryption_key: password + zones: + - zone: 1 + name: zonename + sensors: + - alarm + - moisture + - smoke +``` + +## Configuration options + +Key | Type | Required | Description +-- | -- | -- | -- +`port` | `int` | `True` | Port that SIA will listen on. +`hubs` | `list` | `True` | List of all hubs to connect to. +`name` | `string` | `True` | Used to generate sensor ids. +`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. +`zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. +`zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" +`name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. +`sensors` | `list` | `False` | a list of sensors, must be of type: `alarm`, `moisture` (HA standard name for a leak sensor) or `smoke` -## Disclaimer -This software is supplied "AS IS" without any warranties and support. +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 +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file diff --git a/SIA Codes.xlsx b/SIA Codes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6e8d2baa97b0d084fb20ec884b2f53e774cbc007 GIT binary patch literal 58035 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 L@MTWd>bw5{V!?#l literal 0 HcmV?d00001 diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 22e9db3..16313c0 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -21,21 +21,40 @@ import sseclient import voluptuous as vol -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_MOISTURE, +) +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, +) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel import ( + ENTITY_ID_FORMAT as ALARM_FORMAT, +) + +# from homeassistant.components.sensor import SensorDevice +from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.const import ( + ATTR_FRIENDLY_NAME, CONF_NAME, CONF_PORT, + CONF_SENSORS, + CONF_ZONE, + DEVICE_CLASS_TIMESTAMP, STATE_OFF, STATE_ON, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change, @@ -43,19 +62,49 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow +from .sia_codes import SIACodes + _LOGGER = logging.getLogger(__name__) +# sia: +# port: +# hubs: +# - name: +# account: +# encryption_key: +# zones: +# - zone: 1 +# sensors: +# - leak +# - alarm +# - gas DOMAIN = "sia" CONF_HUBS = "hubs" CONF_ACCOUNT = "account" CONF_ENCRYPTION_KEY = "encryption_key" +CONF_ZONES = "zones" +HUB_SENSOR_NAME = "_last_heartbeat" + +DEVICE_CLASS_ALARM = "alarm" +TYPES = [DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE] + +ZONE_CONFIG = vol.Schema( + { + vol.Optional(CONF_ZONE, default=1): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SENSORS, default=[DEVICE_CLASS_ALARM]): vol.All( + cv.ensure_list, [vol.In(TYPES)] + ), + } +) HUB_CONFIG = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ACCOUNT): cv.string, vol.Optional(CONF_ENCRYPTION_KEY): cv.string, + vol.Optional(CONF_ZONES, default=[]): vol.All(cv.ensure_list, [ZONE_CONFIG]), } ) @@ -98,7 +147,7 @@ def setup(hass, config): else: hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) - for component in ["binary_sensor", "alarm_control_panel"]: + for component in ["binary_sensor", "alarm_control_panel", "sensor"]: discovery.load_platform(hass, component, DOMAIN, {}, config) server = socketserver.TCPServer(("", port), AlarmTCPHandler) @@ -110,30 +159,28 @@ def setup(hass, config): class Hub: + + sensor_types_classes = { + DEVICE_CLASS_ALARM: "SIAAlarmControlPanel", + DEVICE_CLASS_MOISTURE: "SIABinarySensor", + DEVICE_CLASS_SMOKE: "SIABinarySensor", + DEVICE_CLASS_TIMESTAMP: "SIASensor", + } + + # main set of responses to certain codes from SIA (see sia_codes for all of them) reactions = { - "BA": [{"state": "ALARM", "value": True}], - "TA": [{"state": "ALARM", "value": True}], - "CL": [ - {"state": "STATUS", "value": False}, - {"state": "STATUS_TEMP", "value": False}, - {"state": "ALARMCONTROL", "value": STATE_ALARM_ARMED_AWAY}, - ], - "NL": [ - {"state": "STATUS", "value": True}, - {"state": "STATUS_TEMP", "value": False}, - {"state": "ALARMCONTROL", "value": STATE_ALARM_ARMED_NIGHT}, - ], - "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}, - {"state": "ALARMCONTROL", "value": STATE_ALARM_DISARMED}, - ], - "RP": [], + "BA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "CL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, + "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, + "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, + "GA": {"type": DEVICE_CLASS_SMOKE, "new_state": True}, + "GH": {"type": DEVICE_CLASS_SMOKE, "new_state": False}, + "BR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": True}, + "RP": {"type": DEVICE_CLASS_TIMESTAMP, "new_state_eval": "utcnow()"}, } def __init__(self, hass, hub_config): @@ -141,42 +188,119 @@ def __init__(self, hass, hub_config): 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 - ) - self._states["ALARMCONTROL"] = SIAAlarmControlPanel( - "sia_alarmcontrol_" + self._name, STATE_ALARM_DISARMED, hass - ) - - def manage_string(self, msg): - _LOGGER.info("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) + self._zones = hub_config.get(CONF_ZONES) + self._entity_ids = [] + # create the hub sensor + self._upsert_sensor(0, DEVICE_CLASS_TIMESTAMP) + # add sensors for each zone as specified in the config. + for z in self._zones: + for s in z.get(CONF_SENSORS): + self._upsert_sensor(z.get(CONF_ZONE), s) + + def _update_states(self, sia, zoneID, message): + """ Updates the sensors.""" + # find the reactions for that code (if any) + reaction = self.reactions.get(sia.code) + if reaction: + # get the entity_id (or create it) + entity_id = self._upsert_sensor(zoneID, reaction["type"]) + # find out which action to take, update attribute, new state or eval for new state + attr = reaction.get("attr") + new_state = reaction.get("new_state") + new_state_eval = reaction.get("new_state_eval") + # do the work (can be more than 1) + if new_state or new_state_eval: + self._states[entity_id].state = ( + new_state if new_state else eval(new_state_eval) + ) + if attr: + self._states[entity_id].add_attribute( + { + "Last message": utcnow().isoformat() + + ": SIA: " + + str(sia) + + ", Message: " + + message + } + ) else: - _LOGGER.error("unknown event: " + tipo) - - for device in self._states: - self._states[device].assume_available() + _LOGGER.warning( + "Unhandled event type: " + str(sia) + ", Message: " + message + ) + # whenever a message comes in, the connection is good, so reset the availability clock for all devices. + for e in self._entity_ids: + self._states[e].assume_available() + + def _upsert_sensor(self, zone, sensor_type): + """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ + sensor_name = self._get_sensor_name(zone, sensor_type) + entity_id = self._get_entity_id(zone, sensor_type) + if not (entity_id in self._entity_ids): + zone_found = False + for z in self._zones: + # if the zone exists then a sensor is missing, + # so, find the zone and add the missing sensor + if z[CONF_ZONE] == zone: + z[CONF_SENSORS].append(sensor_type) + zone_found = True + break + if not zone_found: + # if zone does not exist, add it with the sensor and no name + self._zones.append({CONF_ZONE: zone, CONF_SENSORS: [sensor_type]}) + + # add the new sensor + constructor = self.sensor_types_classes.get(sensor_type) + if constructor: + self._states[entity_id] = eval(constructor)( + entity_id, sensor_name, sensor_type, self._hass + ) + else: + _LOGGER.warning("Unknown device type: " + sensor_type) + self._entity_ids.append(entity_id) + return entity_id + + def _parse_message(self, msg): + """ Parses the message and finds the SIA.""" + _LOGGER.debug("Parsing: " + msg) + parts = msg.split("|")[2].split("]")[0].split("/") + zoneID = parts[0][3:] + message = parts[1] + sia = SIACodes(message[0:2]) + return sia, zoneID, message + + def _get_entity_id(self, zone=0, sensor_type=None): + """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ + if str(zone) == "0": + return self._name + HUB_SENSOR_NAME + else: + if sensor_type: + return self._name + "_" + str(zone) + "_" + sensor_type + else: + _LOGGER.error( + "Not allowed to create an entity_id without type, unless zone == 0." + ) + + def _get_sensor_name(self, zone=0, sensor_type=None): + """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ + zone = int(zone) + if zone == 0: + return self._name + " Last heartbeat" + else: + zone_name = self._get_zone_name(zone) + if sensor_type: + return ( + self._name + + (" " + zone_name if zone_name else "") + + " " + + sensor_type + ) + else: + _LOGGER.error( + "Not allowed to create an entity_id without type, unless zone == 0." + ) + + def _get_zone_name(self, zone: int): + return next((z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone)) def process_line(self, line): _LOGGER.debug("Hub.process_line" + line.decode()) @@ -184,8 +308,8 @@ def process_line(self, line): 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.info("Hub.process_line found data: " + data.decode()) - self.manage_string(data.decode()) + _LOGGER.debug("Hub.process_line found data: " + data.decode()) + self._update_states(*self._parse_message(data.decode())) return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" @@ -202,7 +326,7 @@ def __init__(self, hass, hub_config): ) Hub.__init__(self, hass, hub_config) - def manage_string(self, msg): + def _manage_string(self, msg): iv = unhexlify( "00000000000000000000000000000000" ) # where i need to find proper IV ? Only this works good. @@ -215,8 +339,7 @@ def manage_string(self, msg): data = data[data.index(b"|") :] resmsg = data.decode(encoding="UTF-8", errors="replace") - - Hub.manage_string(self, resmsg) + Hub._update_states(self, *Hub._parse_message(self, resmsg)) def process_line(self, line): _LOGGER.debug("EncryptedHub.process_line" + line.decode()) @@ -225,28 +348,39 @@ def process_line(self, line): 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()) + self._manage_string(data.decode()) return ( '"*ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[" + self._ending ) class SIAAlarmControlPanel(RestoreEntity): - def __init__(self, name, state, hass): + def __init__(self, entity_id, name, device_class, hass): self._should_poll = False + self.entity_id = generate_entity_id( + entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass + ) self._name = name self.hass = hass self._is_available = True - self._state = state 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_ALARM_DISARMED + if state.state == STATE_ALARM_ARMED_AWAY: + self._state = STATE_ALARM_ARMED_AWAY + elif state.state == STATE_ALARM_ARMED_NIGHT: + self._state = STATE_ALARM_ARMED_NIGHT + elif state.state == STATE_ALARM_TRIGGERED: + self._state = STATE_ALARM_TRIGGERED + elif state.state == STATE_ALARM_DISARMED: + self._state = STATE_ALARM_DISARMED + else: + self._state = None else: - self._state = None + self._state = STATE_ALARM_DISARMED # assume disarmed self._async_track_unavailable() @property @@ -266,29 +400,30 @@ def available(self): return self._is_available def alarm_disarm(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") def alarm_arm_home(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") def alarm_arm_away(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") def alarm_arm_night(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") def alarm_trigger(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") def alarm_arm_custom_bypass(self, code=None): - _LOGGER.info("Not implemented.") + _LOGGER.debug("Not implemented.") @property def device_state_attributes(self): attrs = {} return attrs - def new_state(self, state): + @state.setter + def state(self, state): self._state = state self.async_schedule_update_ha_state() @@ -315,9 +450,12 @@ def _async_set_unavailable(self, now): class SIABinarySensor(RestoreEntity): - def __init__(self, name, device_class, hass): + def __init__(self, entity_id, name, device_class, hass): self._device_class = device_class self._should_poll = False + self.entity_id = generate_entity_id( + entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass + ) self._name = name self.hass = hass self._is_available = True @@ -361,7 +499,8 @@ def device_class(self): def is_on(self): return self._state - def new_state(self, state): + @state.setter + def state(self, state): self._state = state self.async_schedule_update_ha_state() @@ -387,6 +526,50 @@ def _async_set_unavailable(self, now): self.async_schedule_update_ha_state() +class SIASensor(Entity): + def __init__(self, entity_id, name, device_class, hass): + self._should_poll = False + self._device_class = device_class + self.entity_id = generate_entity_id( + entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass + ) + self._state = utcnow() + self._attr = {} + self._name = name + self.hass = hass + + @property + def name(self): + return self._name + + @property + def state(self): + return self._state.isoformat() + + @property + def device_state_attributes(self): + return self._attr + + def add_attribute(self, attr): + self._attr.update(attr) + + @property + def device_class(self): + return self._device_class + + @state.setter + def state(self, state): + self._state = state + + def assume_available(self): + pass + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:alarm-light-outline" + + class AlarmTCPHandler(socketserver.BaseRequestHandler): _received_data = "".encode() diff --git a/custom_components/sia/__pycache__/sia_codes.cpython-37.pyc b/custom_components/sia/__pycache__/sia_codes.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d68bfd714987f36c05a2e931bf81c1b5ce1e7601 GIT binary patch literal 29212 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 literal 0 HcmV?d00001 diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 235a425..ba16c5f 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,6 +1,8 @@ import logging import json +from . import SIAAlarmControlPanel + DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) @@ -9,6 +11,8 @@ 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: - if device == "ALARMCONTROL": - devices.append(hass.data[DOMAIN][account]._states[device]) + new_device = hass.data[DOMAIN][account]._states[device] + if isinstance(new_device, SIAAlarmControlPanel): + devices.append(new_device) + add_entities(devices) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index bec1d74..418722b 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,6 +1,8 @@ import logging import json +from . import SIABinarySensor + DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) @@ -9,6 +11,7 @@ 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: - if device != "ALARMCONTROL": - devices.append(hass.data[DOMAIN][account]._states[device]) + new_device = hass.data[DOMAIN][account]._states[device] + if isinstance(new_device, SIABinarySensor): + devices.append(new_device) add_entities(devices) diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py new file mode 100644 index 0000000..4c239d1 --- /dev/null +++ b/custom_components/sia/sensor.py @@ -0,0 +1,17 @@ +import logging +import json + +from . import SIASensor + +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: + new_device = hass.data[DOMAIN][account]._states[device] + if isinstance(new_device, SIASensor): + devices.append(new_device) + add_entities(devices) diff --git a/custom_components/sia/sia_codes.py b/custom_components/sia/sia_codes.py new file mode 100644 index 0000000..93f10fb --- /dev/null +++ b/custom_components/sia/sia_codes.py @@ -0,0 +1,1865 @@ +class SIACodes: + all_codes = { + "AA": { + "code": "AA", + "type": "Alarm – Panel Substitution", + "description": "An attempt to substitute an alternate alarm panel for a secure panel has been made", + "concerns": "Condition number", + }, + "AB": { + "code": "AB", + "type": "Abort", + "description": "An event message was not sent due to User action", + "concerns": "Zone or point", + }, + "AN": { + "code": "AN", + "type": "Analog Restoral", + "description": "An analog fire sensor has been restored to normal operation", + "concerns": "Zone or point", + }, + "AR": { + "code": "AR", + "type": "AC Restoral", + "description": "AC power has been restored", + "concerns": "Unused", + }, + "AS": { + "code": "AS", + "type": "Analog Service", + "description": "An analog fire sensor needs to be cleaned or calibrated", + "concerns": "Zone or point", + }, + "AT": { + "code": "AT", + "type": "AC Trouble", + "description": "AC power has been failed", + "concerns": "Unused", + }, + "BA": { + "code": "BA", + "type": "Burglary Alarm", + "description": "Burglary zone has been violated while armed", + "concerns": "Zone or point", + }, + "BB": { + "code": "BB", + "type": "Burglary Bypass", + "description": "Burglary zone has been bypassed", + "concerns": "Zone or point", + }, + "BC": { + "code": "BC", + "type": "Burglary Cancel", + "description": "Alarm has been cancelled by authorized user", + "concerns": "User number", + }, + "BD": { + "code": "BD", + "type": "Swinger Trouble", + "description": "A non-fire zone has been violated after a Swinger Shutdown on the zone", + "concerns": "Zone or point", + }, + "BE": { + "code": "BE", + "type": "Swinger Trouble Restore", + "description": "A non-fire zone restores to normal from a Swinger Trouble state", + "concerns": "Zone or point", + }, + "BG": { + "code": "BG", + "type": "Unverified Event - Burglary", + "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", + "concerns": "Zone or point", + }, + "BH": { + "code": "BH", + "type": "Burglary Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "BJ": { + "code": "BJ", + "type": "Burglary Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "BM": { + "code": "BM", + "type": "Burglary Alarm - Cross Point", + "description": "Burglary alarm w/cross point also in alarm - alarm verified", + "concerns": "Zone or point", + }, + "BR": { + "code": "BR", + "type": "Burglary Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "BS": { + "code": "BS", + "type": "Burglary Supervisory", + "description": "Unsafe intrusion detection system condition", + "concerns": "Zone or point", + }, + "BT": { + "code": "BT", + "type": "Burglary Trouble", + "description": "Burglary zone disabled by fault", + "concerns": "Zone or point", + }, + "BU": { + "code": "BU", + "type": "Burglary Unbypass", + "description": "Zone bypass has been removed", + "concerns": "Zone or point", + }, + "BV": { + "code": "BV", + "type": "Burglary Verified", + "description": "A burglary alarm has occurred and been verified within programmed conditions. (zone or point not sent)", + "concerns": "Area number", + }, + "BX": { + "code": "BX", + "type": "Burglary Test", + "description": "Burglary zone activated during testing", + "concerns": "Zone or point", + }, + "BZ": { + "code": "BZ", + "type": "Missing Supervision", + "description": "A non-fire Supervisory point has gone missing", + "concerns": "Zone or point", + }, + "CA": { + "code": "CA", + "type": "Automatic Closing", + "description": "System armed automatically", + "concerns": "Area number", + }, + "CD": { + "code": "CD", + "type": "Closing Delinquent", + "description": "The system has not been armed for a programmed amount of time", + "concerns": "Area number", + }, + "CE": { + "code": "CE", + "type": "Closing Extend", + "description": "Extend closing time", + "concerns": "User number", + }, + "CF": { + "code": "CF", + "type": "Forced Closing", + "description": "System armed, some zones not ready", + "concerns": "User number", + }, + "CG": { + "code": "CG", + "type": "Close Area", + "description": "System has been partially armed", + "concerns": "Area number", + }, + "CI": { + "code": "CI", + "type": "Fail to Close", + "description": "An area has not been armed at the end of the closing window", + "concerns": "Area number", + }, + "CJ": { + "code": "CJ", + "type": "Late Close", + "description": "An area was armed after the closing window", + "concerns": "User number", + }, + "CK": { + "code": "CK", + "type": "Early Close", + "description": "An area was armed before the closing window", + "concerns": "User number", + }, + "CL": { + "code": "CL", + "type": "Closing Report", + "description": "System armed, normal", + "concerns": "User number", + }, + "CM": { + "code": "CM", + "type": "Missing Alarm - Recent Closing", + "description": "A point has gone missing within 2 minutes of closing", + "concerns": "Zone or point", + }, + "CO": { + "code": "CO", + "type": "Command Sent", + "description": "A command has been sent to an expansion/peripheral device", + "concerns": "Condition number", + }, + "CP": { + "code": "CP", + "type": "Automatic Closing", + "description": "System armed automatically", + "concerns": "User number", + }, + "CQ": { + "code": "CQ", + "type": "Remote Closing", + "description": "The system was armed from a remote location", + "concerns": "User number", + }, + "CR": { + "code": "CR", + "type": "Recent Closing", + "description": "An alarm occurred within five minutes after the system was closed", + "concerns": "User number", + }, + "CS": { + "code": "CS", + "type": "Closing Keyswitch", + "description": "Account has been armed by keyswitch", + "concerns": "Zone or point", + }, + "CT": { + "code": "CT", + "type": "Late to Open", + "description": "System was not disarmed on time", + "concerns": "Area number", + }, + "CW": { + "code": "CW", + "type": "Was Force Armed", + "description": "Header for a force armed session, forced point msgs may follow", + "concerns": "Area number", + }, + "CX": { + "code": "CX", + "type": "Custom Function Executed", + "description": "The panel has executed a preprogrammed set of instructions", + "concerns": "Custom Function number", + }, + "CZ": { + "code": "CZ", + "type": "Point Closing", + "description": "A point, as opposed to a whole area or account, has closed", + "concerns": "Zone or point", + }, + "DA": { + "code": "DA", + "type": "Card Assigned", + "description": "An access ID has been added to the controller", + "concerns": "User number", + }, + "DB": { + "code": "DB", + "type": "Card Deleted", + "description": "An access ID has been deleted from the controller", + "concerns": "User number", + }, + "DC": { + "code": "DC", + "type": "Access Closed", + "description": "Access to all users prohibited", + "concerns": "Door number", + }, + "DD": { + "code": "DD", + "type": "Access Denied", + "description": "Access denied, unknown code", + "concerns": "Door number", + }, + "DE": { + "code": "DE", + "type": "Request to Enter", + "description": "An access point was opened via a Request to Enter device", + "concerns": "Door number", + }, + "DF": { + "code": "DF", + "type": "Door Forced", + "description": "Door opened without access request", + "concerns": "Door number", + }, + "DG": { + "code": "DG", + "type": "Access Granted", + "description": "Door access granted", + "concerns": "Door number", + }, + "DH": { + "code": "DH", + "type": "Door Left Open - Restoral", + "description": "An access point in a Door Left Open state has restored", + "concerns": "Door number", + }, + "DI": { + "code": "DI", + "type": "Access Denied – Passback", + "description": "Access denied because credential has not exited area before attempting to re-enter same area", + "concerns": "Door number", + }, + "DJ": { + "code": "DJ", + "type": "Door Forced - Trouble", + "description": "An access point has been forced open in an unarmed area", + "concerns": "Door number", + }, + "DK": { + "code": "DK", + "type": "Access Lockout", + "description": "Access denied, known code", + "concerns": "Door number", + }, + "DL": { + "code": "DL", + "type": "Door Left Open - Alarm", + "description": "An open access point when open time expired in an armed area", + "concerns": "Door number", + }, + "DM": { + "code": "DM", + "type": "Door Left Open - Trouble", + "description": "An open access point when open time expired in an unarmed area", + "concerns": "Door number", + }, + "DN": { + "code": "DN", + "type": "Door Left Open (non-alarm, non-trouble)", + "description": "An access point was open when the door cycle time expired", + "concerns": "Door number", + }, + "DO": { + "code": "DO", + "type": "Access Open", + "description": "Access to authorized users allowed", + "concerns": "Door number", + }, + "DP": { + "code": "DP", + "type": "Access Denied - Unauthorized Time", + "description": "An access request was denied because the request is occurring outside the user’s authorized time window(s)", + "concerns": "Door number", + }, + "DQ": { + "code": "DQ", + "type": "Access Denied - Unauthorized Arming State", + "description": "An access request was denied because the user was not authorized in this area when the area was armed", + "concerns": "Door number", + }, + "DR": { + "code": "DR", + "type": "Door Restoral", + "description": "Access alarm/trouble condition eliminated", + "concerns": "Door number", + }, + "DS": { + "code": "DS", + "type": "Door Station", + "description": "Identifies door for next report", + "concerns": "Door number", + }, + "DT": { + "code": "DT", + "type": "Access Trouble", + "description": "Access system trouble", + "concerns": "Unused", + }, + "DU": { + "code": "DU", + "type": "Dealer ID", + "description": "Dealer ID number", + "concerns": "Dealer ID", + }, + "DV": { + "code": "DV", + "type": "Access Denied - Unauthorized Entry Level", + "description": "An access request was denied because the user is not authorized in this area", + "concerns": "Door number", + }, + "DW": { + "code": "DW", + "type": "Access Denied - Interlock", + "description": "An access request was denied because the doors associated Interlock point is open", + "concerns": "Door number", + }, + "DX": { + "code": "DX", + "type": "Request to Exit", + "description": "An access point was opened via a Request to Exit device", + "concerns": "Door number", + }, + "DY": { + "code": "DY", + "type": "Door Locked", + "description": "The door’s lock has been engaged", + "concerns": "Door number", + }, + "DZ": { + "code": "DZ", + "type": "Access Denied - Door Secured", + "description": "An access request was denied because the door has been placed in an Access Closed state", + "concerns": "Door number", + }, + "EA": { + "code": "EA", + "type": "Exit Alarm", + "description": "An exit zone remained violated at the end of the exit delay period", + "concerns": "Zone or point", + }, + "EE": { + "code": "EE", + "type": "Exit Error", + "description": "An exit zone remained violated at the end of the exit delay period", + "concerns": "User number", + }, + "EJ": { + "code": "EJ", + "type": "Expansion Tamper Restore", + "description": "Expansion device tamper restoral", + "concerns": "Expansion device number", + }, + "EM": { + "code": "EM", + "type": "Expansion Device Missing", + "description": "Expansion device missing", + "concerns": "Expansion device number", + }, + "EN": { + "code": "EN", + "type": "Expansion Missing Restore", + "description": "Expansion device communications re-established", + "concerns": "Expansion device number", + }, + "ER": { + "code": "ER", + "type": "Expansion Restoral", + "description": "Expansion device trouble eliminated", + "concerns": "Expander number", + }, + "ES": { + "code": "ES", + "type": "Expansion Device Tamper", + "description": "Expansion device enclosure tamper", + "concerns": "Expansion device number", + }, + "ET": { + "code": "ET", + "type": "Expansion Trouble", + "description": "Expansion device trouble", + "concerns": "Expander number", + }, + "EX": { + "code": "EX", + "type": "External Device Condition", + "description": "A specific reportable condition is detected on an external device", + "concerns": "Device number", + }, + "EZ": { + "code": "EZ", + "type": "Missing Alarm - Exit Error", + "description": "A point remained missing at the end of the exit delay period", + "concerns": "Point number", + }, + "FA": { + "code": "FA", + "type": "Fire Alarm", + "description": "Fire condition detected", + "concerns": "Zone or point", + }, + "FB": { + "code": "FB", + "type": "Fire Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "FC": { + "code": "FC", + "type": "Fire Cancel", + "description": "A Fire Alarm has been cancelled by an authorized person", + "concerns": "Zone or point", + }, + "FG": { + "code": "FG", + "type": "Unverified Event – Fire", + "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", + "concerns": "Zone or point", + }, + "FH": { + "code": "FH", + "type": "Fire Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "FI": { + "code": "FI", + "type": "Fire Test Begin", + "description": "The transmitter area's fire test has begun", + "concerns": "Area number", + }, + "FJ": { + "code": "FJ", + "type": "Fire Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "FK": { + "code": "FK", + "type": "Fire Test End", + "description": "The transmitter area's fire test has ended", + "concerns": "Area number", + }, + "FL": { + "code": "FL", + "type": "Fire Alarm Silenced", + "description": "The fire panel’s sounder was silenced by command", + "concerns": "Zone or point", + }, + "FM": { + "code": "FM", + "type": "Fire Alarm - Cross Point", + "description": "Fire Alarm with Cross Point also in alarm verifying the Fire Alarm", + "concerns": "Point number", + }, + "FQ": { + "code": "FQ", + "type": "Fire Supervisory Trouble Restore", + "description": "A fire supervisory zone that was in trouble condition has now restored to normal", + "concerns": "Zone or point", + }, + "FR": { + "code": "FR", + "type": "Fire Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "FS": { + "code": "FS", + "type": "Fire Supervisory", + "description": "Unsafe fire detection system condition", + "concerns": "Zone or point", + }, + "FT": { + "code": "FT", + "type": "Fire Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "FU": { + "code": "FU", + "type": "Fire Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "FV": { + "code": "FV", + "type": "Fire Supervision Restore", + "description": "A fire supervision zone that was in alarm has restored to normal", + "concerns": "Zone or point", + }, + "FW": { + "code": "FW", + "type": "Fire Supervisory Trouble", + "description": "A fire supervisory zone is now in a trouble condition", + "concerns": "Zone or point", + }, + "FX": { + "code": "FX", + "type": "Fire Test", + "description": "Fire zone activated during test", + "concerns": "Zone or point", + }, + "FY": { + "code": "FY", + "type": "Missing Fire Trouble", + "description": "A fire point is now logically missing", + "concerns": "Zone or point", + }, + "FZ": { + "code": "FZ", + "type": "Missing Fire Supervision", + "description": "A Fire Supervisory point has gone missing", + "concerns": "Zone or point", + }, + "GA": { + "code": "GA", + "type": "Gas Alarm", + "description": "Gas alarm condition detected", + "concerns": "Zone or point", + }, + "GB": { + "code": "GB", + "type": "Gas Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "GH": { + "code": "GH", + "type": "Gas Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "GJ": { + "code": "GJ", + "type": "Gas Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "GR": { + "code": "GR", + "type": "Gas Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "GS": { + "code": "GS", + "type": "Gas Supervisory", + "description": "Unsafe gas detection system condition", + "concerns": "Zone or point", + }, + "GT": { + "code": "GT", + "type": "Gas Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "GU": { + "code": "GU", + "type": "Gas Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "GX": { + "code": "GX", + "type": "Gas Test", + "description": "Zone activated during test", + "concerns": "Zone or point", + }, + "HA": { + "code": "HA", + "type": "Holdup Alarm", + "description": "Silent alarm, user under duress", + "concerns": "Zone or point", + }, + "HB": { + "code": "HB", + "type": "Holdup Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "HH": { + "code": "HH", + "type": "Holdup Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "HJ": { + "code": "HJ", + "type": "Holdup Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "HR": { + "code": "HR", + "type": "Holdup Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "HS": { + "code": "HS", + "type": "Holdup Supervisory", + "description": "Unsafe holdup system condition", + "concerns": "Zone or point", + }, + "HT": { + "code": "HT", + "type": "Holdup Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "HU": { + "code": "HU", + "type": "Holdup Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "IA": { + "code": "IA", + "type": "Equipment Failure Condition", + "description": "A specific, reportable condition is detected on a device", + "concerns": "Point number", + }, + "IR": { + "code": "IR", + "type": "Equipment Fail - Restoral", + "description": "The equipment condition has been restored to normal", + "concerns": "Point number", + }, + "JA": { + "code": "JA", + "type": "User code Tamper", + "description": "Too many unsuccessful attempts have been made to enter a user ID", + "concerns": "Area number", + }, + "JD": { + "code": "JD", + "type": "Date Changed", + "description": "The date was changed in the transmitter/receiver", + "concerns": "User number", + }, + "JH": { + "code": "JH", + "type": "Holiday Changed", + "description": "The transmitter's holiday schedule has been changed", + "concerns": "User number", + }, + "JK": { + "code": "JK", + "type": "Latchkey Alert", + "description": "A designated user passcode has not been entered during a scheduled time window", + "concerns": "User number", + }, + "JL": { + "code": "JL", + "type": "Log Threshold", + "description": "The transmitter's log memory has reached its threshold level", + "concerns": "Unused", + }, + "JO": { + "code": "JO", + "type": "Log Overflow", + "description": "The transmitter's log memory has overflowed", + "concerns": "Unused", + }, + "JP": { + "code": "JP", + "type": "User On Premises", + "description": "A designated user passcode has been used to gain access to the premises.", + "concerns": "User number", + }, + "JR": { + "code": "JR", + "type": "Schedule Executed", + "description": "An automatic scheduled event was executed", + "concerns": "Area number", + }, + "JS": { + "code": "JS", + "type": "Schedule Changed", + "description": "An automatic schedule was changed", + "concerns": "User number", + }, + "JT": { + "code": "JT", + "type": "Time Changed", + "description": "The time was changed in the transmitter/receiver", + "concerns": "User number", + }, + "JV": { + "code": "JV", + "type": "User code Changed", + "description": "A user's code has been changed", + "concerns": "User number", + }, + "JX": { + "code": "JX", + "type": "User code Deleted", + "description": "A user's code has been removed", + "concerns": "User number", + }, + "JY": { + "code": "JY", + "type": "User code Added", + "description": "A user’s code has been added", + "concerns": "User number", + }, + "JZ": { + "code": "JZ", + "type": "User Level Set", + "description": "A user’s authority level has been set", + "concerns": "User number", + }, + "KA": { + "code": "KA", + "type": "Heat Alarm", + "description": "High temperature detected on premise", + "concerns": "Zone or point", + }, + "KB": { + "code": "KB", + "type": "Heat Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "KH": { + "code": "KH", + "type": "Heat Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "KJ": { + "code": "KJ", + "type": "Heat Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "KR": { + "code": "KR", + "type": "Heat Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "KS": { + "code": "KS", + "type": "Heat Supervisory", + "description": "Unsafe heat detection system condition", + "concerns": "Zone or point", + }, + "KT": { + "code": "KT", + "type": "Heat Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "KU": { + "code": "KU", + "type": "Heat Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "LB": { + "code": "LB", + "type": "Local Program", + "description": "Begin local programming", + "concerns": "Unused", + }, + "LD": { + "code": "LD", + "type": "Local Program Denied", + "description": "Access code incorrect", + "concerns": "Unused", + }, + "LE": { + "code": "LE", + "type": "Listen-in Ended", + "description": "The listen-in session has been terminated", + "concerns": "Unused", + }, + "LF": { + "code": "LF", + "type": "Listen-in Begin", + "description": "The listen-in session with the RECEIVER has begun", + "concerns": "Unused", + }, + "LR": { + "code": "LR", + "type": "Phone Line Restoral", + "description": "Phone line restored to service", + "concerns": "Line number", + }, + "LS": { + "code": "LS", + "type": "Local Program Success", + "description": "Local programming successful", + "concerns": "Unused", + }, + "LT": { + "code": "LT", + "type": "Phone Line Trouble", + "description": "Phone line trouble report", + "concerns": "Line number", + }, + "LU": { + "code": "LU", + "type": "Local Program Fail", + "description": "Local programming unsuccessful", + "concerns": "Unused", + }, + "LX": { + "code": "LX", + "type": "Local Programming Ended", + "description": "A local programming session has been terminated", + "concerns": "Unused", + }, + "MA": { + "code": "MA", + "type": "Medical Alarm", + "description": "Emergency assistance request", + "concerns": "Zone or point", + }, + "MB": { + "code": "MB", + "type": "Medical Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "MH": { + "code": "MH", + "type": "Medical Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "MI": { + "code": "MI", + "type": "Message", + "description": "A canned message is being sent", + "concerns": "Message number", + }, + "MJ": { + "code": "MJ", + "type": "Medical Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "MR": { + "code": "MR", + "type": "Medical Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "MS": { + "code": "MS", + "type": "Medical Supervisory", + "description": "Unsafe system condition exists", + "concerns": "Zone or point", + }, + "MT": { + "code": "MT", + "type": "Medical Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "MU": { + "code": "MU", + "type": "Medical Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "NA": { + "code": "NA", + "type": "No Activity", + "description": "There has been no zone activity for a programmed amount of time", + "concerns": "Zone number", + }, + "NC": { + "code": "NC", + "type": "Network Condition", + "description": "A communications network has a specific reportable condition", + "concerns": "Network number", + }, + "NF": { + "code": "NF", + "type": "Forced Perimeter Arm", + "description": "Some zones/points not ready", + "concerns": "Area number", + }, + "NL": { + "code": "NL", + "type": "Perimeter Armed", + "description": "An area has been perimeter armed", + "concerns": "Area number", + }, + "NM": { + "code": "NM", + "type": "Perimeter Armed, User Defined", + "description": "A user defined area has been perimeter armed", + "concerns": "Area number", + }, + "NR": { + "code": "NR", + "type": "Network Restoral", + "description": "A communications network has returned to normal operation", + "concerns": "Network number", + }, + "NS": { + "code": "NS", + "type": "Activity Resumed", + "description": "A zone has detected activity after an alert", + "concerns": "Zone number", + }, + "NT": { + "code": "NT", + "type": "Network Failure", + "description": "A communications network has failed", + "concerns": "Network number", + }, + "OA": { + "code": "OA", + "type": "Automatic Opening", + "description": "System has disarmed automatically", + "concerns": "Area number", + }, + "OC": { + "code": "OC", + "type": "Cancel Report", + "description": "Untyped zone cancel", + "concerns": "User number", + }, + "OG": { + "code": "OG", + "type": "Open Area", + "description": "System has been partially disarmed", + "concerns": "Area number", + }, + "OH": { + "code": "OH", + "type": "Early to Open from Alarm", + "description": "An area in alarm was disarmed before the opening window", + "concerns": "User number", + }, + "OI": { + "code": "OI", + "type": "Fail to Open", + "description": "An area has not been armed at the end of the opening window", + "concerns": "Area number", + }, + "OJ": { + "code": "OJ", + "type": "Late Open", + "description": "An area was disarmed after the opening window", + "concerns": "User number", + }, + "OK": { + "code": "OK", + "type": "Early Open", + "description": "An area was disarmed before the opening window", + "concerns": "User number", + }, + "OL": { + "code": "OL", + "type": "Late to Open from Alarm", + "description": "An area in alarm was disarmed after the opening window", + "concerns": "User number", + }, + "OP": { + "code": "OP", + "type": "Opening Report", + "description": "Account was disarmed", + "concerns": "User number", + }, + "OQ": { + "code": "OQ", + "type": "Remote Opening", + "description": "The system was disarmed from a remote location", + "concerns": "User number", + }, + "OR": { + "code": "OR", + "type": "Disarm From Alarm", + "description": "Account in alarm was reset/disarmed", + "concerns": "User number", + }, + "OS": { + "code": "OS", + "type": "Opening Keyswitch", + "description": "Account has been disarmed by keyswitch", + "concerns": "Zone or point", + }, + "OT": { + "code": "OT", + "type": "Late To Close", + "description": "System was not armed on time", + "concerns": "User number", + }, + "OU": { + "code": "OU", + "type": "Output State – Trouble", + "description": "An output on a peripheral device or NAC is not functioning", + "concerns": "Output number", + }, + "OV": { + "code": "OV", + "type": "Output State – Restore", + "description": "An output on a peripheral device or NAC is back to normal operation", + "concerns": "Output number", + }, + "OZ": { + "code": "OZ", + "type": "Point Opening", + "description": "A point, rather than a full area or account, disarmed", + "concerns": "Zone or point", + }, + "PA": { + "code": "PA", + "type": "Panic Alarm", + "description": "Emergency assistance request, manually activated", + "concerns": "Zone or point", + }, + "PB": { + "code": "PB", + "type": "Panic Bypass", + "description": "Panic zone has been bypassed", + "concerns": "Zone or point", + }, + "PH": { + "code": "PH", + "type": "Panic Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "PJ": { + "code": "PJ", + "type": "Panic Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "PR": { + "code": "PR", + "type": "Panic Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "PS": { + "code": "PS", + "type": "Panic Supervisory", + "description": "Unsafe system condition exists", + "concerns": "Zone or point", + }, + "PT": { + "code": "PT", + "type": "Panic Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "PU": { + "code": "PU", + "type": "Panic Unbypass", + "description": "Panic zone bypass has been removed", + "concerns": "Zone or point", + }, + "QA": { + "code": "QA", + "type": "Emergency Alarm", + "description": "Emergency assistance request", + "concerns": "Zone or point", + }, + "QB": { + "code": "QB", + "type": "Emergency Bypass", + "description": "Zone has been bypassed", + "concerns": "Zone or point", + }, + "QH": { + "code": "QH", + "type": "Emergency Alarm Restore", + "description": "Alarm condition has been eliminated", + "concerns": "Zone or point", + }, + "QJ": { + "code": "QJ", + "type": "Emergency Trouble Restore", + "description": "Trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "QR": { + "code": "QR", + "type": "Emergency Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "QS": { + "code": "QS", + "type": "Emergency Supervisory", + "description": "Unsafe system condition exists", + "concerns": "Zone or point", + }, + "QT": { + "code": "QT", + "type": "Emergency Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "QU": { + "code": "QU", + "type": "Emergency Unbypass", + "description": "Bypass has been removed", + "concerns": "Zone or point", + }, + "RA": { + "code": "RA", + "type": "Remote Programmer Call Failed", + "description": "Transmitter failed to communicate with the remote programmer", + "concerns": "Unused", + }, + "RB": { + "code": "RB", + "type": "Remote Program Begin", + "description": "Remote programming session initiated", + "concerns": "Unused", + }, + "RC": { + "code": "RC", + "type": "Relay Close", + "description": "A relay has energized", + "concerns": "Relay number", + }, + "RD": { + "code": "RD", + "type": "Remote Program Denied", + "description": "Access passcode incorrect", + "concerns": "Unused", + }, + "RN": { + "code": "RN", + "type": "Remote Reset", + "description": "A TRANSMITTER was reset via a remote programmer", + "concerns": "Unused", + }, + "RO": { + "code": "RO", + "type": "Relay Open", + "description": "A relay has de-energized", + "concerns": "Relay number", + }, + "RP": { + "code": "RP", + "type": "Automatic Test", + "description": "Automatic communication test report", + "concerns": "Unused", + }, + "RR": { + "code": "RR", + "type": "Power Up", + "description": "System lost power, is now restored", + "concerns": "Unused", + }, + "RS": { + "code": "RS", + "type": "Remote Program Success", + "description": "Remote programming successful", + "concerns": "Unused", + }, + "RT": { + "code": "RT", + "type": "Data Lost", + "description": "Dialer data lost, transmission error", + "concerns": "Line number", + }, + "RU": { + "code": "RU", + "type": "Remote Program Fail", + "description": "Remote programming unsuccessful", + "concerns": "Unused", + }, + "RX": { + "code": "RX", + "type": "Manual Test", + "description": "Manual communication test report", + "concerns": "User number", + }, + "RY": { + "code": "RY", + "type": "Test Off Normal", + "description": "Test signal(s) indicates abnormal condition(s) exist", + "concerns": "Zone or point", + }, + "SA": { + "code": "SA", + "type": "Sprinkler Alarm", + "description": "Sprinkler flow condition exists", + "concerns": "Zone or point", + }, + "SB": { + "code": "SB", + "type": "Sprinkler Bypass", + "description": "Sprinkler zone has been bypassed", + "concerns": "Zone or point", + }, + "SC": { + "code": "SC", + "type": "Change of State", + "description": "An expansion/peripheral device is reporting a new condition or state change", + "concerns": "Condition number", + }, + "SH": { + "code": "SH", + "type": "Sprinkler Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "SJ": { + "code": "SJ", + "type": "Sprinkler Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "SR": { + "code": "SR", + "type": "Sprinkler Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "SS": { + "code": "SS", + "type": "Sprinkler Supervisory", + "description": "Unsafe sprinkler system condition", + "concerns": "Zone or point", + }, + "ST": { + "code": "ST", + "type": "Sprinkler Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "SU": { + "code": "SU", + "type": "Sprinkler Unbypass", + "description": "Sprinkler zone bypass has been removed", + "concerns": "Zone or point", + }, + "TA": { + "code": "TA", + "type": "Tamper Alarm", + "description": "Alarm equipment enclosure opened", + "concerns": "Zone or point", + }, + "TB": { + "code": "TB", + "type": "Tamper Bypass", + "description": "Tamper detection has been bypassed", + "concerns": "Zone or point", + }, + "TC": { + "code": "TC", + "type": "All Points Tested", + "description": "All point tested", + "concerns": "Unused", + }, + "TE": { + "code": "TE", + "type": "Test End", + "description": "Communicator restored to operation", + "concerns": "Unused", + }, + "TH": { + "code": "TH", + "type": "Tamper Alarm Restore", + "description": "An Expansion Device’s tamper switch restores to normal from an Alarm state", + "concerns": "Unused", + }, + "TJ": { + "code": "TJ", + "type": "Tamper Trouble Restore", + "description": "An Expansion Device’s tamper switch restores to normal from a Trouble state", + "concerns": "Unused", + }, + "TP": { + "code": "TP", + "type": "Walk Test Point", + "description": "This point was tested during a Walk Test", + "concerns": "Point number", + }, + "TR": { + "code": "TR", + "type": "Tamper Restoral", + "description": "Alarm equipment enclosure has been closed", + "concerns": "Zone or point", + }, + "TS": { + "code": "TS", + "type": "Test Start", + "description": "Communicator taken out of operation", + "concerns": "Unused", + }, + "TT": { + "code": "TT", + "type": "Tamper Trouble", + "description": "Equipment enclosure opened in disarmed state", + "concerns": "Zone or point", + }, + "TU": { + "code": "TU", + "type": "Tamper Unbypass", + "description": "Tamper detection bypass has been removed", + "concerns": "Zone or point", + }, + "TW": { + "code": "TW", + "type": "Area Watch Start", + "description": "Area watch feature has been activated", + "concerns": "Unused", + }, + "TX": { + "code": "TX", + "type": "Test Report", + "description": "An unspecified (manual or automatic) communicator test", + "concerns": "Unused", + }, + "TZ": { + "code": "TZ", + "type": "Area Watch End", + "description": "Area watch feature has been deactivated", + "concerns": "Unused", + }, + "UA": { + "code": "UA", + "type": "Untyped Zone Alarm", + "description": "Alarm condition from zone of unknown type", + "concerns": "Zone or point", + }, + "UB": { + "code": "UB", + "type": "Untyped Zone Bypass", + "description": "Zone of unknown type has been bypassed", + "concerns": "Zone or point", + }, + "UG": { + "code": "UG", + "type": "Unverified Event – Untyped", + "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", + "concerns": "Zone or point", + }, + "UH": { + "code": "UH", + "type": "Untyped Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "UJ": { + "code": "UJ", + "type": "Untyped Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "UR": { + "code": "UR", + "type": "Untyped Zone Restoral", + "description": "Alarm/trouble condition eliminated from zone of unknown type", + "concerns": "Zone or point", + }, + "US": { + "code": "US", + "type": "Untyped Zone Supervisory", + "description": "Unsafe condition from zone of unknown type", + "concerns": "Zone or point", + }, + "UT": { + "code": "UT", + "type": "Untyped Zone Trouble", + "description": "Trouble condition from zone of unknown type", + "concerns": "Zone or point", + }, + "UU": { + "code": "UU", + "type": "Untyped Zone Unbypass", + "description": "Bypass on zone of unknown type has been removed", + "concerns": "Zone or point", + }, + "UX": { + "code": "UX", + "type": "Undefined", + "description": "An undefined alarm condition has occurred", + "concerns": "Unused", + }, + "UY": { + "code": "UY", + "type": "Untyped Missing Trouble", + "description": "A point or device which was not armed is now logically missing", + "concerns": "Zone or point", + }, + "UZ": { + "code": "UZ", + "type": "Untyped Missing Alarm", + "description": "A point or device which was armed is now logically missing", + "concerns": "Zone or point", + }, + "VI": { + "code": "VI", + "type": "Printer Paper In", + "description": "TRANSMITTER or RECEIVER paper in", + "concerns": "Printer number", + }, + "VO": { + "code": "VO", + "type": "Printer Paper Out", + "description": "TRANSMITTER or RECEIVER paper out", + "concerns": "Printer number", + }, + "VR": { + "code": "VR", + "type": "Printer Restore", + "description": "TRANSMITTER or RECEIVER trouble restored", + "concerns": "Printer number", + }, + "VT": { + "code": "VT", + "type": "Printer Trouble", + "description": "TRANSMITTER or RECEIVER trouble", + "concerns": "Printer number", + }, + "VX": { + "code": "VX", + "type": "Printer Test", + "description": "TRANSMITTER or RECEIVER test", + "concerns": "Printer number", + }, + "VY": { + "code": "VY", + "type": "Printer Online", + "description": "RECEIVER’S printer is now online", + "concerns": "Unused", + }, + "VZ": { + "code": "VZ", + "type": "Printer Offline", + "description": "RECEIVER’S printer is now offline", + "concerns": "Unused", + }, + "WA": { + "code": "WA", + "type": "Water Alarm", + "description": "Water detected at protected premises", + "concerns": "Zone or point", + }, + "WB": { + "code": "WB", + "type": "Water Bypass", + "description": "Water detection has been bypassed", + "concerns": "Zone or point", + }, + "WH": { + "code": "WH", + "type": "Water Alarm Restore", + "description": "Water alarm condition eliminated", + "concerns": "Zone or point", + }, + "WJ": { + "code": "WJ", + "type": "Water Trouble Restore", + "description": "Water trouble condition eliminated", + "concerns": "Zone or point", + }, + "WR": { + "code": "WR", + "type": "Water Restoral", + "description": "Water alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "WS": { + "code": "WS", + "type": "Water Supervisory", + "description": "Water unsafe water detection system condition", + "concerns": "Zone or point", + }, + "WT": { + "code": "WT", + "type": "Water Trouble", + "description": "Water zone disabled by fault", + "concerns": "Zone or point", + }, + "WU": { + "code": "WU", + "type": "Water Unbypass", + "description": "Water detection bypass has been removed", + "concerns": "Zone or point", + }, + "XA": { + "code": "XA", + "type": "Extra Account Report", + "description": "CS RECEIVER has received an event from a non-existent account", + "concerns": "Unused", + }, + "XE": { + "code": "XE", + "type": "Extra Point", + "description": "Panel has sensed an extra point not specified for this site", + "concerns": "Point number", + }, + "XF": { + "code": "XF", + "type": "Extra RF Point", + "description": "Panel has sensed an extra RF point not specified for this site", + "concerns": "Point number", + }, + "XH": { + "code": "XH", + "type": "RF Interference Restoral", + "description": "A radio device is no longer detecting RF Interference", + "concerns": "Receiver number", + }, + "XI": { + "code": "XI", + "type": "Sensor Reset", + "description": "A user has reset a sensor", + "concerns": "Zone or point", + }, + "XJ": { + "code": "XJ", + "type": "RF Receiver Tamper Restoral", + "description": "A Tamper condition at a premises RF Receiver has been restored", + "concerns": "Receiver number", + }, + "XL": { + "code": "XL", + "type": "Low Received Signal Strength", + "description": "The RF signal strength of a reported event is below minimum level", + "concerns": "Receiver number", + }, + "XM": { + "code": "XM", + "type": "Missing Alarm - Cross Point", + "description": "Missing Alarm verified by Cross Point in Alarm (or missing)", + "concerns": "Zone or point", + }, + "XQ": { + "code": "XQ", + "type": "RF Interference", + "description": "A radio device is detecting RF Interference", + "concerns": "Receiver number", + }, + "XR": { + "code": "XR", + "type": "Transmitter Battery Restoral", + "description": "Low battery has been corrected", + "concerns": "Zone or point", + }, + "XS": { + "code": "XS", + "type": "RF Receiver Tamper", + "description": "A Tamper condition at a premises receiver is detected", + "concerns": "Receiver number", + }, + "XT": { + "code": "XT", + "type": "Transmitter Battery Trouble", + "description": "Low battery in wireless transmitter", + "concerns": "Zone or point", + }, + "XW": { + "code": "XW", + "type": "Forced Point", + "description": "A point was forced out of the system at arm time", + "concerns": "Zone or point", + }, + "XX": { + "code": "XX", + "type": "Fail to Test", + "description": "A specific test from a panel was not received", + "concerns": "Unused", + }, + "YA": { + "code": "YA", + "type": "Bell Fault", + "description": "A trouble condition has been detected on a Local Bell, Siren, or Annunciator", + "concerns": "Unused", + }, + "YB": { + "code": "YB", + "type": "Busy Seconds", + "description": "Percent of time receiver's line card is on-line", + "concerns": "Line card number", + }, + "YC": { + "code": "YC", + "type": "Communications Fail", + "description": "RECEIVER and TRANSMITTER", + "concerns": "Unused", + }, + "YD": { + "code": "YD", + "type": "Receiver Line Card Trouble", + "description": "A line card identified by the passed address is in trouble", + "concerns": "Line card number", + }, + "YE": { + "code": "YE", + "type": "Receiver Line Card Restored", + "description": "A line card identified by the passed address is restored", + "concerns": "Line card number", + }, + "YF": { + "code": "YF", + "type": "Parameter Checksum Fail", + "description": "System data corrupted", + "concerns": "Unused", + }, + "YG": { + "code": "YG", + "type": "Parameter Changed", + "description": "A TRANSMITTER’S parameters have been changed", + "concerns": "Unused", + }, + "YH": { + "code": "YH", + "type": "Bell Restored", + "description": "A trouble condition has been restored on a Local Bell, Siren, or Annunciator", + "concerns": "Unused", + }, + "YI": { + "code": "YI", + "type": "Overcurrent Trouble", + "description": "An Expansion Device has detected an overcurrent condition", + "concerns": "Unused", + }, + "YJ": { + "code": "YJ", + "type": "Overcurrent Restore", + "description": "An Expansion Device has restored from an overcurrent condition", + "concerns": "Unused", + }, + "YK": { + "code": "YK", + "type": "Communications Restoral", + "description": "TRANSMITTER has resumed communication with a RECEIVER", + "concerns": "Unused", + }, + "YM": { + "code": "YM", + "type": "System Battery Missing", + "description": "TRANSMITTER/RECEIVER battery is missing", + "concerns": "Unused", + }, + "YN": { + "code": "YN", + "type": "Invalid Report", + "description": "TRANSMITTER has sent a packet with invalid data", + "concerns": "Unused", + }, + "YO": { + "code": "YO", + "type": "Unknown Message", + "description": "An unknown message was received from automation or the printer", + "concerns": "Unused", + }, + "YP": { + "code": "YP", + "type": "Power Supply Trouble", + "description": "TRANSMITTER/RECEIVER has a problem with the power supply", + "concerns": "Unused", + }, + "YQ": { + "code": "YQ", + "type": "Power Supply Restored", + "description": "TRANSMITTER’S/RECEIVER’S power supply has been restored", + "concerns": "Unused", + }, + "YR": { + "code": "YR", + "type": "System Battery Restoral", + "description": "Low battery has been corrected", + "concerns": "Unused", + }, + "YS": { + "code": "YS", + "type": "Communications Trouble", + "description": "RECEIVER and TRANSMITTER", + "concerns": "Unused", + }, + "YT": { + "code": "YT", + "type": "System Battery Trouble", + "description": "Low battery in control/communicator", + "concerns": "Unused", + }, + "YU": { + "code": "YU", + "type": "Diagnostic Error", + "description": "An expansion/peripheral device is reporting a diagnostic error", + "concerns": "Condition number", + }, + "YW": { + "code": "YW", + "type": "Watchdog Reset", + "description": "The TRANSMITTER created an internal reset", + "concerns": "Unused", + }, + "YX": { + "code": "YX", + "type": "Service Required", + "description": "A TRANSMITTER/RECEIVER needs service", + "concerns": "Unused", + }, + "YY": { + "code": "YY", + "type": "Status Report", + "description": "This is a header for an account status report transmission", + "concerns": "Unused", + }, + "YZ": { + "code": "YZ", + "type": "Service Completed", + "description": "Required TRANSMITTER / RECEIVER service completed", + "concerns": "Mfr defined", + }, + "ZA": { + "code": "ZA", + "type": "Freeze Alarm", + "description": "Low temperature detected at premises", + "concerns": "Zone or point", + }, + "ZB": { + "code": "ZB", + "type": "Freeze Bypass", + "description": "Low temperature detection has been bypassed", + "concerns": "Zone or point", + }, + "ZH": { + "code": "ZH", + "type": "Freeze Alarm Restore", + "description": "Alarm condition eliminated", + "concerns": "Zone or point", + }, + "ZJ": { + "code": "ZJ", + "type": "Freeze Trouble Restore", + "description": "Trouble condition eliminated", + "concerns": "Zone or point", + }, + "ZR": { + "code": "ZR", + "type": "Freeze Restoral", + "description": "Alarm/trouble condition has been eliminated", + "concerns": "Zone or point", + }, + "ZS": { + "code": "ZS", + "type": "Freeze Supervisory", + "description": "Unsafe freeze detection system condition", + "concerns": "Zone or point", + }, + "ZT": { + "code": "ZT", + "type": "Freeze Trouble", + "description": "Zone disabled by fault", + "concerns": "Zone or point", + }, + "ZU": { + "code": "ZU", + "type": "Freeze Unbypass", + "description": "Low temperature detection bypass removed", + "concerns": "Zone or point", + }, + } + + def __init__(self, value): + """Initiates a code object with just a code""" + full = self.all_codes.get(value, None) + if full: + self._code = full.get("code") + self._type = full.get("type") + self._description = full.get("description") + self._concerns = full.get("concerns") + else: + raise LookupError + + @property + def code(self): + return self._code + + @property + def type(self): + return self._type + + @property + def description(self): + return self._description + + @property + def concerns(self): + return self._concerns + + def __str__(self): + return "Code: {}, Type: {}, Description: {}, Concerns: {}".format( + self._code, self._type, self._description, self._concerns + ) diff --git a/info.md b/info.md index 7daff5a..0df8d58 100644 --- a/info.md +++ b/info.md @@ -4,36 +4,66 @@ _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` | Show something `True` or `False`. -`alarm_control_panel` | Alarm panel +`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 -- Fire/gas tracker -- Water leak tracker -- Alarm tracking -- Armed state tracking -- Partial armed state tracking -- Alarm panel for status in Alarm Panel visual +- 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. 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. {% if not installed %} ## Installation 1. Click install. -1. Add `sia:` to your HA configuration. +1. Add at least the minimum configuration to your HA configuration, see below. + +### Minimum config +This is the least amount of information that needs to be in your config. This will result in a `sensor.hubname_last_heartbeat` being added after reboot. Dynamically any other sensors are added. + +```yaml +sia: + port: port + hubs: + - name: hubname + account: account +``` {% endif %} -## Example configuration.yaml +## Full configuration ```yaml sia: port: port hubs: - - name: name + - name: hubname account: account encryption_key: password + zones: + - zone: 1 + name: zonename + sensors: + - alarm + - moisture + - smoke ``` ## Configuration options @@ -45,10 +75,12 @@ Key | Type | Required | Description `name` | `string` | `True` | Used to generate sensor ids. `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. +`zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. +`zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" +`name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. +`sensors` | `list` | `False` | a list of sensors, must be of type: `alarm`, `moisture` (HA standard name for a leak sensor) or `smoke` 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 From 60b98ed77ee15c03f483b14f6913058ae10661b6 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Sun, 15 Sep 2019 15:20:13 +0200 Subject: [PATCH 10/63] added ping_interval and fixed #1 --- .github/ISSUE_TEMPLATE/issue.md | 2 +- .../ISSUE_TEMPLATE/unhandled_event_type.md | 44 +++++ .metals/metals.h2.db | Bin 5361664 -> 0 bytes .metals/metals.lock.db | 6 - .metals/metals.log | 0 README.md | 4 +- custom_components/sia/__init__.py | 180 +++++++++++------- info.md | 4 +- 8 files changed, 161 insertions(+), 79 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/unhandled_event_type.md delete mode 100644 .metals/metals.h2.db delete mode 100644 .metals/metals.lock.db delete mode 100644 .metals/metals.log diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md index bbd0345..b40f23e 100644 --- a/.github/ISSUE_TEMPLATE/issue.md +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -23,7 +23,7 @@ If you are unsure about the version check the const.py file. ```yaml -Add your logs here. +Add your configuration here. ``` 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/.metals/metals.h2.db b/.metals/metals.h2.db deleted file mode 100644 index d4c4e77c5835bf9b981afe90f197c0dc6c7e62a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5361664 zcmeF)2Y6NW;m6?sSHJ*?0R%+EO8@~42w`_;Tp$_}Oae-WS4OPVS-?S;qucJ?+TGf{ zcWd_^cH6y&ZMEADZL4kn-`oTw649srd3fY`dHg)UIp?17p5MKka{_Vx{NkF%;=%<> zPi!vEpI@v_`~7`Y)e%)AMpXGhfB*pk1PBlyK!5-N0t5)$iv(`0KJ}7;-QAI&3+b42 z?7%cX5g=a-)wQROsa-X;wz0N$>=8Bp7+>?t2{qSFta;z0nwL(lxqM2^Sx41$A6;|5 zV`}CcTT@dU_s<#QuAe#X+S%jYe~)&{iwO`QK!5-N0t5&UAV8oZP?$BNFn#vA&Q+@# z7B)0A?A(4~Z>h0RpNYo}n%LIZu&7~Sp*W*3bQ3E?iJt zP&jhXa;H`fzF~30l7^)X%Nmw9tY}zy!i?khdFAoN;|r4py|Vw{=FK>+FnQ4Q)k_){ z&dJm%gQsRY#}$t&95ra_+C5vJJ)^!bbZ!b|157T0t5&UAV7cs0RjXF5FkL{&=r_CzHqn1$z~N)Atse8h zG1b?Ntv+$w;cuuJbzAMImmM)`Sl!5%j~_XeAprse2pnPp`F1+RagS~3DmC?#iY-ko zYf7!fo~GvZQgPk-=JvLh1+ArZrHOFT;`K)a_cAmdIzb$XudP#k;vt$3u)z7Wpap9INy*qZ) z&np~JoIURO!7Whdt7A2M+Cd#z*l76&bRcaHgv zn_iF;{JIhQIm34%(0`uqcqBQHoZdmFxc;Kv?K`%ee_s9WAuxC8!g>8?zkX})jxF1_ zU9fY|?8b!)=iPA~J9k~sd;1C+_g$cWJ-08iXyFiR$vD|^_KpkBt?%2-fnB)WWAm;- zFW+Y$ZeOzRV0zltmb!bI)~+kImR2>bZ|^C#tncdT8|{1M%-v@dy_fWExv=kO%R9H7 zn*$m`YxkHeHg|Tmmzp~ENL0_8U#uT@#jt@>wZ1z;wQW^pcImXX?w;;qcT@h$j?8In zY+SN+YvZ!b%NrXPE!(_hPH|0B_nP9IO^X|sZ(Y22(V}Hr&Ro7_%b90xUb1ZY=0z)3 zY;Ih+b?LH2TQ)7+x^?N66)P4l>D{z^;ihFPHg8(8Wzm-2mA%WiY+kZ*PKN#3wvL9p zSza;d&C=Gfs=u^ z%gMXnDnq<*@3+Fr6Y{os_z-WKyWISswe-EAhS<{XW1HK*OULIe^rYSAWzdJ@U2bj9 zn)*Kt`;M^K+_rk(anaJbc3oGgyE~u1d&X{sx4Eq&A1l@Ks@Jr&=5u&nj{CfU`Z zxyya1ELQH@leJCjD(9^4H~TsCEY^SI4aF=tZ*GRetM1Nl$m{OOXjnXGG-S&S#qQE6 zcXL22o)egD_jEukz6+H-J9S)!#MS@%kmwVxjEKGyG30p2kq`0dv97awVDbJ>x60do z&v>bf)`9tZ2F&cZl>gTO(|;*&r*D$(xs?Ux&B_RQ(cKs!-S=d8G!7abZLN26U^LDQ zz!~>+U^L$O!04WveW}lQxa@x(58eHPp{;f3vCzD+r&Mm~T%SLB`^QTE$?rcoIs1Fg z^^D?ir*)U|CspNou3WkJDlS^Nyl(m#xo)l8|4*pBdSdOgBWtdnR5N?>xT~g&YddP} zm#2<>*wJIwPa8AtnCh#hS5H0m@UImQzhcJdH`R~soH^(*=Wl0lbKS$jiqT~p7RTyt0UX7|fQJ0>Um+##OJ?RCg^J(%;fa<8XvYg?Dz;Y{s4dP{|A(QCeYqqlNKhd2p+o5(jt zFOIu>_UdpgUzx3YUZ_hj|` zXEsN@&xy?`Ji0h@&z<-G$Ht0z?AFy&Jhd(3Xi>4b)ZW>#y1Va)DmOfZqcUPH9%96_ ztm*77b@bo#1g~pDo}Uf<6nC$8$;!bG9rizTRxU0~$=Esh?v0(S`u6wU5IZ-wGKeN; z5IteY8^?iRH`FUDZ^EI@M*n&0&PPy7Pd>H=6b2s6_s=|aO^~`uEuCGheb4%LKjqAM zoRnSJwV&bK+Sa|ct?vm{Ib*GDRa;BtdEnk7d#^3*S@`x%>@~M^+Pbzp?YezlHh*L` zzkNTO?{3al9NJdrp=|#q_d5Q8!|rM7S>LzLj*WfqvfXcpy*4}KVNc9>x_qcVa`ySs z$K4oCgFcA%`69`FK9*J%CS+)BxSK<3uaULSUe4WjvvpI??7PgFKH@-M>{vem0t5&U zAV7cs0RjXF5Fl{S1SU?Y{QLiD2W z(<@*9pX}fNA9!n9AV7cs0RjXF5FkK+009CAOQ3F8<^F$?`~QP=25grA0RjXF5FkK+ z009C72po8Ux~j_k|B>$h54^Q45FkK+009C72oNAZfB=DmB`|Sh<=_8LI9S`aT>=CM z5FkK+009C72oNAZ;J^#i4X=Ftf4uMif8eccfdByl1PBlyK!5-N0t5&gEP;v5AMV?9 zUDb%%|J2@A`>%twAGS+?009C72oNAZfB*pk1PB}mfe}r^n}#*jZN8~*?%jS-@Al%>Z9BG{ zpI_|id-+2*G?vT#Gs=5rRMzp@kG%Wp5q;};asN7IHq9t4I;CZM@1~u-#hsfrpVM2} z{>BZ9%H{s4<^HLaSHJn@zh84$->YxDxv~edo9aeY{{DZp@BepiABFax009C72oNAZ zfB*pk1PI)@z{C-i@BerBoiAYy0RjXF5FkK+009C72oNA}Zx@(2wsQYJ>fU}F_MZR& z0t5&UAV7cs0RjXF5V&)Jy3v)t|3C7uJ72^c0t5&UAV7cs0RjXF5FkL{UM?`PrtZ&VW{~zxA|Lsr7JOTs=5FkK+009C72oNA} z?-m$7eeCW7m{m0_zpPG&kKaAb&jbh%AV7cs0RjXF5FkK+z#$+os=hWyaZD=YzoSOn znqN&%$EISMk?PaTG%L+c$E7)GZkm^lPbZ{?G(Rm!C#HpIQEE(!(~`6_ElbPOinKD_ zC*3#QFWo;qAU!ZWC^e<#)RJ0LDXmJY)0)(lPD&@I_Ov#2q|UT1oszmzcj`&&(}r|v zIxTHX4^9tB4^0nCr>Al{BW+5X)0VU~^`^eRG4b$pR@#;xkHKs-dSu$3 zcBGx@!gNu(IQ9L@i(Tna>Cx#i>9Of?>C$vrx;$Nx9-p3&o|vALo}8YNo|>*qPfJfv z&q&Wq&q~iu&q>cs&r8owFGw#;FG?>?FG*LWm!_Acm#0^xSEg5`SEtvc*QTq}>(cAf z8`2xoo6?)pThd$8+tS<9JJLJTyVASUd(wN;`_lW<2hs=Ahtf6a!|5aGqv>PmWB zVfs<}ar#O6Y5H0EdHO~AWx787D*ZbBCf$&3OutRPOTSNlNPkR!N`Fo_rN5-VroW}X zr<>C)=^yEz>0jyAbX)p&`cL|ARpn{iuwnUmcp8xk>9901jY^}_;i)=}Nn_KvRFi7c z5veYXPZQF_bYz;8CZ{Rss5CVlou;K@()4s}DyA8!KFv(C((H6xnv>?HdFl9cLTX6! z(}HwjT9_84#Yg0$+OzYAqsVjA-p0qw~NT;UL(#G`Q^pNz>^ssb#DyK8jrnEV2Nn2BIIx{^y zot3twN2IgUIqBSVUOGQrkRF+~ryXf$x-eaoE>4%EUFlKj(djYivFUN?(sWt6JYA6< zpPrDOn4XlLoSu@NnyySwOHWVFNY6~qO3zNuNzYBsOV3X)NH0t;N-s_?Nmr$prkACc zr&pv`rdOp`r`M#{rmNHI((BV3(i_v8(woy;(p%Hp(%aKJ(mT_;(!0}p(tFeU()-f~ z(g)Lr(lzPB=_Bc*>0{~R=@aRb=~L;`=`-oG>2vAx=?m$L=}YO_^yT!G^wspW^!4x<36X{W|?7-H>ifzfHeO zzfXTie@uT$e@-{0zofsWzooyYo6{}nAL*azU+LC#Tl#nUPx^0F)$pn`Eaew{e?M<{ zAsv=Rrcr5hIy_aUF==cXmugaNIwIAj@o7Sun2t=7(&RKH9hIi0qtmo>Oq!mKO~o`L z)u)+hR+^oTOLNlPG%p>WPDl-Dep--DObgSZ)R-2hC246|mX@a#X=S=kx^KE)x_^2= zdSH4`YD&$iCAFqfT9sC(HK{F~lul0VX>ICAooQVhzlQ+H`e#U3z_bLwaL+Q+jiHOL}X1 zTY7tXM|x*^S9*7PPkL{9UwVJ~K>A?%P`W04IDI62G<__6Jbfa4GJPt2I(;U6HhnIA zK7Ap5F?}gro4%aBlD?Y0mcE|8k-nL(OW#W0PTxu2P2Wr3Pd`XMOg~CLPCrRMO+QON zPrpdNOxLGhrC+Dtq#M$W>9^^3>G$am>5u78>Cfq=^q2J4^tbf)baT2T{UiM|{VUy? zZcG18|4IL?sv1$1hNa;tzZp?ThozBeR2rQQPt|Em8k@$YnpB&PNOfs^nvf=@Bh#cb zIZa7NrK#!YG%X#Irl(_5G0jNzX=a+0W~bxQoHRGhOUI`ZQbU@b7Nir?!n7zgrp0MV zTAG%ntHpB|7Nm>!gxQgdoat*Mk&rPXOoYD*`jlT&+In>tcwT9-~q zU8y_ur1fb-IyIe^Hl_!shopz5ho#d~Ih~Op9PLD~CO^-{Lrpwah>5BCD^n~=p^rZCU^py0} zbY*&4dU|?BdS-f7dUkqFdTx4NdVYFAdSQA|dU1M5x+=Xiy)3;vy&}Cby(+yry(Yaj zU7cQ+UZ38O-k9E$-kjc&-kRQ)-k#o(-kIK&-ksi)-kaW+-k&~@KA1j~u1OzGA4wlg zA4?xkpGcofpGu!jpGluhpG%)lUr1j}UrN`eFQ>1hucoi1ucvRMZ>HSoJ9f3~JfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM Y7%*VKfB^#r3>YwAz<>b*1`K=#0%`i5O#lD@ diff --git a/.metals/metals.lock.db b/.metals/metals.lock.db deleted file mode 100644 index d55fc02..0000000 --- a/.metals/metals.lock.db +++ /dev/null @@ -1,6 +0,0 @@ -#FileLock -#Sun Sep 08 14:39:55 CEST 2019 -hostName=localhost -id=16d10e34db621fb628123ae9ed2e15c0ab0a6fae115 -method=file -server=localhost\:64444 diff --git a/.metals/metals.log b/.metals/metals.log deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 0df8d58..aa64f00 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Platform | Description 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. +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 @@ -57,6 +57,7 @@ sia: - name: hubname account: account encryption_key: password + ping_interval: pinginterval zones: - zone: 1 name: zonename @@ -75,6 +76,7 @@ Key | Type | Required | Description `name` | `string` | `True` | Used to generate sensor ids. `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` | `False` | 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. `zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. `zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" `name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 16313c0..02e2c24 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -21,20 +21,16 @@ import sseclient import voluptuous as vol -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_MOISTURE, +from homeassistant.components.alarm_control_panel import ( + ENTITY_ID_FORMAT as ALARM_FORMAT, + AlarmControlPanel, ) from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, + BinarySensorDevice, ) -from homeassistant.components.alarm_control_panel import AlarmControlPanel -from homeassistant.components.alarm_control_panel import ( - ENTITY_ID_FORMAT as ALARM_FORMAT, -) - -# from homeassistant.components.sensor import SensorDevice from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.const import ( ATTR_FRIENDLY_NAME, @@ -43,12 +39,13 @@ CONF_SENSORS, CONF_ZONE, DEVICE_CLASS_TIMESTAMP, - STATE_OFF, - STATE_ON, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_OFF, + STATE_ON, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -66,27 +63,18 @@ _LOGGER = logging.getLogger(__name__) -# sia: -# port: -# hubs: -# - name: -# account: -# encryption_key: -# zones: -# - zone: 1 -# sensors: -# - leak -# - alarm -# - gas - DOMAIN = "sia" -CONF_HUBS = "hubs" + CONF_ACCOUNT = "account" CONF_ENCRYPTION_KEY = "encryption_key" +CONF_HUBS = "hubs" +CONF_PING_INTERVAL = "ping_interval" CONF_ZONES = "zones" -HUB_SENSOR_NAME = "_last_heartbeat" DEVICE_CLASS_ALARM = "alarm" +HUB_SENSOR_NAME = "_last_heartbeat" +HUB_ZONE = 0 + TYPES = [DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE] ZONE_CONFIG = vol.Schema( @@ -104,6 +92,9 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ACCOUNT): cv.string, vol.Optional(CONF_ENCRYPTION_KEY): cv.string, + vol.Optional(CONF_PING_INTERVAL, default=1): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1440) + ), vol.Optional(CONF_ZONES, default=[]): vol.All(cv.ensure_list, [ZONE_CONFIG]), } ) @@ -124,11 +115,10 @@ ID_STRING = '"SIA-DCS"'.encode() ID_STRING_ENCODED = '"*SIA-DCS"'.encode() - -TIME_TILL_UNAVAILABLE = timedelta(minutes=3) - ID_R = "\r".encode() +PING_INTERVAL_MARGIN = timedelta(seconds=30) + hass_platform = None @@ -170,17 +160,29 @@ class Hub: # main set of responses to certain codes from SIA (see sia_codes for all of them) reactions = { "BA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, - "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "BR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "CA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CF": { + "type": DEVICE_CLASS_ALARM, + "new_state": STATE_ALARM_ARMED_CUSTOM_BYPASS, + }, + "CG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, "CL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, - "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, - "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, + "CP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, "GA": {"type": DEVICE_CLASS_SMOKE, "new_state": True}, "GH": {"type": DEVICE_CLASS_SMOKE, "new_state": False}, - "BR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, + "OA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, "OP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": True}, + "OQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, "RP": {"type": DEVICE_CLASS_TIMESTAMP, "new_state_eval": "utcnow()"}, + "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, + "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, + "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": True}, } def __init__(self, hass, hub_config): @@ -190,8 +192,9 @@ def __init__(self, hass, hub_config): self._states = {} self._zones = hub_config.get(CONF_ZONES) self._entity_ids = [] + self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) # create the hub sensor - self._upsert_sensor(0, DEVICE_CLASS_TIMESTAMP) + self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) # add sensors for each zone as specified in the config. for z in self._zones: for s in z.get(CONF_SENSORS): @@ -210,10 +213,17 @@ def _update_states(self, sia, zoneID, message): new_state_eval = reaction.get("new_state_eval") # do the work (can be more than 1) if new_state or new_state_eval: + _LOGGER.debug( + "Will set state for entity: " + + entity_id + + " to state: " + + (new_state if new_state else new_state_eval) + ) self._states[entity_id].state = ( new_state if new_state else eval(new_state_eval) ) if attr: + _LOGGER.debug("Will set attribute entity: " + entity_id) self._states[entity_id].add_attribute( { "Last message": utcnow().isoformat() @@ -231,6 +241,24 @@ def _update_states(self, sia, zoneID, message): for e in self._entity_ids: self._states[e].assume_available() + def _parse_message(self, msg): + """ Parses the message and finds the SIA.""" + parts = msg.split("|")[2].split("]")[0].split("/") + zoneID = parts[0][3:] + message = parts[1] + sia = SIACodes(message[0:2]) + _LOGGER.debug( + "Incoming parsed: " + + msg + + " to sia: " + + str(sia) + + " for zone: " + + zoneID + + " with message: " + + message + ) + return sia, zoneID, message + def _upsert_sensor(self, zone, sensor_type): """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ sensor_name = self._get_sensor_name(zone, sensor_type) @@ -239,7 +267,7 @@ def _upsert_sensor(self, zone, sensor_type): zone_found = False for z in self._zones: # if the zone exists then a sensor is missing, - # so, find the zone and add the missing sensor + # so, get the zone and add the missing sensor if z[CONF_ZONE] == zone: z[CONF_SENSORS].append(sensor_type) zone_found = True @@ -247,27 +275,23 @@ def _upsert_sensor(self, zone, sensor_type): if not zone_found: # if zone does not exist, add it with the sensor and no name self._zones.append({CONF_ZONE: zone, CONF_SENSORS: [sensor_type]}) - + # add the new sensor constructor = self.sensor_types_classes.get(sensor_type) if constructor: self._states[entity_id] = eval(constructor)( - entity_id, sensor_name, sensor_type, self._hass + entity_id, + sensor_name, + sensor_type, + zone, + self._ping_interval, + self._hass, ) else: _LOGGER.warning("Unknown device type: " + sensor_type) self._entity_ids.append(entity_id) return entity_id - def _parse_message(self, msg): - """ Parses the message and finds the SIA.""" - _LOGGER.debug("Parsing: " + msg) - parts = msg.split("|")[2].split("]")[0].split("/") - zoneID = parts[0][3:] - message = parts[1] - sia = SIACodes(message[0:2]) - return sia, zoneID, message - def _get_entity_id(self, zone=0, sensor_type=None): """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ if str(zone) == "0": @@ -303,12 +327,12 @@ def _get_zone_name(self, zone: int): return next((z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone)) def process_line(self, line): - _LOGGER.debug("Hub.process_line" + line.decode()) + # _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()) + # _LOGGER.debug("Hub.process_line found data: " + data.decode()) self._update_states(*self._parse_message(data.decode())) return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" @@ -332,22 +356,22 @@ def _manage_string(self, msg): ) # 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") - ) + # _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._update_states(self, *Hub._parse_message(self, resmsg)) def process_line(self, line): - _LOGGER.debug("EncryptedHub.process_line" + line.decode()) + # _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()) + # _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode()) self._manage_string(data.decode()) return ( '"*ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[" + self._ending @@ -355,13 +379,15 @@ def process_line(self, line): class SIAAlarmControlPanel(RestoreEntity): - def __init__(self, entity_id, name, device_class, hass): + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._should_poll = False self.entity_id = generate_entity_id( entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass ) self._name = name self.hass = hass + self._ping_interval = ping_interval + self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} self._is_available = True self._remove_unavailability_tracker = None @@ -377,6 +403,8 @@ async def async_added_to_hass(self): self._state = STATE_ALARM_TRIGGERED elif state.state == STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED + elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + self._state = STATE_ALARM_ARMED_CUSTOM_BYPASS else: self._state = None else: @@ -387,6 +415,10 @@ async def async_added_to_hass(self): def name(self): return self._name + @property + def ping_interval(self): + return str(self._ping_interval) + @property def state(self): return self._state @@ -419,8 +451,7 @@ def alarm_arm_custom_bypass(self, code=None): @property def device_state_attributes(self): - attrs = {} - return attrs + return self._attr @state.setter def state(self, state): @@ -435,7 +466,9 @@ 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 + self.hass, + self._async_set_unavailable, + utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, ) if not self._is_available: self._is_available = True @@ -450,9 +483,11 @@ def _async_set_unavailable(self, now): class SIABinarySensor(RestoreEntity): - def __init__(self, entity_id, name, device_class, hass): + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._device_class = device_class self._should_poll = False + self._ping_interval = ping_interval + self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} self.entity_id = generate_entity_id( entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass ) @@ -474,6 +509,10 @@ async def async_added_to_hass(self): def name(self): return self._name + @property + def ping_interval(self): + return str(self._ping_interval) + @property def state(self): return STATE_ON if self.is_on else STATE_OFF @@ -488,8 +527,7 @@ def available(self): @property def device_state_attributes(self): - attrs = {} - return attrs + return self._attr @property def device_class(self): @@ -512,7 +550,9 @@ 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 + self.hass, + self._async_set_unavailable, + utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, ) if not self._is_available: self._is_available = True @@ -527,14 +567,14 @@ def _async_set_unavailable(self, now): class SIASensor(Entity): - def __init__(self, entity_id, name, device_class, hass): + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._should_poll = False self._device_class = device_class self.entity_id = generate_entity_id( entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass ) self._state = utcnow() - self._attr = {} + self._attr = {CONF_PING_INTERVAL: str(ping_interval), CONF_ZONE: zone} self._name = name self.hass = hass @@ -574,7 +614,7 @@ class AlarmTCPHandler(socketserver.BaseRequestHandler): _received_data = "".encode() def handle_line(self, line): - _LOGGER.debug("Income raw string: " + line.decode()) + # _LOGGER.debug("Income raw string: " + line.decode()) accountId = line[line.index(b"#") + 1 : line.index(b"[")].decode() pos = line.find(b'"') @@ -628,7 +668,7 @@ def CRCCalc(msg): CRC = 0 for letter in msg: temp = letter - for j in range(0, 8): # @UnusedVariable + for _ in range(0, 8): temp ^= CRC & 1 CRC >>= 1 if (temp & 1) != 0: @@ -642,7 +682,7 @@ def CRCCalc2(msg): CRC = 0 for letter in msg: temp = ord(letter) - for j in range(0, 8): # @UnusedVariable + for _ in range(0, 8): temp ^= CRC & 1 CRC >>= 1 if (temp & 1) != 0: diff --git a/info.md b/info.md index 0df8d58..aa64f00 100644 --- a/info.md +++ b/info.md @@ -28,7 +28,7 @@ Platform | Description 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. +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 @@ -57,6 +57,7 @@ sia: - name: hubname account: account encryption_key: password + ping_interval: pinginterval zones: - zone: 1 name: zonename @@ -75,6 +76,7 @@ Key | Type | Required | Description `name` | `string` | `True` | Used to generate sensor ids. `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` | `False` | 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. `zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. `zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" `name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. From 85d405b36e2c75b9ccad4f0a113a3611fd3ce736 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Sun, 15 Sep 2019 15:21:41 +0200 Subject: [PATCH 11/63] small readme fix --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index aa64f00..fb1e689 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Platform | Description 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. @@ -47,7 +47,6 @@ sia: account: account ``` -{% endif %} ## Full configuration ```yaml From a21274afcb74240adc951713302407fc2e698aea Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Mon, 23 Sep 2019 15:34:33 +0200 Subject: [PATCH 12/63] fix for #3 --- .github/ISSUE_TEMPLATE/issue.md | 2 +- .gitignore | 1 + custom_components/sia/__init__.py | 55 ++++++++++++++++++++----------- 3 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 .gitignore diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md index b40f23e..da55566 100644 --- a/.github/ISSUE_TEMPLATE/issue.md +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -14,7 +14,7 @@ Issues not containing the minimum requirements will be closed: --> -## Version of the custom_component +## Version of the custom_component and HA setup (version, OS, etc) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe9c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/ \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 02e2c24..fbb56e9 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -191,7 +191,7 @@ def __init__(self, hass, hub_config): self._hass = hass self._states = {} self._zones = hub_config.get(CONF_ZONES) - self._entity_ids = [] + self._sensor_ids = [] self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) # create the hub sensor self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) @@ -206,7 +206,7 @@ def _update_states(self, sia, zoneID, message): reaction = self.reactions.get(sia.code) if reaction: # get the entity_id (or create it) - entity_id = self._upsert_sensor(zoneID, reaction["type"]) + sensor_id = self._upsert_sensor(zoneID, reaction["type"]) # find out which action to take, update attribute, new state or eval for new state attr = reaction.get("attr") new_state = reaction.get("new_state") @@ -215,16 +215,16 @@ def _update_states(self, sia, zoneID, message): if new_state or new_state_eval: _LOGGER.debug( "Will set state for entity: " - + entity_id + + sensor_id + " to state: " + (new_state if new_state else new_state_eval) ) - self._states[entity_id].state = ( + self._states[sensor_id].state = ( new_state if new_state else eval(new_state_eval) ) if attr: - _LOGGER.debug("Will set attribute entity: " + entity_id) - self._states[entity_id].add_attribute( + _LOGGER.debug("Will set attribute entity: " + sensor_id) + self._states[sensor_id].add_attribute( { "Last message": utcnow().isoformat() + ": SIA: " @@ -238,8 +238,8 @@ def _update_states(self, sia, zoneID, message): "Unhandled event type: " + str(sia) + ", Message: " + message ) # whenever a message comes in, the connection is good, so reset the availability clock for all devices. - for e in self._entity_ids: - self._states[e].assume_available() + for s in self._states: + s.assume_available() def _parse_message(self, msg): """ Parses the message and finds the SIA.""" @@ -261,9 +261,8 @@ def _parse_message(self, msg): def _upsert_sensor(self, zone, sensor_type): """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ - sensor_name = self._get_sensor_name(zone, sensor_type) - entity_id = self._get_entity_id(zone, sensor_type) - if not (entity_id in self._entity_ids): + sensor_id = self._get_id(zone, sensor_type) + if not (sensor_id in self._sensor_ids): zone_found = False for z in self._zones: # if the zone exists then a sensor is missing, @@ -277,22 +276,24 @@ def _upsert_sensor(self, zone, sensor_type): self._zones.append({CONF_ZONE: zone, CONF_SENSORS: [sensor_type]}) # add the new sensor + sensor_name = self._get_sensor_name(zone, sensor_type) constructor = self.sensor_types_classes.get(sensor_type) if constructor: - self._states[entity_id] = eval(constructor)( - entity_id, + new_sensor = eval(constructor)( + sensor_id, sensor_name, sensor_type, zone, self._ping_interval, self._hass, ) + self._states[sensor_id] = new_sensor else: _LOGGER.warning("Unknown device type: " + sensor_type) - self._entity_ids.append(entity_id) - return entity_id + self._sensor_ids.append(sensor_id) + return sensor_id - def _get_entity_id(self, zone=0, sensor_type=None): + def _get_id(self, zone=0, sensor_type=None): """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ if str(zone) == "0": return self._name + HUB_SENSOR_NAME @@ -324,7 +325,9 @@ def _get_sensor_name(self, zone=0, sensor_type=None): ) def _get_zone_name(self, zone: int): - return next((z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone)) + return next( + (z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone), None + ) def process_line(self, line): # _LOGGER.debug("Hub.process_line" + line.decode()) @@ -381,7 +384,7 @@ def process_line(self, line): class SIAAlarmControlPanel(RestoreEntity): def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._should_poll = False - self.entity_id = generate_entity_id( + self._entity_id = generate_entity_id( entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass ) self._name = name @@ -411,6 +414,10 @@ async def async_added_to_hass(self): self._state = STATE_ALARM_DISARMED # assume disarmed self._async_track_unavailable() + @property + def entity_id(self): + return self._entity_id + @property def name(self): return self._name @@ -488,7 +495,7 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._should_poll = False self._ping_interval = ping_interval self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} - self.entity_id = generate_entity_id( + self._entity_id = generate_entity_id( entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass ) self._name = name @@ -496,6 +503,10 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._is_available = True self._remove_unavailability_tracker = None + @property + def entity_id(self): + return self._entity_id + async def async_added_to_hass(self): await super().async_added_to_hass() state = await self.async_get_last_state() @@ -570,7 +581,7 @@ class SIASensor(Entity): def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._should_poll = False self._device_class = device_class - self.entity_id = generate_entity_id( + self._entity_id = generate_entity_id( entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass ) self._state = utcnow() @@ -578,6 +589,10 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._name = name self.hass = hass + @property + def entity_id(self): + return self._entity_id + @property def name(self): return self._name From f12f1cfb21574dad945f4c5872dbfac5aa47fc3d Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Mon, 23 Sep 2019 15:59:12 +0200 Subject: [PATCH 13/63] small fix --- custom_components/sia/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index fbb56e9..5958a92 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -238,8 +238,8 @@ def _update_states(self, sia, zoneID, message): "Unhandled event type: " + str(sia) + ", Message: " + message ) # whenever a message comes in, the connection is good, so reset the availability clock for all devices. - for s in self._states: - s.assume_available() + for s in self._sensor_ids: + self._states[s].assume_available() def _parse_message(self, msg): """ Parses the message and finds the SIA.""" From 595e7f722b9be5bfb0862c11c2357836429850d3 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Tue, 24 Sep 2019 13:07:00 +0200 Subject: [PATCH 14/63] fixes to list index out of range error (issue #3) --- custom_components/sia/__init__.py | 100 +++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 5958a92..38f7621 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -243,9 +243,12 @@ def _update_states(self, sia, zoneID, message): def _parse_message(self, msg): """ Parses the message and finds the SIA.""" - parts = msg.split("|")[2].split("]")[0].split("/") + _LOGGER.debug("Message to parse: " + msg) + # example1: |#XXXXX|Nri1/NL501]_10:47:31,09-24-2019 + # example2: |Nri1/OP501]_10:47:32,09-24-2019 + parts = msg.rpartition("|")[2].partition("]")[0].partition("/") zoneID = parts[0][3:] - message = parts[1] + message = parts[2] sia = SIACodes(message[0:2]) _LOGGER.debug( "Incoming parsed: " @@ -315,8 +318,7 @@ def _get_sensor_name(self, zone=0, sensor_type=None): if sensor_type: return ( self._name - + (" " + zone_name if zone_name else "") - + " " + + (" " + zone_name + " " if zone_name else " ") + sensor_type ) else: @@ -330,55 +332,73 @@ def _get_zone_name(self, zone: int): ) def process_line(self, line): - # _LOGGER.debug("Hub.process_line" + line.decode()) + _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._update_states(*self._parse_message(data.decode())) - return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" + if pos >= 0: + 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._update_states(*self._parse_message(data.decode())) + return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" + else: + raise KeyError( + "Can't find ID_STRING, is SIA encryption enabled? Line: " + line + ) class EncryptedHub(Hub): def __init__(self, hass, hub_config): self._key = hub_config[CONF_ENCRYPTION_KEY].encode("utf8") - iv = Random.new().read(AES.block_size) - _cipher = AES.new(self._key, AES.MODE_CBC, iv) - self.iv2 = None + # IV standards from https://manualzz.com/doc/11555754/sia-digital-communication-standard-%E2%80%93-internet-protocol-ev... + # page 12 specifies the decrytion IV to all zeros. + self._decrypter = AES.new( + self._key, AES.MODE_CBC, unhexlify("00000000000000000000000000000000") + ) + # self.iv2 = None + # encode_iv = Random.new().read(AES.block_size) + _encrypter = AES.new(self._key, AES.MODE_CBC, Random.new().read(AES.block_size)) self._ending = ( - hexlify(_cipher.encrypt("00000000000000|]".encode("utf8"))) + hexlify(_encrypter.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") - # ) - + _LOGGER.debug("EncryptedHub.manage_string decrypting: " + str(msg)) + data = self._decrypter.decrypt(unhexlify(msg)) + _LOGGER.debug("EncryptedHub.manage_string decrypted to: " + str(data)) data = data[data.index(b"|") :] resmsg = data.decode(encoding="UTF-8", errors="replace") + _LOGGER.debug("EncryptedHub.manage_string decoded to: " + resmsg) Hub._update_states(self, *Hub._parse_message(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 + def process_line(self, line: string): + _LOGGER.debug( + "EncryptedHub.process_line: " + + line.decode() + + ", finding string: " + + str(ID_STRING_ENCODED) ) + pos = line.find(ID_STRING_ENCODED) + # assert pos >= 0, "Can't find ID_STRING_ENCODED, is SIA encryption enabled?" + if pos >= 0: + seq = line[pos + len(ID_STRING_ENCODED) : pos + len(ID_STRING_ENCODED) + 4] + data = line[line.index(b"[") + 1 :] + _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode()) + self._manage_string(data.decode()) + return ( + '"*ACK"' + + (seq.decode()) + + "L0#" + + (self._accountId) + + "[" + + self._ending + ) + else: + raise KeyError( + "Can't find ID_STRING_ENCODED, is SIA encryption enabled? Line: " + line + ) class SIAAlarmControlPanel(RestoreEntity): @@ -629,7 +649,7 @@ class AlarmTCPHandler(socketserver.BaseRequestHandler): _received_data = "".encode() def handle_line(self, line): - # _LOGGER.debug("Income raw string: " + line.decode()) + _LOGGER.debug("Income raw string: " + line.decode()) accountId = line[line.index(b"#") + 1 : line.index(b"[")].decode() pos = line.find(b'"') @@ -643,6 +663,14 @@ def handle_line(self, line): if accountId not in hass_platform.data[DOMAIN]: raise Exception("Not supported account " + accountId) response = hass_platform.data[DOMAIN][accountId].process_line(line) + except KeyError: + _LOGGER.error( + "Can't find ID_STRING_ENCODED, is SIA encryption enabled? Line: " + line + ) + timestamp = datetime.fromtimestamp(time.time()).strftime( + "_%H:%M:%S,%m-%d-%Y" + ) + response = '"NAK"0000L0R0A0[]' + timestamp except Exception as e: _LOGGER.error(str(e)) timestamp = datetime.fromtimestamp(time.time()).strftime( From 4163d73b464964efd74b994fe38368de37f4d4d7 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 26 Sep 2019 10:17:23 +0200 Subject: [PATCH 15/63] first beta of major rewrite --- custom_components/sia/__init__.py | 592 +++++------------- .../sia/__pycache__/sia_codes.cpython-37.pyc | Bin 29212 -> 0 bytes custom_components/sia/alarm_control_panel.py | 148 ++++- custom_components/sia/binary_sensor.py | 114 +++- custom_components/sia/sensor.py | 64 +- .../sia/{sia_codes.py => sia_event.py} | 129 +++- 6 files changed, 567 insertions(+), 480 deletions(-) delete mode 100644 custom_components/sia/__pycache__/sia_codes.cpython-37.pyc rename custom_components/sia/{sia_codes.py => sia_event.py} (94%) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 38f7621..6bfc97c 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,3 +1,5 @@ +"""Module for SIA Hub.""" + import asyncio import base64 from binascii import hexlify, unhexlify @@ -59,7 +61,10 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from .sia_codes import SIACodes +from .sia_event import SIAEvent +from .alarm_control_panel import SIAAlarmControlPanel +from .binary_sensor import SIABinarySensor +from .sensor import SIASensor _LOGGER = logging.getLogger(__name__) @@ -113,42 +118,39 @@ extra=vol.ALLOW_EXTRA, ) -ID_STRING = '"SIA-DCS"'.encode() -ID_STRING_ENCODED = '"*SIA-DCS"'.encode() ID_R = "\r".encode() PING_INTERVAL_MARGIN = timedelta(seconds=30) -hass_platform = None +HASS_PLATFORM = None def setup(hass, config): - global hass_platform + """Implementation of setup from HA.""" + global HASS_PLATFORM socketserver.TCPServer.allow_reuse_address = True - hass_platform = hass + HASS_PLATFORM = hass - hass_platform.data[DOMAIN] = {} + HASS_PLATFORM.data[DOMAIN] = {} port = int(config[DOMAIN][CONF_PORT]) for hub_config in config[DOMAIN][CONF_HUBS]: - if CONF_ENCRYPTION_KEY 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) + hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) for component in ["binary_sensor", "alarm_control_panel", "sensor"]: discovery.load_platform(hass, component, DOMAIN, {}, config) server = socketserver.TCPServer(("", port), AlarmTCPHandler) - t = threading.Thread(target=server.serve_forever) - t.start() + server_thread = threading.Thread(target=server.serve_forever) + server_thread.start() return True class Hub: + """Class for SIA Hubs.""" sensor_types_classes = { DEVICE_CLASS_ALARM: "SIAAlarmControlPanel", @@ -187,91 +189,48 @@ class Hub: def __init__(self, hass, hub_config): self._name = hub_config[CONF_NAME] - self._accountId = hub_config[CONF_ACCOUNT] + self._account_id = hub_config[CONF_ACCOUNT] self._hass = hass self._states = {} self._zones = hub_config.get(CONF_ZONES) self._sensor_ids = [] self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) + self._encrypted = False + self._ending = "]" + self._key = hub_config.get(CONF_ENCRYPTION_KEY, None) + if self._key: + self._encrypted = True + self._key = self._key.encode("utf8") + # IV standards from https://manualzz.com/doc/11555754/sia-digital-communication-standard-%E2%80%93-internet-protocol-ev... + # page 12 specifies the decrytion IV to all zeros. + self._decrypter = AES.new( + self._key, AES.MODE_CBC, unhexlify("00000000000000000000000000000000") + ) + _encrypter = AES.new( + self._key, AES.MODE_CBC, Random.new().read(AES.block_size) + ) + self._ending = ( + hexlify(_encrypter.encrypt("00000000000000|]".encode("utf8"))) + .decode(encoding="UTF-8") + .upper() + ) # create the hub sensor self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) # add sensors for each zone as specified in the config. - for z in self._zones: - for s in z.get(CONF_SENSORS): - self._upsert_sensor(z.get(CONF_ZONE), s) - - def _update_states(self, sia, zoneID, message): - """ Updates the sensors.""" - # find the reactions for that code (if any) - reaction = self.reactions.get(sia.code) - if reaction: - # get the entity_id (or create it) - sensor_id = self._upsert_sensor(zoneID, reaction["type"]) - # find out which action to take, update attribute, new state or eval for new state - attr = reaction.get("attr") - new_state = reaction.get("new_state") - new_state_eval = reaction.get("new_state_eval") - # do the work (can be more than 1) - if new_state or new_state_eval: - _LOGGER.debug( - "Will set state for entity: " - + sensor_id - + " to state: " - + (new_state if new_state else new_state_eval) - ) - self._states[sensor_id].state = ( - new_state if new_state else eval(new_state_eval) - ) - if attr: - _LOGGER.debug("Will set attribute entity: " + sensor_id) - self._states[sensor_id].add_attribute( - { - "Last message": utcnow().isoformat() - + ": SIA: " - + str(sia) - + ", Message: " - + message - } - ) - else: - _LOGGER.warning( - "Unhandled event type: " + str(sia) + ", Message: " + message - ) - # whenever a message comes in, the connection is good, so reset the availability clock for all devices. - for s in self._sensor_ids: - self._states[s].assume_available() - - def _parse_message(self, msg): - """ Parses the message and finds the SIA.""" - _LOGGER.debug("Message to parse: " + msg) - # example1: |#XXXXX|Nri1/NL501]_10:47:31,09-24-2019 - # example2: |Nri1/OP501]_10:47:32,09-24-2019 - parts = msg.rpartition("|")[2].partition("]")[0].partition("/") - zoneID = parts[0][3:] - message = parts[2] - sia = SIACodes(message[0:2]) - _LOGGER.debug( - "Incoming parsed: " - + msg - + " to sia: " - + str(sia) - + " for zone: " - + zoneID - + " with message: " - + message - ) - return sia, zoneID, message + for zone in self._zones: + for sensor in zone.get(CONF_SENSORS): + self._upsert_sensor(zone.get(CONF_ZONE), sensor) def _upsert_sensor(self, zone, sensor_type): """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ sensor_id = self._get_id(zone, sensor_type) if not (sensor_id in self._sensor_ids): zone_found = False - for z in self._zones: + for existing_zone in self._zones: # if the zone exists then a sensor is missing, # so, get the zone and add the missing sensor - if z[CONF_ZONE] == zone: - z[CONF_SENSORS].append(sensor_type) + if existing_zone[CONF_ZONE] == zone: + existing_zone[CONF_SENSORS].append(sensor_type) zone_found = True break if not zone_found: @@ -281,7 +240,7 @@ def _upsert_sensor(self, zone, sensor_type): # add the new sensor sensor_name = self._get_sensor_name(zone, sensor_type) constructor = self.sensor_types_classes.get(sensor_type) - if constructor: + if constructor and sensor_name: new_sensor = eval(constructor)( sensor_id, sensor_name, @@ -292,7 +251,9 @@ def _upsert_sensor(self, zone, sensor_type): ) self._states[sensor_id] = new_sensor else: - _LOGGER.warning("Unknown device type: " + sensor_type) + _LOGGER.warning( + "Hub: Upsert Sensor: Unknown device type: %s", sensor_type + ) self._sensor_ids.append(sensor_id) return sensor_id @@ -305,7 +266,7 @@ def _get_id(self, zone=0, sensor_type=None): return self._name + "_" + str(zone) + "_" + sensor_type else: _LOGGER.error( - "Not allowed to create an entity_id without type, unless zone == 0." + "Hub: Get ID: Not allowed to create an entity_id without type, unless zone == 0." ) def _get_sensor_name(self, zone=0, sensor_type=None): @@ -323,374 +284,134 @@ def _get_sensor_name(self, zone=0, sensor_type=None): ) else: _LOGGER.error( - "Not allowed to create an entity_id without type, unless zone == 0." + "Hub: Get Sensor Name: Not allowed to create an entity_id without type, unless zone == 0." ) + return None def _get_zone_name(self, zone: int): return next( (z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone), None ) - def process_line(self, line): - _LOGGER.debug("Hub.process_line" + line.decode()) - pos = line.find(ID_STRING) - if pos >= 0: - 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._update_states(*self._parse_message(data.decode())) - return '"ACK"' + (seq.decode()) + "L0#" + (self._accountId) + "[]" - else: - raise KeyError( - "Can't find ID_STRING, is SIA encryption enabled? Line: " + line - ) - - -class EncryptedHub(Hub): - def __init__(self, hass, hub_config): - self._key = hub_config[CONF_ENCRYPTION_KEY].encode("utf8") - # IV standards from https://manualzz.com/doc/11555754/sia-digital-communication-standard-%E2%80%93-internet-protocol-ev... - # page 12 specifies the decrytion IV to all zeros. - self._decrypter = AES.new( - self._key, AES.MODE_CBC, unhexlify("00000000000000000000000000000000") - ) - # self.iv2 = None - # encode_iv = Random.new().read(AES.block_size) - _encrypter = AES.new(self._key, AES.MODE_CBC, Random.new().read(AES.block_size)) - self._ending = ( - hexlify(_encrypter.encrypt("00000000000000|]".encode("utf8"))) - .decode(encoding="UTF-8") - .upper() - ) - Hub.__init__(self, hass, hub_config) - - def _manage_string(self, msg): - _LOGGER.debug("EncryptedHub.manage_string decrypting: " + str(msg)) - data = self._decrypter.decrypt(unhexlify(msg)) - _LOGGER.debug("EncryptedHub.manage_string decrypted to: " + str(data)) - data = data[data.index(b"|") :] - resmsg = data.decode(encoding="UTF-8", errors="replace") - _LOGGER.debug("EncryptedHub.manage_string decoded to: " + resmsg) - Hub._update_states(self, *Hub._parse_message(self, resmsg)) - - def process_line(self, line: string): - _LOGGER.debug( - "EncryptedHub.process_line: " - + line.decode() - + ", finding string: " - + str(ID_STRING_ENCODED) - ) - pos = line.find(ID_STRING_ENCODED) - # assert pos >= 0, "Can't find ID_STRING_ENCODED, is SIA encryption enabled?" - if pos >= 0: - seq = line[pos + len(ID_STRING_ENCODED) : pos + len(ID_STRING_ENCODED) + 4] - data = line[line.index(b"[") + 1 :] - _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode()) - self._manage_string(data.decode()) - return ( - '"*ACK"' - + (seq.decode()) - + "L0#" - + (self._accountId) - + "[" - + self._ending - ) + def _update_states(self, event): + """ Updates the sensors.""" + # find the reactions for that code (if any) + reaction = self.reactions.get(event.code) + if reaction: + # get the entity_id (or create it) + sensor_id = self._upsert_sensor(event.zone, reaction["type"]) + # find out which action to take, update attribute, new state or eval for new state + attr = reaction.get("attr") + new_state = reaction.get("new_state") + new_state_eval = reaction.get("new_state_eval") + # do the work (can be more than 1) + if new_state or new_state_eval: + _LOGGER.debug( + "Hub: Update States: Will set state for entity: " + + sensor_id + + " to state: " + + (new_state if new_state else new_state_eval) + ) + self._states[sensor_id].state = ( + new_state if new_state else eval(new_state_eval) + ) + if attr: + _LOGGER.debug( + "Hub: Update States: Will set attribute entity: %s", sensor_id + ) + self._states[sensor_id].add_attribute( + { + "Last message": utcnow().isoformat() + + ": SIA: " + + event.sia_string + + ", Message: " + + event.message + } + ) else: - raise KeyError( - "Can't find ID_STRING_ENCODED, is SIA encryption enabled? Line: " + line + _LOGGER.warning( + "Hub: Update States: Unhandled event type: " + + event.sia_string + + ", Message: " + + event.message ) + # whenever a message comes in, the connection is good, so reset the availability clock for all devices. + for sensor in self._sensor_ids: + self._states[sensor].assume_available() - -class SIAAlarmControlPanel(RestoreEntity): - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): - self._should_poll = False - self._entity_id = generate_entity_id( - entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass + def process_event(self, event): + """Process the Event that comes from the TCP handler.""" + try: + _LOGGER.debug("Hub: Process event: %s", event) + if self._encrypted: + self._decrypt_string(event) + _LOGGER.debug("Hub: Process event, after decrypt: %s", event) + self._update_states(event) + except Exception as exc: + _LOGGER.error("Hub: Process Event: %s gave error %s", event, str(exc)) + + # Even if decrypting or something else gives an error, create the acknowledgement message. + return '"ACK"{}L0#{}[{}'.format( + event.sequence.decode(), self._account_id, self._ending ) - self._name = name - self.hass = hass - self._ping_interval = ping_interval - self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} - 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: - if state.state == STATE_ALARM_ARMED_AWAY: - self._state = STATE_ALARM_ARMED_AWAY - elif state.state == STATE_ALARM_ARMED_NIGHT: - self._state = STATE_ALARM_ARMED_NIGHT - elif state.state == STATE_ALARM_TRIGGERED: - self._state = STATE_ALARM_TRIGGERED - elif state.state == STATE_ALARM_DISARMED: - self._state = STATE_ALARM_DISARMED - elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: - self._state = STATE_ALARM_ARMED_CUSTOM_BYPASS - else: - self._state = None - else: - self._state = STATE_ALARM_DISARMED # assume disarmed - self._async_track_unavailable() - - @property - def entity_id(self): - return self._entity_id - - @property - def name(self): - return self._name - @property - def ping_interval(self): - return str(self._ping_interval) - - @property - def state(self): - return self._state - - @property - def unique_id(self) -> str: - return self._name - - @property - def available(self): - return self._is_available - - def alarm_disarm(self, code=None): - _LOGGER.debug("Not implemented.") - - def alarm_arm_home(self, code=None): - _LOGGER.debug("Not implemented.") - - def alarm_arm_away(self, code=None): - _LOGGER.debug("Not implemented.") - - def alarm_arm_night(self, code=None): - _LOGGER.debug("Not implemented.") - - def alarm_trigger(self, code=None): - _LOGGER.debug("Not implemented.") - - def alarm_arm_custom_bypass(self, code=None): - _LOGGER.debug("Not implemented.") - - @property - def device_state_attributes(self): - return self._attr - - @state.setter - def 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() + self._ping_interval + PING_INTERVAL_MARGIN, - ) - if not self._is_available: - self._is_available = True - return True - return False - - @callback - def _async_set_unavailable(self, now): - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() - - -class SIABinarySensor(RestoreEntity): - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): - self._device_class = device_class - self._should_poll = False - self._ping_interval = ping_interval - self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} - self._entity_id = generate_entity_id( - entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass - ) - self._name = name - self.hass = hass - self._is_available = True - self._remove_unavailability_tracker = None - - @property - def entity_id(self): - return self._entity_id - - 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 ping_interval(self): - return str(self._ping_interval) - - @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): - return self._attr - - @property - def device_class(self): - return self._device_class - - @property - def is_on(self): - return self._state - - @state.setter - def 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() + self._ping_interval + PING_INTERVAL_MARGIN, + def _decrypt_string(self, event): + """Decrypt the encrypted event content and parse it.""" + _LOGGER.debug("Hub: Decrypt String: Original: %s", str(event.content)) + resmsg = self._decrypter.decrypt(unhexlify(event.content)).decode( + encoding="UTF-8", errors="replace" ) - 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 SIASensor(Entity): - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): - self._should_poll = False - self._device_class = device_class - self._entity_id = generate_entity_id( - entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass - ) - self._state = utcnow() - self._attr = {CONF_PING_INTERVAL: str(ping_interval), CONF_ZONE: zone} - self._name = name - self.hass = hass - - @property - def entity_id(self): - return self._entity_id - - @property - def name(self): - return self._name - - @property - def state(self): - return self._state.isoformat() - - @property - def device_state_attributes(self): - return self._attr - - def add_attribute(self, attr): - self._attr.update(attr) - - @property - def device_class(self): - return self._device_class - - @state.setter - def state(self, state): - self._state = state - - def assume_available(self): - pass - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return "mdi:alarm-light-outline" + _LOGGER.debug("Hub: Decrypt String: Decrypted: %s", resmsg) + event.parse_decrypted(resmsg) class AlarmTCPHandler(socketserver.BaseRequestHandler): + """Class for the TCP Handler.""" + _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)) + """Method called for each line that comes in.""" + _LOGGER.debug("Income raw string: %s", line) try: - if msgcrc != codecrc: + event = SIAEvent(line) + _LOGGER.debug("TCP: Handle Line: event: %s", event) + if not event.valid_message: + _LOGGER.error( + "TCP: Handle Line: CRC mismatch, received: %s, calculated: %s", + event.msg_crc, + event.calc_crc, + ) 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 KeyError: - _LOGGER.error( - "Can't find ID_STRING_ENCODED, is SIA encryption enabled? Line: " + line - ) - timestamp = datetime.fromtimestamp(time.time()).strftime( - "_%H:%M:%S,%m-%d-%Y" - ) - response = '"NAK"0000L0R0A0[]' + timestamp - except Exception as e: - _LOGGER.error(str(e)) + if event.account not in HASS_PLATFORM.data[DOMAIN]: + _LOGGER.error( + "TCP: Handle Line: Not supported account %s", event.account + ) + raise Exception( + "TCP: Handle Line: Not supported account {}".format(event.account) + ) + response = HASS_PLATFORM.data[DOMAIN][event.account].process_event(event) + except Exception as exc: + _LOGGER.error("TCP: Handle Line: %s", str(exc)) 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" - + response = "\n{}{}{}\r".format( + AlarmTCPHandler.crc_calc(response), header, response + ) byte_response = str.encode(response) self.request.sendall(byte_response) def handle(self): + """Method called for handling.""" line = b"" try: while True: raw = self.request.recv(1024) - if (not raw) or (len(raw) == 0): + if not raw: return raw = bytearray(raw) while True: @@ -701,35 +422,24 @@ def handle(self): else: break - self.handle_line(line) - except Exception as e: - _LOGGER.error(str(e) + " last line: " + line.decode()) + self.handle_line(line.decode()) + except Exception as exc: + _LOGGER.error( + "TCP: Handle: last line %s gave error: %s", line.decode(), str(exc) + ) return @staticmethod - def CRCCalc(msg): - CRC = 0 - for letter in msg: - temp = letter - for _ in range(0, 8): - 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 + def crc_calc(msg): + """Calculate the CRC of the response.""" + new_crc = 0 for letter in msg: temp = ord(letter) for _ in range(0, 8): - temp ^= CRC & 1 - CRC >>= 1 + temp ^= new_crc & 1 + new_crc >>= 1 if (temp & 1) != 0: - CRC ^= 0xA001 + new_crc ^= 0xA001 temp >>= 1 - return ("%x" % CRC).upper().zfill(4) + return ("%x" % new_crc).upper().zfill(4) diff --git a/custom_components/sia/__pycache__/sia_codes.cpython-37.pyc b/custom_components/sia/__pycache__/sia_codes.cpython-37.pyc deleted file mode 100644 index d68bfd714987f36c05a2e931bf81c1b5ce1e7601..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29212 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 diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index ba16c5f..44a561b 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,13 +1,31 @@ +"""Module for SIA Alarm Control Panels.""" + import logging -import json -from . import SIAAlarmControlPanel +from homeassistant.core import callback +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.dt import utcnow + +from . import ( + ALARM_FORMAT, + CONF_PING_INTERVAL, + CONF_ZONE, + PING_INTERVAL_MARGIN, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): + """Implementation of platform setup from HA.""" devices = [] for account in hass.data[DOMAIN]: for device in hass.data[DOMAIN][account]._states: @@ -16,3 +34,129 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices.append(new_device) add_entities(devices) + + +class SIAAlarmControlPanel(RestoreEntity): + """Class for SIA Alarm Control Panels.""" + + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + self._should_poll = False + self._entity_id = generate_entity_id( + entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass + ) + self._name = name + self.hass = hass + self._ping_interval = ping_interval + self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} + self._is_available = True + self._remove_unavailability_tracker = None + self._state = None + + async def async_added_to_hass(self): + """Once the panel is added, see if it was there before and pull in that state.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state is not None: + if state.state == STATE_ALARM_ARMED_AWAY: + self._state = STATE_ALARM_ARMED_AWAY + elif state.state == STATE_ALARM_ARMED_NIGHT: + self._state = STATE_ALARM_ARMED_NIGHT + elif state.state == STATE_ALARM_TRIGGERED: + self._state = STATE_ALARM_TRIGGERED + elif state.state == STATE_ALARM_DISARMED: + self._state = STATE_ALARM_DISARMED + elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + self._state = STATE_ALARM_ARMED_CUSTOM_BYPASS + else: + self._state = None + else: + self._state = STATE_ALARM_DISARMED # assume disarmed + self._async_track_unavailable() + + @property + def entity_id(self): + """Get entity_id.""" + return self._entity_id + + @property + def name(self): + """Get Name.""" + return self._name + + @property + def ping_interval(self): + """Get ping_interval.""" + return str(self._ping_interval) + + @property + def state(self): + """Get state.""" + return self._state + + @property + def unique_id(self) -> str: + """Get unique_id.""" + return self._name + + @property + def available(self): + """Get availability.""" + return self._is_available + + def alarm_disarm(self, code=None): + """Method for disarming, not implemented.""" + _LOGGER.debug("Not implemented.") + + def alarm_arm_home(self, code=None): + """Method for arming, not implemented.""" + _LOGGER.debug("Not implemented.") + + def alarm_arm_away(self, code=None): + """Method for arming, not implemented.""" + _LOGGER.debug("Not implemented.") + + def alarm_arm_night(self, code=None): + """Method for arming, not implemented.""" + _LOGGER.debug("Not implemented.") + + def alarm_trigger(self, code=None): + """Method for triggering, not implemented.""" + _LOGGER.debug("Not implemented.") + + def alarm_arm_custom_bypass(self, code=None): + """Method for arming, not implemented.""" + _LOGGER.debug("Not implemented.") + + @property + def device_state_attributes(self): + return self._attr + + @state.setter + def state(self, state): + self._state = state + self.async_schedule_update_ha_state() + + def assume_available(self): + """Reset unavalability tracker.""" + self._async_track_unavailable() + + @callback + def _async_track_unavailable(self): + """Callback method for resetting unavailability.""" + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + self._remove_unavailability_tracker = async_track_point_in_utc_time( + self.hass, + self._async_set_unavailable, + utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, + ) + if not self._is_available: + self._is_available = True + 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() diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 418722b..e9cb774 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,13 +1,28 @@ +"""Module for SIA Binary Sensors.""" + import logging -import json -from . import SIABinarySensor +from homeassistant.core import callback +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.dt import utcnow + +from . import ( + CONF_PING_INTERVAL, + PING_INTERVAL_MARGIN, + CONF_ZONE, + BINARY_SENSOR_FORMAT, + STATE_ON, + STATE_OFF, +) DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): + """Implementation of platform setup from HA.""" devices = [] for account in hass.data[DOMAIN]: for device in hass.data[DOMAIN][account]._states: @@ -15,3 +30,98 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if isinstance(new_device, SIABinarySensor): devices.append(new_device) add_entities(devices) + + +class SIABinarySensor(RestoreEntity): + """Class for SIA Binary Sensors.""" + + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + self._device_class = device_class + self._should_poll = False + self._ping_interval = ping_interval + self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} + self._entity_id = generate_entity_id( + entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass + ) + self._name = name + self.hass = hass + self._is_available = True + self._remove_unavailability_tracker = None + self._state = None + + @property + def entity_id(self): + """Get entity_id.""" + return self._entity_id + + 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 ping_interval(self): + """Get ping_interval.""" + return str(self._ping_interval) + + @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): + return self._attr + + @property + def device_class(self): + return self._device_class + + @property + def is_on(self): + """Get whether the sensor is set to ON.""" + return self._state + + @state.setter + def state(self, state): + self._state = state + self.async_schedule_update_ha_state() + + def assume_available(self): + """Reset unavalability tracker.""" + 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() + self._ping_interval + PING_INTERVAL_MARGIN, + ) + if not self._is_available: + self._is_available = True + return True + return False + + @callback + def _async_set_unavailable(self, now): + self._remove_unavailability_tracker = None + self._is_available = False + self.async_schedule_update_ha_state() diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 4c239d1..8b02ebd 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,13 +1,20 @@ +"""Module for SIA Sensors.""" + import logging -import json -from . import SIASensor +from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT + +from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.util.dt import utcnow + +from . import CONF_ZONE, CONF_PING_INTERVAL DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): + """Implementation of platform setup from HA.""" devices = [] for account in hass.data[DOMAIN]: for device in hass.data[DOMAIN][account]._states: @@ -15,3 +22,56 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if isinstance(new_device, SIASensor): devices.append(new_device) add_entities(devices) + + +class SIASensor(Entity): + """Class for SIA Sensors.""" + + def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + self._should_poll = False + self._device_class = device_class + self._entity_id = generate_entity_id( + entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass + ) + self._state = utcnow() + self._attr = {CONF_PING_INTERVAL: str(ping_interval), CONF_ZONE: zone} + self._name = name + self.hass = hass + + @property + def entity_id(self): + """Get entity_id.""" + return self._entity_id + + @property + def name(self): + return self._name + + @property + def state(self): + return self._state.isoformat() + + @property + def device_state_attributes(self): + return self._attr + + def add_attribute(self, attr): + """Update attributes.""" + self._attr.update(attr) + + @property + def device_class(self): + return self._device_class + + @state.setter + def state(self, state): + self._state = state + + def assume_available(self): + """Stub method, to keep signature the same between all SIA components.""" + pass + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:alarm-light-outline" diff --git a/custom_components/sia/sia_codes.py b/custom_components/sia/sia_event.py similarity index 94% rename from custom_components/sia/sia_codes.py rename to custom_components/sia/sia_event.py index 93f10fb..b13e39f 100644 --- a/custom_components/sia/sia_codes.py +++ b/custom_components/sia/sia_event.py @@ -1,4 +1,99 @@ -class SIACodes: +"""Module for SIA Events.""" + +import re + + +class SIAEvent: + """Class for SIA Events.""" + + def __init__(self, line): + # Example events: 98100078"*SIA-DCS"5994L0#acct[5AB718E008C616BF16F6468033A11326B0F7546CAB230910BCA10E4DEBA42283C436E4F8EFF50931070DDE36D5BB5F0C + # Example events: 66100078"*SIA-DCS"6001L0#acct[6F7457178C6F0EAD99109E1DC5B75B26EDFBE1AA17361CD48E0B0E340081035F16AD2A25CD3D7F04105EC1EA65BF6341 + # Example events: 2E680078"*SIA-DCS"6002L0#acct[FDDCDFEC950EDC3F7C438B75CD57B9C91E1CA632806882769097C60292F86BD13D43D3BA7E2F529560DC7B51E6581E58 + # Example events: 2E680078"SIA-DCS"6002L0#acct[|Nri1/CL501]_14:12:04,09-25-2019 + # Example events: 5BFD0078"*SIA-DCS"6003L0#acct[03D1EA959BCC9E2DA91CACA7AFF472F1CB234708977C4E1E3B86A8ABD45AD9F95F0EFFFF817EE5349572972325BFC856 + # Example events: 5BFD0078"SIA-DCS"6003L0#acct[|Nri1/OP501]_14:12:04,09-25-2019 + + regex = r"(.{4})0[A-F0-9]{3}(\"(SIA-DCS|\*SIA-DCS)\"([0-9]{4})(R[A-F0-9]{1,6})?(L[A-F0-9]{1,6})#([A-F0-9]{3,16})\[([A-F0-9]*)?(.*Nri(\d*)/([a-zA-z]{2})(.*)]_([0-9:,-]*))?)" + matches = re.findall(regex, line) + + # check if there is at lease one match + if not matches: + raise ValueError("SIAEvent: Constructor: no matches found.") + self.msg_crc, self.full_message, self.message_type, self.sequence, self.receiver, self.prefix, self.account, self.content, self.zone, self.code, self.message, self.timestamp = matches[ + 0 + ] + self.calc_crc = SIAEvent.crc_calc(self.full_message) + if self.code: + self._add_sia() + + def _add_sia(self): + """Finds the sia codes based on self.code.""" + full = self.all_codes.get(self.code, None) + if full: + self.type = full.get("type") + self.description = full.get("description") + self.concerns = full.get("concerns") + else: + raise LookupError("Code not found: {}".format(self.code)) + + def parse_decrypted(self, new_data): + """When the content was decrypted, update the fields contained within.""" + regex = r".*Nri(\d*)/([a-zA-z]{2})(.*)]_([0-9:,-]*)" + matches = re.findall(regex, new_data) + if not matches: + raise ValueError("SIAEvent: Parse Decrypted: no matches found.") + self.zone, self.code, self.message, self.timestamp = matches[0] + if self.code: + self._add_sia() + + @staticmethod + def crc_calc(msg): + """Calculate the CRC of the events.""" + crc = 0 + for letter in msg: + temp = letter + for _ in range(0, 8): + temp ^= crc & 1 + crc >>= 1 + if (temp & 1) != 0: + crc ^= 0xA001 + temp >>= 1 + return str.encode(("%x" % crc).upper().zfill(4)) + + @property + def valid_message(self): + """Check the validity of the message by comparing the sent CRC with the calculated CRC.""" + return self.msg_crc == self.calc_crc + + @property + def sia_string(self): + """Create a string with the SIA codes and some other fields.""" + return "Code: {}, Type: {}, Description: {}, Concerns: {}".format( + self.code, self.type, self.description, self.concerns + ) + + def __str__(self): + return "CRC: {}, Calc CRC: {}, Full Message: {}, Message type: {}, Sequence: {}, Receiver: {}, Prefix: {}, Account: {}, Content: {}, Zone: {}, Code: {}, Message: {}, Timestamp: {}, Code: {}, Type: {}, Description: {}, Concerns: {}".format( + self.msg_crc, + self.calc_crc, + self.full_message, + self.message_type, + self.sequence, + self.receiver, + self.prefix, + self.account, + self.content, + self.zone, + self.code, + self.message, + self.timestamp, + self.code, + self.type, + self.description, + self.concerns, + ) + all_codes = { "AA": { "code": "AA", @@ -1831,35 +1926,3 @@ class SIACodes: "concerns": "Zone or point", }, } - - def __init__(self, value): - """Initiates a code object with just a code""" - full = self.all_codes.get(value, None) - if full: - self._code = full.get("code") - self._type = full.get("type") - self._description = full.get("description") - self._concerns = full.get("concerns") - else: - raise LookupError - - @property - def code(self): - return self._code - - @property - def type(self): - return self._type - - @property - def description(self): - return self._description - - @property - def concerns(self): - return self._concerns - - def __str__(self): - return "Code: {}, Type: {}, Description: {}, Concerns: {}".format( - self._code, self._type, self._description, self._concerns - ) From 1a5f27776f5c9f5817895d5f4f450091f180d953 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 26 Sep 2019 11:23:40 +0200 Subject: [PATCH 16/63] update to beta version --- custom_components/sia/__init__.py | 26 ++++++++++++++------------ custom_components/sia/sia_event.py | 14 ++++++++++---- info.md | 4 ++-- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 6bfc97c..3800868 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -61,11 +61,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from .sia_event import SIAEvent -from .alarm_control_panel import SIAAlarmControlPanel -from .binary_sensor import SIABinarySensor -from .sensor import SIASensor - _LOGGER = logging.getLogger(__name__) DOMAIN = "sia" @@ -124,6 +119,12 @@ HASS_PLATFORM = None +#final import here, because they rely on variables above +from .sia_event import SIAEvent +from .alarm_control_panel import SIAAlarmControlPanel +from .binary_sensor import SIABinarySensor +from .sensor import SIASensor + def setup(hass, config): """Implementation of setup from HA.""" @@ -197,8 +198,9 @@ def __init__(self, hass, hub_config): self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) self._encrypted = False self._ending = "]" - self._key = hub_config.get(CONF_ENCRYPTION_KEY, None) + self._key = hub_config.get(CONF_ENCRYPTION_KEY) if self._key: + _LOGGER.debug("Hub: init: encryption is enabled.") self._encrypted = True self._key = self._key.encode("utf8") # IV standards from https://manualzz.com/doc/11555754/sia-digital-communication-standard-%E2%80%93-internet-protocol-ev... @@ -352,13 +354,13 @@ def process_event(self, event): # Even if decrypting or something else gives an error, create the acknowledgement message. return '"ACK"{}L0#{}[{}'.format( - event.sequence.decode(), self._account_id, self._ending + event.sequence, self._account_id, self._ending ) def _decrypt_string(self, event): """Decrypt the encrypted event content and parse it.""" - _LOGGER.debug("Hub: Decrypt String: Original: %s", str(event.content)) - resmsg = self._decrypter.decrypt(unhexlify(event.content)).decode( + _LOGGER.debug("Hub: Decrypt String: Original: %s", str(event.encrypted_content)) + resmsg = self._decrypter.decrypt(unhexlify(event.encrypted_content)).decode( encoding="UTF-8", errors="replace" ) _LOGGER.debug("Hub: Decrypt String: Decrypted: %s", resmsg) @@ -372,10 +374,10 @@ class AlarmTCPHandler(socketserver.BaseRequestHandler): def handle_line(self, line): """Method called for each line that comes in.""" - _LOGGER.debug("Income raw string: %s", line) + _LOGGER.debug("TCP: Handle Line: Income raw string: %s", line) try: event = SIAEvent(line) - _LOGGER.debug("TCP: Handle Line: event: %s", event) + _LOGGER.debug("TCP: Handle Line: event: %s", str(event)) if not event.valid_message: _LOGGER.error( "TCP: Handle Line: CRC mismatch, received: %s, calculated: %s", @@ -392,7 +394,7 @@ def handle_line(self, line): ) response = HASS_PLATFORM.data[DOMAIN][event.account].process_event(event) except Exception as exc: - _LOGGER.error("TCP: Handle Line: %s", str(exc)) + _LOGGER.error("TCP: Handle Line: error: %s", str(exc)) timestamp = datetime.fromtimestamp(time.time()).strftime( "_%H:%M:%S,%m-%d-%Y" ) diff --git a/custom_components/sia/sia_event.py b/custom_components/sia/sia_event.py index b13e39f..d4b42d2 100644 --- a/custom_components/sia/sia_event.py +++ b/custom_components/sia/sia_event.py @@ -2,6 +2,7 @@ import re +from . import _LOGGER class SIAEvent: """Class for SIA Events.""" @@ -20,9 +21,13 @@ def __init__(self, line): # check if there is at lease one match if not matches: raise ValueError("SIAEvent: Constructor: no matches found.") - self.msg_crc, self.full_message, self.message_type, self.sequence, self.receiver, self.prefix, self.account, self.content, self.zone, self.code, self.message, self.timestamp = matches[ + # _LOGGER.debug(matches) + self.msg_crc, self.full_message, self.message_type, self.sequence, self.receiver, self.prefix, self.account, self.encrypted_content, self.content, self.zone, self.code, self.message, self.timestamp = matches[ 0 ] + self.type = "" + self.description = "" + self.concerns = "" self.calc_crc = SIAEvent.crc_calc(self.full_message) if self.code: self._add_sia() @@ -51,7 +56,7 @@ def parse_decrypted(self, new_data): def crc_calc(msg): """Calculate the CRC of the events.""" crc = 0 - for letter in msg: + for letter in str.encode(msg): temp = letter for _ in range(0, 8): temp ^= crc & 1 @@ -59,7 +64,7 @@ def crc_calc(msg): if (temp & 1) != 0: crc ^= 0xA001 temp >>= 1 - return str.encode(("%x" % crc).upper().zfill(4)) + return ("%x" % crc).upper().zfill(4) @property def valid_message(self): @@ -74,7 +79,7 @@ def sia_string(self): ) def __str__(self): - return "CRC: {}, Calc CRC: {}, Full Message: {}, Message type: {}, Sequence: {}, Receiver: {}, Prefix: {}, Account: {}, Content: {}, Zone: {}, Code: {}, Message: {}, Timestamp: {}, Code: {}, Type: {}, Description: {}, Concerns: {}".format( + return "CRC: {}, Calc CRC: {}, Full Message: {}, Message type: {}, Sequence: {}, Receiver: {}, Prefix: {}, Account: {}, Encrypted Content: {}, Content: {}, Zone: {}, Code: {}, Message: {}, Timestamp: {}, Code: {}, Type: {}, Description: {}, Concerns: {}".format( self.msg_crc, self.calc_crc, self.full_message, @@ -83,6 +88,7 @@ def __str__(self): self.receiver, self.prefix, self.account, + self.encrypted_content, self.content, self.zone, self.code, diff --git a/info.md b/info.md index aa64f00..d2f4bc6 100644 --- a/info.md +++ b/info.md @@ -1,4 +1,4 @@ -[![hacs][hacsbadge]](hacs) +[![hacs][hacs_badge]](hacs) _Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ @@ -88,4 +88,4 @@ ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and [sia]: https://github.com/eavanvalkenburg/sia-ha [ch_sia]: https://github.com/Cheaterdev/sia-ha [hacs]: https://github.com/custom-components/hacs -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file +[hacs_badge]: https://img.shields.io/badge/HACS-Default-orange.svg) \ No newline at end of file From 1ad5616609e6e2f9bdefb039916ae94d98b89c97 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Fri, 4 Oct 2019 14:53:27 +0200 Subject: [PATCH 17/63] rewrite complete, restoreentity not fully working --- custom_components/sia/__init__.py | 35 +++++++++------ custom_components/sia/alarm_control_panel.py | 46 ++++++++++++-------- custom_components/sia/binary_sensor.py | 22 +++++----- custom_components/sia/sensor.py | 17 ++++---- custom_components/sia/sia_event.py | 3 +- 5 files changed, 73 insertions(+), 50 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 3800868..85533cc 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -119,7 +119,7 @@ HASS_PLATFORM = None -#final import here, because they rely on variables above +# final import here, because they rely on variables above from .sia_event import SIAEvent from .alarm_control_panel import SIAAlarmControlPanel from .binary_sensor import SIABinarySensor @@ -142,6 +142,10 @@ def setup(hass, config): for component in ["binary_sensor", "alarm_control_panel", "sensor"]: discovery.load_platform(hass, component, DOMAIN, {}, config) + for hub in HASS_PLATFORM.data[DOMAIN].values(): + for sensor in hub._states.values(): + sensor.async_schedule_update_ha_state() + server = socketserver.TCPServer(("", port), AlarmTCPHandler) server_thread = threading.Thread(target=server.serve_forever) @@ -193,8 +197,7 @@ def __init__(self, hass, hub_config): self._account_id = hub_config[CONF_ACCOUNT] self._hass = hass self._states = {} - self._zones = hub_config.get(CONF_ZONES) - self._sensor_ids = [] + self._zones = [dict(z) for z in hub_config.get(CONF_ZONES)] self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) self._encrypted = False self._ending = "]" @@ -216,17 +219,17 @@ def __init__(self, hass, hub_config): .decode(encoding="UTF-8") .upper() ) - # create the hub sensor - self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) # add sensors for each zone as specified in the config. for zone in self._zones: for sensor in zone.get(CONF_SENSORS): self._upsert_sensor(zone.get(CONF_ZONE), sensor) + # create the hub sensor + self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) def _upsert_sensor(self, zone, sensor_type): """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ sensor_id = self._get_id(zone, sensor_type) - if not (sensor_id in self._sensor_ids): + if not (sensor_id in self._states.keys()): zone_found = False for existing_zone in self._zones: # if the zone exists then a sensor is missing, @@ -242,6 +245,14 @@ def _upsert_sensor(self, zone, sensor_type): # add the new sensor sensor_name = self._get_sensor_name(zone, sensor_type) constructor = self.sensor_types_classes.get(sensor_type) + _LOGGER.debug( + "Hub: upsert_sensor: Updating sensor: " + + sensor_name + + ", id: " + + sensor_id + + ", with constructor: " + + constructor + ) if constructor and sensor_name: new_sensor = eval(constructor)( sensor_id, @@ -251,12 +262,12 @@ def _upsert_sensor(self, zone, sensor_type): self._ping_interval, self._hass, ) + _LOGGER.debug("Hub: upsert_sensor: created sensor: " + str(new_sensor)) self._states[sensor_id] = new_sensor else: _LOGGER.warning( "Hub: Upsert Sensor: Unknown device type: %s", sensor_type ) - self._sensor_ids.append(sensor_id) return sensor_id def _get_id(self, zone=0, sensor_type=None): @@ -337,9 +348,9 @@ def _update_states(self, event): + ", Message: " + event.message ) - # whenever a message comes in, the connection is good, so reset the availability clock for all devices. - for sensor in self._sensor_ids: - self._states[sensor].assume_available() + # whenever a message comes in, the connection is good, so reset the availability timer for all devices. + for sensor in self._states.values(): + sensor.assume_available() def process_event(self, event): """Process the Event that comes from the TCP handler.""" @@ -353,9 +364,7 @@ def process_event(self, event): _LOGGER.error("Hub: Process Event: %s gave error %s", event, str(exc)) # Even if decrypting or something else gives an error, create the acknowledgement message. - return '"ACK"{}L0#{}[{}'.format( - event.sequence, self._account_id, self._ending - ) + return '"ACK"{}L0#{}[{}'.format(event.sequence, self._account_id, self._ending) def _decrypt_string(self, event): """Decrypt the encrypted event content and parse it.""" diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 44a561b..7293db8 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -6,6 +6,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.util.dt import utcnow from . import ( @@ -24,22 +25,26 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Implementation of platform setup from HA.""" - devices = [] - for account in hass.data[DOMAIN]: - for device in hass.data[DOMAIN][account]._states: - new_device = hass.data[DOMAIN][account]._states[device] - if isinstance(new_device, SIAAlarmControlPanel): - devices.append(new_device) + devices = [ + device + for hub in hass.data[DOMAIN].values() + for device in hub._states.values() + if isinstance(device, SIAAlarmControlPanel) + ] + _LOGGER.debug("SIAAlarmControlPanel: setup: devices: " + str(devices)) + async_add_entities(devices) - add_entities(devices) - -class SIAAlarmControlPanel(RestoreEntity): +class SIAAlarmControlPanel(AlarmControlPanel, RestoreEntity): """Class for SIA Alarm Control Panels.""" def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + _LOGGER.debug( + "SIAAlarmControlPanel: init: Initializing SIA Alarm Control Panel: " + + entity_id + ) self._should_poll = False self._entity_id = generate_entity_id( entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass @@ -50,28 +55,33 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} self._is_available = True self._remove_unavailability_tracker = None - self._state = None + self._state = STATE_ALARM_DISARMED async def async_added_to_hass(self): """Once the panel is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: + _LOGGER.debug("SIAAlarmControlPanel: init: old state: " + state.state) if state.state == STATE_ALARM_ARMED_AWAY: - self._state = STATE_ALARM_ARMED_AWAY + self.state = STATE_ALARM_ARMED_AWAY elif state.state == STATE_ALARM_ARMED_NIGHT: - self._state = STATE_ALARM_ARMED_NIGHT + self.state = STATE_ALARM_ARMED_NIGHT elif state.state == STATE_ALARM_TRIGGERED: - self._state = STATE_ALARM_TRIGGERED + self.state = STATE_ALARM_TRIGGERED elif state.state == STATE_ALARM_DISARMED: - self._state = STATE_ALARM_DISARMED + self.state = STATE_ALARM_DISARMED elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: - self._state = STATE_ALARM_ARMED_CUSTOM_BYPASS + self.state = STATE_ALARM_ARMED_CUSTOM_BYPASS else: - self._state = None + self.state = None else: - self._state = STATE_ALARM_DISARMED # assume disarmed + self.state = STATE_ALARM_DISARMED # assume disarmed + _LOGGER.debug("SIAAlarmControlPanel: added: state: " + str(state)) self._async_track_unavailable() + # async_dispatcher_connect( + # self._hass, DATA_UPDATED, self._schedule_immediate_update + # ) @property def entity_id(self): diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index e9cb774..29f591b 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -21,15 +21,16 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Implementation of platform setup from HA.""" - devices = [] - for account in hass.data[DOMAIN]: - for device in hass.data[DOMAIN][account]._states: - new_device = hass.data[DOMAIN][account]._states[device] - if isinstance(new_device, SIABinarySensor): - devices.append(new_device) - add_entities(devices) + devices = [ + device + for hub in hass.data[DOMAIN].values() + for device in hub._states.values() + if isinstance(device, SIABinarySensor) + ] + _LOGGER.debug("SIABinarySensor: setup: devices: " + str(devices)) + async_add_entities(devices) class SIABinarySensor(RestoreEntity): @@ -58,9 +59,10 @@ 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 + self.state = state.state == STATE_ON else: - self._state = None + self.state = None + _LOGGER.debug("SIABinarySensor: added: state: " + str(state)) self._async_track_unavailable() @property diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 8b02ebd..c2cbf1a 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -13,15 +13,16 @@ _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Implementation of platform setup from HA.""" - devices = [] - for account in hass.data[DOMAIN]: - for device in hass.data[DOMAIN][account]._states: - new_device = hass.data[DOMAIN][account]._states[device] - if isinstance(new_device, SIASensor): - devices.append(new_device) - add_entities(devices) + devices = [ + device + for hub in hass.data[DOMAIN].values() + for device in hub._states.values() + if isinstance(device, SIASensor) + ] + _LOGGER.debug("SIASensor: setup: devices: " + str(devices)) + async_add_entities(devices) class SIASensor(Entity): diff --git a/custom_components/sia/sia_event.py b/custom_components/sia/sia_event.py index d4b42d2..689603d 100644 --- a/custom_components/sia/sia_event.py +++ b/custom_components/sia/sia_event.py @@ -4,6 +4,7 @@ from . import _LOGGER + class SIAEvent: """Class for SIA Events.""" @@ -88,7 +89,7 @@ def __str__(self): self.receiver, self.prefix, self.account, - self.encrypted_content, + self.encrypted_content, self.content, self.zone, self.code, From df634400d6aff4c1ebd6467d48ff7ea2e7b0bf97 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Wed, 9 Oct 2019 15:49:32 +0200 Subject: [PATCH 18/63] fix for #5 --- custom_components/sia/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index c2cbf1a..dd13a76 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -67,6 +67,7 @@ def device_class(self): @state.setter def state(self, state): self._state = state + self.async_schedule_update_ha_state() def assume_available(self): """Stub method, to keep signature the same between all SIA components.""" From 823865d2886d0cb2629663a44dd0b2ef9bf5e9af Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Sun, 15 Dec 2019 18:16:20 +0100 Subject: [PATCH 19/63] fix for error about abstract classes and methods. --- custom_components/sia/alarm_control_panel.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 7293db8..24d8f54 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -8,7 +8,13 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.util.dt import utcnow - +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) from . import ( ALARM_FORMAT, CONF_PING_INTERVAL, @@ -170,3 +176,9 @@ def _async_set_unavailable(self, now): self._remove_unavailability_tracker = None self._is_available = False self.async_schedule_update_ha_state() + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_CUSTOM_BYPASS | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER + From 73db176d4ff4359814eb0619cf36b38747134102 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Tue, 7 Jan 2020 15:19:24 +0100 Subject: [PATCH 20/63] wip on fixing entity id already exists --- custom_components/sia/__init__.py | 8 ++++---- custom_components/sia/alarm_control_panel.py | 14 ++++++++----- custom_components/sia/sensor.py | 21 +++++++++++++++++--- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 85533cc..608d0c4 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -64,7 +64,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "sia" - +DATA_UPDATED = f"{DOMAIN}_data_updated" CONF_ACCOUNT = "account" CONF_ENCRYPTION_KEY = "encryption_key" CONF_HUBS = "hubs" @@ -142,9 +142,9 @@ def setup(hass, config): for component in ["binary_sensor", "alarm_control_panel", "sensor"]: discovery.load_platform(hass, component, DOMAIN, {}, config) - for hub in HASS_PLATFORM.data[DOMAIN].values(): - for sensor in hub._states.values(): - sensor.async_schedule_update_ha_state() + # for hub in HASS_PLATFORM.data[DOMAIN].values(): + # for sensor in hub._states.values(): + # sensor.async_schedule_update_ha_state() server = socketserver.TCPServer(("", port), AlarmTCPHandler) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 24d8f54..579acd0 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -4,6 +4,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.components.alarm_control_panel import AlarmControlPanel @@ -19,6 +20,7 @@ ALARM_FORMAT, CONF_PING_INTERVAL, CONF_ZONE, + DATA_UPDATED, PING_INTERVAL_MARGIN, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, @@ -61,7 +63,7 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} self._is_available = True self._remove_unavailability_tracker = None - self._state = STATE_ALARM_DISARMED + # self._state = STATE_ALARM_DISARMED async def async_added_to_hass(self): """Once the panel is added, see if it was there before and pull in that state.""" @@ -82,12 +84,14 @@ async def async_added_to_hass(self): else: self.state = None else: - self.state = STATE_ALARM_DISARMED # assume disarmed + _LOGGER.debug("SIAAlarmControlPanel: no previous state.") + return + # self.state = STATE_ALARM_DISARMED # assume disarmed _LOGGER.debug("SIAAlarmControlPanel: added: state: " + str(state)) self._async_track_unavailable() - # async_dispatcher_connect( - # self._hass, DATA_UPDATED, self._schedule_immediate_update - # ) + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) @property def entity_id(self): diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index dd13a76..beb7206 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -3,11 +3,11 @@ import logging from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT - +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.util.dt import utcnow -from . import CONF_ZONE, CONF_PING_INTERVAL +from . import CONF_ZONE, CONF_PING_INTERVAL, DATA_UPDATED DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(devices) -class SIASensor(Entity): +class SIASensor(Entity, RestoreEntity): """Class for SIA Sensors.""" def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): @@ -39,6 +39,21 @@ def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): self._name = name self.hass = hass + async def async_added_to_hass(self): + """Once the sensor is added, see if it was there before and pull in that state.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state is not None: + _LOGGER.debug("SIASensor: init: old state: " + state.state) + self.state = state.state + else: + return + _LOGGER.debug("SIASensor: added: state: " + str(state)) + self._async_track_unavailable() + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + @property def entity_id(self): """Get entity_id.""" From 413eb27a0bdeb2a7ec2692b476819b6eb73be597 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Tue, 7 Jan 2020 17:33:24 +0100 Subject: [PATCH 21/63] fixes for #8 and #9 --- custom_components/sia/__init__.py | 7 ++--- custom_components/sia/alarm_control_panel.py | 28 +++++++++++++------- custom_components/sia/binary_sensor.py | 23 ++++++++++------ custom_components/sia/sensor.py | 27 ++++++++++++------- 4 files changed, 55 insertions(+), 30 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 608d0c4..c2348c5 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -142,9 +142,9 @@ def setup(hass, config): for component in ["binary_sensor", "alarm_control_panel", "sensor"]: discovery.load_platform(hass, component, DOMAIN, {}, config) - # for hub in HASS_PLATFORM.data[DOMAIN].values(): - # for sensor in hub._states.values(): - # sensor.async_schedule_update_ha_state() + for hub in HASS_PLATFORM.data[DOMAIN].values(): + for sensor in hub._states.values(): + sensor.async_schedule_update_ha_state() server = socketserver.TCPServer(("", port), AlarmTCPHandler) @@ -255,6 +255,7 @@ def _upsert_sensor(self, zone, sensor_type): ) if constructor and sensor_name: new_sensor = eval(constructor)( + self._name, sensor_id, sensor_name, sensor_type, diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 579acd0..a16c07e 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -48,25 +48,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class SIAAlarmControlPanel(AlarmControlPanel, RestoreEntity): """Class for SIA Alarm Control Panels.""" - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + def __init__( + self, hub_name, entity_id, name, device_class, zone, ping_interval, hass + ): _LOGGER.debug( "SIAAlarmControlPanel: init: Initializing SIA Alarm Control Panel: " + entity_id ) self._should_poll = False - self._entity_id = generate_entity_id( + self.entity_id = generate_entity_id( entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass ) + self._unique_id = f"{hub_name}-{self.entity_id}" self._name = name self.hass = hass self._ping_interval = ping_interval self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} self._is_available = True self._remove_unavailability_tracker = None - # self._state = STATE_ALARM_DISARMED + self._state = None async def async_added_to_hass(self): """Once the panel is added, see if it was there before and pull in that state.""" + _LOGGER.debug("SIAAlarmControlPanel: init: added_to_hass") await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: @@ -93,10 +97,9 @@ async def async_added_to_hass(self): self.hass, DATA_UPDATED, self._schedule_immediate_update ) - @property - def entity_id(self): - """Get entity_id.""" - return self._entity_id + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) @property def name(self): @@ -116,7 +119,7 @@ def state(self): @property def unique_id(self) -> str: """Get unique_id.""" - return self._name + return self._unique_id @property def available(self): @@ -184,5 +187,10 @@ def _async_set_unavailable(self, now): @property def supported_features(self) -> int: """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_CUSTOM_BYPASS | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER - + return ( + SUPPORT_ALARM_ARM_AWAY + | SUPPORT_ALARM_ARM_CUSTOM_BYPASS + | SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_TRIGGER + ) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 29f591b..d8db2eb 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -4,6 +4,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow @@ -12,6 +13,7 @@ CONF_PING_INTERVAL, PING_INTERVAL_MARGIN, CONF_ZONE, + DATA_UPDATED, BINARY_SENSOR_FORMAT, STATE_ON, STATE_OFF, @@ -36,25 +38,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class SIABinarySensor(RestoreEntity): """Class for SIA Binary Sensors.""" - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + def __init__( + self, hub_name, entity_id, name, device_class, zone, ping_interval, hass + ): self._device_class = device_class self._should_poll = False self._ping_interval = ping_interval self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} - self._entity_id = generate_entity_id( + self.entity_id = generate_entity_id( entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass ) + self._unique_id = f"{hub_name}-{self.entity_id}" self._name = name self.hass = hass self._is_available = True self._remove_unavailability_tracker = None self._state = None - @property - def entity_id(self): - """Get entity_id.""" - return self._entity_id - async def async_added_to_hass(self): await super().async_added_to_hass() state = await self.async_get_last_state() @@ -64,6 +64,13 @@ async def async_added_to_hass(self): self.state = None _LOGGER.debug("SIABinarySensor: added: state: " + str(state)) self._async_track_unavailable() + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) @property def name(self): @@ -80,7 +87,7 @@ def state(self): @property def unique_id(self) -> str: - return self._name + return self._unique_id @property def available(self): diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index beb7206..866a1f6 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,10 +1,13 @@ """Module for SIA Sensors.""" import logging +import datetime as dt +from homeassistant.core import callback from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow from . import CONF_ZONE, CONF_PING_INTERVAL, DATA_UPDATED @@ -25,15 +28,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(devices) -class SIASensor(Entity, RestoreEntity): +class SIASensor(RestoreEntity): """Class for SIA Sensors.""" - def __init__(self, entity_id, name, device_class, zone, ping_interval, hass): + def __init__( + self, hub_name, entity_id, name, device_class, zone, ping_interval, hass + ): self._should_poll = False self._device_class = device_class - self._entity_id = generate_entity_id( + self.entity_id = generate_entity_id( entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass ) + self._unique_id = f"{hub_name}-{self.entity_id}" self._state = utcnow() self._attr = {CONF_PING_INTERVAL: str(ping_interval), CONF_ZONE: zone} self._name = name @@ -45,24 +51,27 @@ async def async_added_to_hass(self): state = await self.async_get_last_state() if state is not None and state.state is not None: _LOGGER.debug("SIASensor: init: old state: " + state.state) - self.state = state.state + self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") else: return _LOGGER.debug("SIASensor: added: state: " + str(state)) - self._async_track_unavailable() async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) - @property - def entity_id(self): - """Get entity_id.""" - return self._entity_id + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) @property def name(self): return self._name + @property + def unique_id(self) -> str: + """Get unique_id.""" + return self._unique_id + @property def state(self): return self._state.isoformat() From b3917c52c8f69118b49813f68290134b2130852b Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Wed, 10 Jun 2020 13:11:39 +0200 Subject: [PATCH 22/63] new version based on official component --- README.md | 63 +- custom_components/sia/__init__.py | 620 ++---- custom_components/sia/alarm_control_panel.py | 158 +- custom_components/sia/binary_sensor.py | 125 +- custom_components/sia/config_flow.py | 166 ++ custom_components/sia/const.py | 64 + custom_components/sia/manifest.json | 12 +- custom_components/sia/sensor.py | 96 +- custom_components/sia/sia_event.py | 1935 ------------------ custom_components/sia/strings.json | 31 + custom_components/sia/translations/en.json | 31 + 11 files changed, 747 insertions(+), 2554 deletions(-) create mode 100644 custom_components/sia/config_flow.py create mode 100644 custom_components/sia/const.py delete mode 100644 custom_components/sia/sia_event.py create mode 100644 custom_components/sia/strings.json create mode 100644 custom_components/sia/translations/en.json diff --git a/README.md b/README.md index fb1e689..2aeca33 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ _Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ +_Latest beta will be suggested for inclusion as a official integration._ + **This component will set up the following platforms.** ## WARNING @@ -10,22 +12,22 @@ This integration was tested with Ajax Systems security hub only. Other SIA hubs 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. +`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. +`alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. +`sensor` | Sensor with the last heartbeat message from your system, one per account. ## Features - Alarm tracking with a alarm_control_panel component -- Optional Fire/gas tracker -- Optional Water leak tracker +- Fire/gas tracker +- Water leak tracker - AES-128 CBC encryption support -## Hub Setup(Ajax Systems Hub example) +## 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. +4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. 5. Insert Home Assistant listening port. This port must not be used with anything else. 6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. 7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. @@ -34,52 +36,23 @@ Platform | Description ## Installation 1. Click install. -1. Add at least the minimum configuration to your HA configuration, see below. - -### Minimum config -This is the least amount of information that needs to be in your config. This will result in a `sensor.hubname_last_heartbeat` being added after reboot. Dynamically any other sensors are added. - -```yaml -sia: - port: port - hubs: - - name: hubname - account: account -``` - -## Full configuration - -```yaml -sia: - port: port - hubs: - - name: hubname - account: account - encryption_key: password - ping_interval: pinginterval - zones: - - zone: 1 - name: zonename - sensors: - - alarm - - moisture - - smoke -``` +1. The latest version is only available through a config flow. +1. After clicking the add button in the Integration pane, you full in the below fields. + +If you have multiple accounts that you want to monitor you can choose to have both communicating with the same port, in that case, use the additional accounts checkbox in the config so setup the second (and more) accounts. You can also choose to have both running on a different port, in that case setup the component twice. + +After setup you will see one entity per account for the heartbeat, and 3 entities for each zone per account, alarm, smoke sensor and moisture sensor. This means at least four entities are added, each will also have a device associated with it, so allow you to use the area feature. Unwanted sensors should be hidden in the interface. ## Configuration options Key | Type | Required | Description -- | -- | -- | -- `port` | `int` | `True` | Port that SIA will listen on. -`hubs` | `list` | `True` | List of all hubs to connect to. -`name` | `string` | `True` | Used to generate sensor ids. `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` | `False` | 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. -`zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. -`zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" -`name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. -`sensors` | `list` | `False` | a list of sensors, must be of type: `alarm`, `moisture` (HA standard name for a leak sensor) or `smoke` +`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. *** diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index c2348c5..7c4bf9e 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,457 +1,261 @@ -"""Module for SIA Hub.""" - +"""The sia integration.""" import asyncio -import base64 -from binascii import hexlify, unhexlify -from collections import defaultdict -from datetime import datetime, timedelta -import json +from datetime import timedelta import logging -import random -import re -import socketserver -import string -import sys -import threading -from threading import Thread -import time -from Crypto import Random -from Crypto.Cipher import AES -import requests -from requests_toolbelt.utils import dump -import sseclient -import voluptuous as vol +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent -from homeassistant.components.alarm_control_panel import ( - ENTITY_ID_FORMAT as ALARM_FORMAT, - AlarmControlPanel, -) -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, - BinarySensorDevice, -) -from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_ZONE, - DEVICE_CLASS_TIMESTAMP, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, - STATE_OFF, - STATE_ON, + EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity, generate_entity_id -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, - async_track_state_change, -) -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "sia" -DATA_UPDATED = f"{DOMAIN}_data_updated" -CONF_ACCOUNT = "account" -CONF_ENCRYPTION_KEY = "encryption_key" -CONF_HUBS = "hubs" -CONF_PING_INTERVAL = "ping_interval" -CONF_ZONES = "zones" - -DEVICE_CLASS_ALARM = "alarm" -HUB_SENSOR_NAME = "_last_heartbeat" -HUB_ZONE = 0 - -TYPES = [DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE] - -ZONE_CONFIG = vol.Schema( - { - vol.Optional(CONF_ZONE, default=1): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_SENSORS, default=[DEVICE_CLASS_ALARM]): vol.All( - cv.ensure_list, [vol.In(TYPES)] - ), - } -) - -HUB_CONFIG = vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ACCOUNT): cv.string, - vol.Optional(CONF_ENCRYPTION_KEY): cv.string, - vol.Optional(CONF_PING_INTERVAL, default=1): vol.All( - vol.Coerce(int), vol.Range(min=1, max=1440) - ), - vol.Optional(CONF_ZONES, default=[]): vol.All(cv.ensure_list, [ZONE_CONFIG]), - } -) - -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_R = "\r".encode() - -PING_INTERVAL_MARGIN = timedelta(seconds=30) - -HASS_PLATFORM = None - -# final import here, because they rely on variables above -from .sia_event import SIAEvent from .alarm_control_panel import SIAAlarmControlPanel from .binary_sensor import SIABinarySensor +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ENCRYPTION_KEY, + CONF_PING_INTERVAL, + CONF_ZONES, + DEVICE_CLASS_ALARM, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_TIMESTAMP, + DOMAIN, + HUB_SENSOR_NAME, + HUB_ZONE, + LAST_MESSAGE, + PLATFORMS, + REACTIONS, + UTCNOW, +) from .sensor import SIASensor +_LOGGER = logging.getLogger(__name__) -def setup(hass, config): - """Implementation of setup from HA.""" - global HASS_PLATFORM - socketserver.TCPServer.allow_reuse_address = True - HASS_PLATFORM = hass - HASS_PLATFORM.data[DOMAIN] = {} +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the sia component.""" + hass.data[DOMAIN] = {} + return True - port = int(config[DOMAIN][CONF_PORT]) - for hub_config in config[DOMAIN][CONF_HUBS]: - hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up sia from a config entry.""" + hass.data[DOMAIN][entry.entry_id] = SIAHub( + hass, entry.data, entry.entry_id, entry.title + ) + await hass.data[DOMAIN][entry.entry_id].async_setup_hub() + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + hass.data[DOMAIN][entry.entry_id].sia_client.start(reuse_port=True) + return True - for component in ["binary_sensor", "alarm_control_panel", "sensor"]: - discovery.load_platform(hass, component, DOMAIN, {}, config) - for hub in HASS_PLATFORM.data[DOMAIN].values(): - for sensor in hub._states.values(): - sensor.async_schedule_update_ha_state() +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" - server = socketserver.TCPServer(("", port), AlarmTCPHandler) + await hass.data[DOMAIN][entry.entry_id].sia_client.stop() + hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) - server_thread = threading.Thread(target=server.serve_forever) - server_thread.start() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - return True + return unload_ok -class Hub: +class SIAHub: """Class for SIA Hubs.""" - sensor_types_classes = { - DEVICE_CLASS_ALARM: "SIAAlarmControlPanel", - DEVICE_CLASS_MOISTURE: "SIABinarySensor", - DEVICE_CLASS_SMOKE: "SIABinarySensor", - DEVICE_CLASS_TIMESTAMP: "SIASensor", - } + def __init__(self, hass, hub_config, entry_id, title): + """Create the SIAHub.""" + self._hass = hass + self.states = {} + self._port = int(hub_config[CONF_PORT]) + self.entry_id = entry_id + self._title = title + self._accounts = hub_config[CONF_ACCOUNTS] + self.shutdown_remove_listener = None + + self._zones = [ + { + CONF_ACCOUNT: a[CONF_ACCOUNT], + CONF_ZONE: HUB_ZONE, + CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP], + } + for a in self._accounts + ] + self._zones.extend( + [ + { + CONF_ACCOUNT: a[CONF_ACCOUNT], + CONF_ZONE: z, + CONF_SENSORS: [ + DEVICE_CLASS_ALARM, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, + ], + } + for a in self._accounts + for z in range(1, int(a[CONF_ZONES]) + 1) + ] + ) - # main set of responses to certain codes from SIA (see sia_codes for all of them) - reactions = { - "BA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, - "BR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "CA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CF": { - "type": DEVICE_CLASS_ALARM, - "new_state": STATE_ALARM_ARMED_CUSTOM_BYPASS, - }, - "CG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "GA": {"type": DEVICE_CLASS_SMOKE, "new_state": True}, - "GH": {"type": DEVICE_CLASS_SMOKE, "new_state": False}, - "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, - "OA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "RP": {"type": DEVICE_CLASS_TIMESTAMP, "new_state_eval": "utcnow()"}, - "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, - "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, - "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, - "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": True}, - } + self.sia_accounts = [ + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY)) + for a in self._accounts + ] + self.sia_client = SIAClient( + "", self._port, self.sia_accounts, self.update_states + ) - def __init__(self, hass, hub_config): - self._name = hub_config[CONF_NAME] - self._account_id = hub_config[CONF_ACCOUNT] - self._hass = hass - self._states = {} - self._zones = [dict(z) for z in hub_config.get(CONF_ZONES)] - self._ping_interval = timedelta(minutes=hub_config.get(CONF_PING_INTERVAL)) - self._encrypted = False - self._ending = "]" - self._key = hub_config.get(CONF_ENCRYPTION_KEY) - if self._key: - _LOGGER.debug("Hub: init: encryption is enabled.") - self._encrypted = True - self._key = self._key.encode("utf8") - # IV standards from https://manualzz.com/doc/11555754/sia-digital-communication-standard-%E2%80%93-internet-protocol-ev... - # page 12 specifies the decrytion IV to all zeros. - self._decrypter = AES.new( - self._key, AES.MODE_CBC, unhexlify("00000000000000000000000000000000") - ) - _encrypter = AES.new( - self._key, AES.MODE_CBC, Random.new().read(AES.block_size) - ) - self._ending = ( - hexlify(_encrypter.encrypt("00000000000000|]".encode("utf8"))) - .decode(encoding="UTF-8") - .upper() - ) - # add sensors for each zone as specified in the config. for zone in self._zones: - for sensor in zone.get(CONF_SENSORS): - self._upsert_sensor(zone.get(CONF_ZONE), sensor) - # create the hub sensor - self._upsert_sensor(HUB_ZONE, DEVICE_CLASS_TIMESTAMP) - - def _upsert_sensor(self, zone, sensor_type): - """ checks if the entity exists, and creates otherwise. always gives back the entity_id """ - sensor_id = self._get_id(zone, sensor_type) - if not (sensor_id in self._states.keys()): - zone_found = False - for existing_zone in self._zones: - # if the zone exists then a sensor is missing, - # so, get the zone and add the missing sensor - if existing_zone[CONF_ZONE] == zone: - existing_zone[CONF_SENSORS].append(sensor_type) - zone_found = True - break - if not zone_found: - # if zone does not exist, add it with the sensor and no name - self._zones.append({CONF_ZONE: zone, CONF_SENSORS: [sensor_type]}) + ping = self._get_ping_interval(zone[CONF_ACCOUNT]) + for sensor in zone[CONF_SENSORS]: + self._create_sensor( + self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], sensor, ping + ) - # add the new sensor - sensor_name = self._get_sensor_name(zone, sensor_type) - constructor = self.sensor_types_classes.get(sensor_type) - _LOGGER.debug( - "Hub: upsert_sensor: Updating sensor: " - + sensor_name - + ", id: " - + sensor_id - + ", with constructor: " - + constructor + async def async_setup_hub(self): + """Add a device to the device_registry and register shutdown listener.""" + device_registry = await dr.async_get_registry(self._hass) + port = self._port + for acc in self._accounts: + account = acc[CONF_ACCOUNT] + device_registry.async_get_or_create( + config_entry_id=self.entry_id, + identifiers={(DOMAIN, port, account)}, + name=f"{port} - {account}", ) - if constructor and sensor_name: - new_sensor = eval(constructor)( - self._name, - sensor_id, - sensor_name, - sensor_type, - zone, - self._ping_interval, - self._hass, - ) - _LOGGER.debug("Hub: upsert_sensor: created sensor: " + str(new_sensor)) - self._states[sensor_id] = new_sensor - else: - _LOGGER.warning( - "Hub: Upsert Sensor: Unknown device type: %s", sensor_type - ) - return sensor_id + self.shutdown_remove_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_shutdown + ) - def _get_id(self, zone=0, sensor_type=None): - """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ - if str(zone) == "0": - return self._name + HUB_SENSOR_NAME - else: - if sensor_type: - return self._name + "_" + str(zone) + "_" + sensor_type - else: - _LOGGER.error( - "Hub: Get ID: Not allowed to create an entity_id without type, unless zone == 0." - ) + async def async_shutdown(self): + """Shutdown the SIA server.""" + await self.sia_client.stop() + + def _create_sensor(self, port, account, zone, entity_type, ping): + """Check if the entity exists, and creates otherwise.""" + entity_id, entity_name = self._get_entity_id_and_name( + account, zone, entity_type + ) + if entity_type == DEVICE_CLASS_ALARM: + new_entity = SIAAlarmControlPanel( + entity_id, entity_name, port, account, zone, ping, self._hass, + ) + elif entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + new_entity = SIABinarySensor( + entity_id, + entity_name, + entity_type, + port, + account, + zone, + ping, + self._hass, + ) + elif entity_type == DEVICE_CLASS_TIMESTAMP: + new_entity = SIASensor( + entity_id, + entity_name, + entity_type, + port, + account, + zone, + ping, + self._hass, + ) + self.states[entity_id] = new_entity - def _get_sensor_name(self, zone=0, sensor_type=None): - """ Gives back a entity_id according to the variables, defaults to the hub sensor entity_id. """ - zone = int(zone) + def _get_entity_id_and_name(self, account, zone=0, entity_type=None): + """Give back a entity_id and name according to the variables.""" if zone == 0: - return self._name + " Last heartbeat" + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - Last Heartbeat", + ) else: - zone_name = self._get_zone_name(zone) - if sensor_type: + if entity_type: return ( - self._name - + (" " + zone_name + " " if zone_name else " ") - + sensor_type - ) - else: - _LOGGER.error( - "Hub: Get Sensor Name: Not allowed to create an entity_id without type, unless zone == 0." + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - zone {zone} - {entity_type}", ) - return None - - def _get_zone_name(self, zone: int): - return next( - (z.get(CONF_NAME) for z in self._zones if z.get(CONF_ZONE) == zone), None - ) + return None - def _update_states(self, event): - """ Updates the sensors.""" - # find the reactions for that code (if any) - reaction = self.reactions.get(event.code) - if reaction: - # get the entity_id (or create it) - sensor_id = self._upsert_sensor(event.zone, reaction["type"]) - # find out which action to take, update attribute, new state or eval for new state - attr = reaction.get("attr") - new_state = reaction.get("new_state") - new_state_eval = reaction.get("new_state_eval") - # do the work (can be more than 1) - if new_state or new_state_eval: - _LOGGER.debug( - "Hub: Update States: Will set state for entity: " - + sensor_id - + " to state: " - + (new_state if new_state else new_state_eval) - ) - self._states[sensor_id].state = ( - new_state if new_state else eval(new_state_eval) - ) - if attr: - _LOGGER.debug( - "Hub: Update States: Will set attribute entity: %s", sensor_id - ) - self._states[sensor_id].add_attribute( - { - "Last message": utcnow().isoformat() - + ": SIA: " - + event.sia_string - + ", Message: " - + event.message - } - ) + def _get_entity_id(self, account, zone=0, entity_type=None): + """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" + if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{self._port}_{account}_{HUB_SENSOR_NAME}" else: - _LOGGER.warning( - "Hub: Update States: Unhandled event type: " - + event.sia_string - + ", Message: " - + event.message - ) - # whenever a message comes in, the connection is good, so reset the availability timer for all devices. - for sensor in self._states.values(): - sensor.assume_available() - - def process_event(self, event): - """Process the Event that comes from the TCP handler.""" - try: - _LOGGER.debug("Hub: Process event: %s", event) - if self._encrypted: - self._decrypt_string(event) - _LOGGER.debug("Hub: Process event, after decrypt: %s", event) - self._update_states(event) - except Exception as exc: - _LOGGER.error("Hub: Process Event: %s gave error %s", event, str(exc)) - - # Even if decrypting or something else gives an error, create the acknowledgement message. - return '"ACK"{}L0#{}[{}'.format(event.sequence, self._account_id, self._ending) - - def _decrypt_string(self, event): - """Decrypt the encrypted event content and parse it.""" - _LOGGER.debug("Hub: Decrypt String: Original: %s", str(event.encrypted_content)) - resmsg = self._decrypter.decrypt(unhexlify(event.encrypted_content)).decode( - encoding="UTF-8", errors="replace" - ) - _LOGGER.debug("Hub: Decrypt String: Decrypted: %s", resmsg) - event.parse_decrypted(resmsg) + if entity_type: + return f"{self._port}_{account}_{zone}_{entity_type}" + return None + def _get_ping_interval(self, account): + """Return the ping interval for specified account.""" + for acc in self._accounts: + if acc[CONF_ACCOUNT] == account: + return timedelta(minutes=acc[CONF_PING_INTERVAL]) + return None -class AlarmTCPHandler(socketserver.BaseRequestHandler): - """Class for the TCP Handler.""" + async def update_states(self, event: SIAEvent): + """Update the sensors. This can be both a new state and a new attribute. - _received_data = "".encode() - - def handle_line(self, line): - """Method called for each line that comes in.""" - _LOGGER.debug("TCP: Handle Line: Income raw string: %s", line) - try: - event = SIAEvent(line) - _LOGGER.debug("TCP: Handle Line: event: %s", str(event)) - if not event.valid_message: - _LOGGER.error( - "TCP: Handle Line: CRC mismatch, received: %s, calculated: %s", - event.msg_crc, - event.calc_crc, - ) - raise Exception("CRC mismatch") - if event.account not in HASS_PLATFORM.data[DOMAIN]: - _LOGGER.error( - "TCP: Handle Line: Not supported account %s", event.account - ) - raise Exception( - "TCP: Handle Line: Not supported account {}".format(event.account) - ) - response = HASS_PLATFORM.data[DOMAIN][event.account].process_event(event) - except Exception as exc: - _LOGGER.error("TCP: Handle Line: error: %s", str(exc)) - timestamp = datetime.fromtimestamp(time.time()).strftime( - "_%H:%M:%S,%m-%d-%Y" - ) - response = '"NAK"0000L0R0A0[]' + timestamp + Whenever a message comes in and is a event that should cause a reaction, the connection is good, so reset the availability timer for all devices of that account, excluding the last heartbeat. - header = ("%04x" % len(response)).upper() - response = "\n{}{}{}\r".format( - AlarmTCPHandler.crc_calc(response), header, response - ) - byte_response = str.encode(response) - self.request.sendall(byte_response) - - def handle(self): - """Method called for handling.""" - line = b"" - try: - while True: - raw = self.request.recv(1024) - if not raw: - 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.decode()) - except Exception as exc: - _LOGGER.error( - "TCP: Handle: last line %s gave error: %s", line.decode(), str(exc) + """ + # find the reactions for that code (if any) + reaction = REACTIONS.get(event.code) + if not reaction: + _LOGGER.warning( + "Unhandled event code: %s, Message: %s, Full event: %s", + event.code, + event.message, + event.sia_string, ) return + attr = reaction.get("attr") + new_state = reaction.get("new_state") + new_state_eval = reaction.get("new_state_eval") + entity_id = self._get_entity_id( + event.account, int(event.zone), reaction["type"] + ) - @staticmethod - def crc_calc(msg): - """Calculate the CRC of the response.""" - new_crc = 0 - for letter in msg: - temp = ord(letter) - for _ in range(0, 8): - temp ^= new_crc & 1 - new_crc >>= 1 - if (temp & 1) != 0: - new_crc ^= 0xA001 - temp >>= 1 + if new_state: + self.states[entity_id].state = new_state + elif new_state_eval: + if new_state_eval == UTCNOW: + self.states[entity_id].state = utcnow() + if attr: + if attr == LAST_MESSAGE: + self.states[entity_id].add_attribute( + { + "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" + } + ) - return ("%x" % new_crc).upper().zfill(4) + await asyncio.gather( + *[ + entity.assume_available() + for entity in self.states.values() + if entity.account == event.account and not isinstance(entity, SIASensor) + ] + ) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index a16c07e..70005b5 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -2,79 +2,79 @@ import logging +from homeassistant.components.alarm_control_panel import ( + ENTITY_ID_FORMAT as ALARM_FORMAT, + AlarmControlPanelEntity, +) +from homeassistant.const import ( + CONF_ZONE, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) from homeassistant.core import callback -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.util.dt import utcnow -from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_TRIGGER, -) -from . import ( - ALARM_FORMAT, + +from .const import ( + CONF_ACCOUNT, CONF_PING_INTERVAL, - CONF_ZONE, DATA_UPDATED, + DOMAIN, PING_INTERVAL_MARGIN, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, + PREVIOUS_STATE, ) -DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Implementation of platform setup from HA.""" - devices = [ - device - for hub in hass.data[DOMAIN].values() - for device in hub._states.values() - if isinstance(device, SIAAlarmControlPanel) - ] - _LOGGER.debug("SIAAlarmControlPanel: setup: devices: " + str(devices)) - async_add_entities(devices) +async def async_setup_entry(hass, entry, async_add_devices): + """Set up sia_alarm_control_panel from a config entry.""" + async_add_devices( + [ + device + for device in hass.data[DOMAIN][entry.entry_id].states.values() + if isinstance(device, SIAAlarmControlPanel) + ] + ) + return True -class SIAAlarmControlPanel(AlarmControlPanel, RestoreEntity): + +class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): """Class for SIA Alarm Control Panels.""" - def __init__( - self, hub_name, entity_id, name, device_class, zone, ping_interval, hass - ): - _LOGGER.debug( - "SIAAlarmControlPanel: init: Initializing SIA Alarm Control Panel: " - + entity_id - ) - self._should_poll = False - self.entity_id = generate_entity_id( - entity_id_format=ALARM_FORMAT, name=entity_id, hass=hass - ) - self._unique_id = f"{hub_name}-{self.entity_id}" + def __init__(self, entity_id, name, port, account, zone, ping_interval, hass): + """Create SIAAlarmControlPanel object.""" + self.entity_id = ALARM_FORMAT.format(entity_id) + self._unique_id = entity_id self._name = name - self.hass = hass + self._port = port + self._account = account + self._zone = zone self._ping_interval = ping_interval - self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} + self.hass = hass + + self._should_poll = False self._is_available = True self._remove_unavailability_tracker = None self._state = None + self._old_state = None + self._attr = { + CONF_ACCOUNT: self._account, + CONF_PING_INTERVAL: str(self._ping_interval), + CONF_ZONE: self._zone, + } async def async_added_to_hass(self): """Once the panel is added, see if it was there before and pull in that state.""" - _LOGGER.debug("SIAAlarmControlPanel: init: added_to_hass") await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: - _LOGGER.debug("SIAAlarmControlPanel: init: old state: " + state.state) if state.state == STATE_ALARM_ARMED_AWAY: self.state = STATE_ALARM_ARMED_AWAY elif state.state == STATE_ALARM_ARMED_NIGHT: @@ -88,11 +88,8 @@ async def async_added_to_hass(self): else: self.state = None else: - _LOGGER.debug("SIAAlarmControlPanel: no previous state.") - return - # self.state = STATE_ALARM_DISARMED # assume disarmed - _LOGGER.debug("SIAAlarmControlPanel: added: state: " + str(state)) - self._async_track_unavailable() + self.state = None + await self._async_track_unavailable() async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) @@ -116,6 +113,11 @@ def state(self): """Get state.""" return self._state + @property + def account(self): + """Return device account.""" + return self._account + @property def unique_id(self) -> str: """Get unique_id.""" @@ -126,46 +128,26 @@ def available(self): """Get availability.""" return self._is_available - def alarm_disarm(self, code=None): - """Method for disarming, not implemented.""" - _LOGGER.debug("Not implemented.") - - def alarm_arm_home(self, code=None): - """Method for arming, not implemented.""" - _LOGGER.debug("Not implemented.") - - def alarm_arm_away(self, code=None): - """Method for arming, not implemented.""" - _LOGGER.debug("Not implemented.") - - def alarm_arm_night(self, code=None): - """Method for arming, not implemented.""" - _LOGGER.debug("Not implemented.") - - def alarm_trigger(self, code=None): - """Method for triggering, not implemented.""" - _LOGGER.debug("Not implemented.") - - def alarm_arm_custom_bypass(self, code=None): - """Method for arming, not implemented.""" - _LOGGER.debug("Not implemented.") - @property def device_state_attributes(self): + """Return device attributes.""" return self._attr @state.setter def state(self, state): - self._state = state + """Set state.""" + temp = self._old_state if state == PREVIOUS_STATE else state + self._old_state = self._state + self._state = temp self.async_schedule_update_ha_state() - def assume_available(self): + async def assume_available(self): """Reset unavalability tracker.""" - self._async_track_unavailable() + await self._async_track_unavailable() @callback - def _async_track_unavailable(self): - """Callback method for resetting unavailability.""" + async def _async_track_unavailable(self): + """Reset unavailability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() self._remove_unavailability_tracker = async_track_point_in_utc_time( @@ -180,6 +162,7 @@ def _async_track_unavailable(self): @callback def _async_set_unavailable(self, now): + """Set availability.""" self._remove_unavailability_tracker = None self._is_available = False self.async_schedule_update_ha_state() @@ -187,10 +170,13 @@ def _async_set_unavailable(self, now): @property def supported_features(self) -> int: """Return the list of supported features.""" - return ( - SUPPORT_ALARM_ARM_AWAY - | SUPPORT_ALARM_ARM_CUSTOM_BYPASS - | SUPPORT_ALARM_ARM_HOME - | SUPPORT_ALARM_ARM_NIGHT - | SUPPORT_ALARM_TRIGGER - ) + return None + + @property + def device_info(self): + """Return the device_info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self._port, self._account), + } diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index d8db2eb..8d716f0 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -2,78 +2,91 @@ import logging +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, + BinarySensorEntity, +) +from homeassistant.const import CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import callback -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from . import ( +from .const import ( + CONF_ACCOUNT, CONF_PING_INTERVAL, - PING_INTERVAL_MARGIN, - CONF_ZONE, DATA_UPDATED, - BINARY_SENSOR_FORMAT, - STATE_ON, - STATE_OFF, + DOMAIN, + PING_INTERVAL_MARGIN, ) -DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Implementation of platform setup from HA.""" - devices = [ - device - for hub in hass.data[DOMAIN].values() - for device in hub._states.values() - if isinstance(device, SIABinarySensor) - ] - _LOGGER.debug("SIABinarySensor: setup: devices: " + str(devices)) - async_add_entities(devices) +async def async_setup_entry(hass, entry, async_add_devices): + """Set up sia_binary_sensor from a config entry.""" + async_add_devices( + [ + device + for device in hass.data[DOMAIN][entry.entry_id].states.values() + if isinstance(device, SIABinarySensor) + ] + ) + return True -class SIABinarySensor(RestoreEntity): + +class SIABinarySensor(BinarySensorEntity, RestoreEntity): """Class for SIA Binary Sensors.""" def __init__( - self, hub_name, entity_id, name, device_class, zone, ping_interval, hass + self, entity_id, name, device_class, port, account, zone, ping_interval, hass ): + """Create SIABinarySensor object.""" + + self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) + self._unique_id = entity_id + self._name = name self._device_class = device_class - self._should_poll = False + self._port = port + self._account = account + self._zone = zone self._ping_interval = ping_interval - self._attr = {CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: zone} - self.entity_id = generate_entity_id( - entity_id_format=BINARY_SENSOR_FORMAT, name=entity_id, hass=hass - ) - self._unique_id = f"{hub_name}-{self.entity_id}" - self._name = name self.hass = hass + + self._should_poll = False + self._is_on = None self._is_available = True self._remove_unavailability_tracker = None - self._state = None + self._attr = { + CONF_ACCOUNT: self._account, + CONF_PING_INTERVAL: str(self._ping_interval), + CONF_ZONE: self._zone, + } async def async_added_to_hass(self): + """Add sensor to HASS.""" await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: - self.state = state.state == STATE_ON - else: - self.state = None - _LOGGER.debug("SIABinarySensor: added: state: " + str(state)) - self._async_track_unavailable() + if state.state == STATE_ON: + self._is_on = True + elif state.state == STATE_OFF: + self._is_on = False + await self._async_track_unavailable() async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) @callback def _schedule_immediate_update(self): + """Schedule update.""" self.async_schedule_update_ha_state(True) @property def name(self): + """Return name.""" return self._name @property @@ -81,42 +94,56 @@ def ping_interval(self): """Get ping_interval.""" return str(self._ping_interval) - @property - def state(self): - return STATE_ON if self.is_on else STATE_OFF - @property def unique_id(self) -> str: + """Return unique id.""" return self._unique_id + @property + def account(self): + """Return device account.""" + return self._account + @property def available(self): + """Return avalability.""" return self._is_available @property def device_state_attributes(self): + """Return attributes.""" return self._attr @property def device_class(self): + """Return device class.""" return self._device_class + @property + def state(self): + """Return the state of the binary sensor.""" + if self.is_on is None: + return STATE_UNKNOWN + return STATE_ON if self.is_on else STATE_OFF + @property def is_on(self): - """Get whether the sensor is set to ON.""" - return self._state + """Return true if the binary sensor is on.""" + return self._is_on @state.setter - def state(self, state): - self._state = state + def state(self, new_on): + """Set state.""" + self._is_on = new_on self.async_schedule_update_ha_state() - def assume_available(self): + async def assume_available(self): """Reset unavalability tracker.""" - self._async_track_unavailable() + await self._async_track_unavailable() @callback - def _async_track_unavailable(self): + async def _async_track_unavailable(self): + """Track availability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() self._remove_unavailability_tracker = async_track_point_in_utc_time( @@ -131,6 +158,16 @@ def _async_track_unavailable(self): @callback def _async_set_unavailable(self, now): + """Set unavailable.""" self._remove_unavailability_tracker = None self._is_available = False self.async_schedule_update_ha_state() + + @property + def device_info(self): + """Return the device_info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self._port, self._account), + } diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py new file mode 100644 index 0000000..67827ca --- /dev/null +++ b/custom_components/sia/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for sia integration.""" +import logging + +from pysiaalarm import ( + InvalidAccountFormatError, + InvalidAccountLengthError, + InvalidKeyFormatError, + InvalidKeyLengthError, + SIAAccount, +) +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_PORT +from homeassistant.data_entry_flow import AbortFlow + +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ADDITIONAL_ACCOUNTS, + CONF_ENCRYPTION_KEY, + CONF_PING_INTERVAL, + CONF_ZONES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +HUB_SCHEMA = vol.Schema( + { + vol.Required(CONF_PORT): int, + 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, + } +) + + +def validate_input(data): + """Validate the input by the user.""" + SIAAccount(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) + + try: + ping = int(data[CONF_PING_INTERVAL]) + assert 1 <= ping <= 1440 + except AssertionError: + raise InvalidPing + try: + zones = int(data[CONF_ZONES]) + assert zones > 0 + except AssertionError: + raise InvalidZones + + return True + + +class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for sia.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + data = None + + async def async_step_add_account(self, user_input=None): + """Handle the additional accounts steps.""" + errors = {} + if user_input is not None: + try: + if validate_input(user_input): + add_data = user_input.copy() + add_data.pop(CONF_ADDITIONAL_ACCOUNTS) + self.data[CONF_ACCOUNTS].append(add_data) + if user_input[CONF_ADDITIONAL_ACCOUNTS]: + return await self.async_step_add_account() + except InvalidKeyFormatError: + errors["base"] = "invalid_key_format" + except InvalidKeyLengthError: + errors["base"] = "invalid_key_length" + except InvalidAccountFormatError: + errors["base"] = "invalid_account_format" + except InvalidAccountLengthError: + errors["base"] = "invalid_account_length" + except InvalidPing: + errors["base"] = "invalid_ping" + except InvalidZones: + errors["base"] = "invalid_zones" + + return self.async_show_form( + step_id="user", data_schema=ACCOUNT_SCHEMA, errors=errors, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + if validate_input(user_input): + if not self.data: + self.data = { + CONF_PORT: user_input[CONF_PORT], + CONF_ACCOUNTS: [ + { + CONF_ACCOUNT: user_input[CONF_ACCOUNT], + CONF_ENCRYPTION_KEY: user_input.get( + CONF_ENCRYPTION_KEY + ), + CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], + CONF_ZONES: user_input[CONF_ZONES], + } + ], + } + else: + add_data = user_input.copy() + add_data.pop(CONF_ADDITIONAL_ACCOUNTS) + self.data[CONF_ACCOUNTS].append(add_data) + await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") + self._abort_if_unique_id_configured() + if not user_input[CONF_ADDITIONAL_ACCOUNTS]: + return self.async_create_entry( + title=f"SIA Alarm on port {self.data[CONF_PORT]}", + data=self.data, + ) + else: + return await self.async_step_add_account() + except InvalidKeyFormatError: + errors["base"] = "invalid_key_format" + except InvalidKeyLengthError: + errors["base"] = "invalid_key_length" + except InvalidAccountFormatError: + errors["base"] = "invalid_account_format" + except InvalidAccountLengthError: + errors["base"] = "invalid_account_length" + except InvalidPing: + errors["base"] = "invalid_ping" + except InvalidZones: + errors["base"] = "invalid_zones" + except AbortFlow: + return self.async_abort(reason="already_configured") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=HUB_SCHEMA, errors=errors + ) + + +class InvalidPing(exceptions.HomeAssistantError): + """Error to indicate there is invalid ping interval.""" + + +class InvalidZones(exceptions.HomeAssistantError): + """Error to indicate there is invalid number of zones.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py new file mode 100644 index 0000000..bf78c48 --- /dev/null +++ b/custom_components/sia/const.py @@ -0,0 +1,64 @@ +"""Constants for the sia integration.""" + +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, + DOMAIN as BINARY_SENSOR_DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + DEVICE_CLASS_TIMESTAMP, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) + +CONF_ACCOUNT = "account" +CONF_ACCOUNTS = "accounts" +CONF_ADDITIONAL_ACCOUNTS = "additional_account" +CONF_PING_INTERVAL = "ping_interval" +CONF_ENCRYPTION_KEY = "encryption_key" +CONF_ZONES = "zones" +DOMAIN = "sia" +DATA_UPDATED = f"{DOMAIN}_data_updated" +DEFAULT_NAME = "SIA Alarm" +DEVICE_CLASS_ALARM = "alarm" +HUB_SENSOR_NAME = "last_heartbeat" +HUB_ZONE = 0 +PING_INTERVAL_MARGIN = timedelta(seconds=30) +PREVIOUS_STATE = "PREVIOUS_STATE" +UTCNOW = "utcnow" +LAST_MESSAGE = "lastmessage" + +PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] + +REACTIONS = { + "BA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "BR": {"type": DEVICE_CLASS_ALARM, "new_state": PREVIOUS_STATE}, + "CA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CF": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_CUSTOM_BYPASS}, + "CG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "CQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, + "GA": {"type": DEVICE_CLASS_SMOKE, "new_state": True}, + "GH": {"type": DEVICE_CLASS_SMOKE, "new_state": False}, + "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, + "OA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "OR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, + "RP": {"type": DEVICE_CLASS_TIMESTAMP, "new_state_eval": UTCNOW}, + "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, + "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, + "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, + "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE}, +} diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index f9dd24a..090d1a0 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -1,8 +1,8 @@ { "domain": "sia", - "name": "Sia", - "documentation": "", - "dependencies": [], - "codeowners": ["@cheater.dev", "@eavanvalkenburg"], - "requirements": [] -} \ No newline at end of file + "name": "SIA Alarm Systems", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sia", + "requirements": ["pysiaalarm==2.0.3"], + "codeowners": ["@eavanvalkenburg"] +} diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 866a1f6..f08f605 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,70 +1,87 @@ """Module for SIA Sensors.""" -import logging import datetime as dt +import logging -from homeassistant.core import callback from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT +from homeassistant.const import CONF_ZONE +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from . import CONF_ZONE, CONF_PING_INTERVAL, DATA_UPDATED +from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DATA_UPDATED, DOMAIN -DOMAIN = "sia" _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Implementation of platform setup from HA.""" - devices = [ - device - for hub in hass.data[DOMAIN].values() - for device in hub._states.values() - if isinstance(device, SIASensor) - ] - _LOGGER.debug("SIASensor: setup: devices: " + str(devices)) - async_add_entities(devices) +async def async_setup_entry(hass, entry, async_add_devices): + """Set up sia_sensor from a config entry.""" + async_add_devices( + [ + device + for device in hass.data[DOMAIN][entry.entry_id].states.values() + if isinstance(device, SIASensor) + ] + ) + + return True class SIASensor(RestoreEntity): """Class for SIA Sensors.""" def __init__( - self, hub_name, entity_id, name, device_class, zone, ping_interval, hass + self, + entity_id, + name, + device_class, + port, + account, + zone, + ping_interval, + hass + # self, entity_id, name, zone, account, ping_interval, hass, ): - self._should_poll = False - self._device_class = device_class - self.entity_id = generate_entity_id( - entity_id_format=SENSOR_FORMAT, name=entity_id, hass=hass - ) - self._unique_id = f"{hub_name}-{self.entity_id}" - self._state = utcnow() - self._attr = {CONF_PING_INTERVAL: str(ping_interval), CONF_ZONE: zone} + """Create SIASensor object.""" + self.entity_id = SENSOR_FORMAT.format(entity_id) + self._unique_id = entity_id self._name = name + self._device_class = device_class + self._port = port + self._account = account + self._zone = zone + self._ping_interval = str(ping_interval) self.hass = hass + self._state = utcnow() + self._should_poll = False + self._attr = { + CONF_ACCOUNT: self._account, + CONF_PING_INTERVAL: self._ping_interval, + CONF_ZONE: self._zone, + } + async def async_added_to_hass(self): """Once the sensor is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: - _LOGGER.debug("SIASensor: init: old state: " + state.state) self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") else: return - _LOGGER.debug("SIASensor: added: state: " + str(state)) async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) @callback def _schedule_immediate_update(self): + """Schedule update.""" self.async_schedule_update_ha_state(True) @property def name(self): + """Return name.""" return self._name @property @@ -74,10 +91,17 @@ def unique_id(self) -> str: @property def state(self): + """Return state.""" return self._state.isoformat() + @property + def account(self): + """Return device account.""" + return self._account + @property def device_state_attributes(self): + """Return attributes.""" return self._attr def add_attribute(self, attr): @@ -86,18 +110,30 @@ def add_attribute(self, attr): @property def device_class(self): + """Return device class.""" return self._device_class @state.setter def state(self, state): + """Set state.""" self._state = state self.async_schedule_update_ha_state() - def assume_available(self): - """Stub method, to keep signature the same between all SIA components.""" - pass - @property def icon(self): """Return the icon to use in the frontend, if any.""" return "mdi:alarm-light-outline" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "ISO8601" + + @property + def device_info(self): + """Return the device_info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self._port, self._account), + } diff --git a/custom_components/sia/sia_event.py b/custom_components/sia/sia_event.py deleted file mode 100644 index 689603d..0000000 --- a/custom_components/sia/sia_event.py +++ /dev/null @@ -1,1935 +0,0 @@ -"""Module for SIA Events.""" - -import re - -from . import _LOGGER - - -class SIAEvent: - """Class for SIA Events.""" - - def __init__(self, line): - # Example events: 98100078"*SIA-DCS"5994L0#acct[5AB718E008C616BF16F6468033A11326B0F7546CAB230910BCA10E4DEBA42283C436E4F8EFF50931070DDE36D5BB5F0C - # Example events: 66100078"*SIA-DCS"6001L0#acct[6F7457178C6F0EAD99109E1DC5B75B26EDFBE1AA17361CD48E0B0E340081035F16AD2A25CD3D7F04105EC1EA65BF6341 - # Example events: 2E680078"*SIA-DCS"6002L0#acct[FDDCDFEC950EDC3F7C438B75CD57B9C91E1CA632806882769097C60292F86BD13D43D3BA7E2F529560DC7B51E6581E58 - # Example events: 2E680078"SIA-DCS"6002L0#acct[|Nri1/CL501]_14:12:04,09-25-2019 - # Example events: 5BFD0078"*SIA-DCS"6003L0#acct[03D1EA959BCC9E2DA91CACA7AFF472F1CB234708977C4E1E3B86A8ABD45AD9F95F0EFFFF817EE5349572972325BFC856 - # Example events: 5BFD0078"SIA-DCS"6003L0#acct[|Nri1/OP501]_14:12:04,09-25-2019 - - regex = r"(.{4})0[A-F0-9]{3}(\"(SIA-DCS|\*SIA-DCS)\"([0-9]{4})(R[A-F0-9]{1,6})?(L[A-F0-9]{1,6})#([A-F0-9]{3,16})\[([A-F0-9]*)?(.*Nri(\d*)/([a-zA-z]{2})(.*)]_([0-9:,-]*))?)" - matches = re.findall(regex, line) - - # check if there is at lease one match - if not matches: - raise ValueError("SIAEvent: Constructor: no matches found.") - # _LOGGER.debug(matches) - self.msg_crc, self.full_message, self.message_type, self.sequence, self.receiver, self.prefix, self.account, self.encrypted_content, self.content, self.zone, self.code, self.message, self.timestamp = matches[ - 0 - ] - self.type = "" - self.description = "" - self.concerns = "" - self.calc_crc = SIAEvent.crc_calc(self.full_message) - if self.code: - self._add_sia() - - def _add_sia(self): - """Finds the sia codes based on self.code.""" - full = self.all_codes.get(self.code, None) - if full: - self.type = full.get("type") - self.description = full.get("description") - self.concerns = full.get("concerns") - else: - raise LookupError("Code not found: {}".format(self.code)) - - def parse_decrypted(self, new_data): - """When the content was decrypted, update the fields contained within.""" - regex = r".*Nri(\d*)/([a-zA-z]{2})(.*)]_([0-9:,-]*)" - matches = re.findall(regex, new_data) - if not matches: - raise ValueError("SIAEvent: Parse Decrypted: no matches found.") - self.zone, self.code, self.message, self.timestamp = matches[0] - if self.code: - self._add_sia() - - @staticmethod - def crc_calc(msg): - """Calculate the CRC of the events.""" - crc = 0 - for letter in str.encode(msg): - temp = letter - for _ in range(0, 8): - temp ^= crc & 1 - crc >>= 1 - if (temp & 1) != 0: - crc ^= 0xA001 - temp >>= 1 - return ("%x" % crc).upper().zfill(4) - - @property - def valid_message(self): - """Check the validity of the message by comparing the sent CRC with the calculated CRC.""" - return self.msg_crc == self.calc_crc - - @property - def sia_string(self): - """Create a string with the SIA codes and some other fields.""" - return "Code: {}, Type: {}, Description: {}, Concerns: {}".format( - self.code, self.type, self.description, self.concerns - ) - - def __str__(self): - return "CRC: {}, Calc CRC: {}, Full Message: {}, Message type: {}, Sequence: {}, Receiver: {}, Prefix: {}, Account: {}, Encrypted Content: {}, Content: {}, Zone: {}, Code: {}, Message: {}, Timestamp: {}, Code: {}, Type: {}, Description: {}, Concerns: {}".format( - self.msg_crc, - self.calc_crc, - self.full_message, - self.message_type, - self.sequence, - self.receiver, - self.prefix, - self.account, - self.encrypted_content, - self.content, - self.zone, - self.code, - self.message, - self.timestamp, - self.code, - self.type, - self.description, - self.concerns, - ) - - all_codes = { - "AA": { - "code": "AA", - "type": "Alarm – Panel Substitution", - "description": "An attempt to substitute an alternate alarm panel for a secure panel has been made", - "concerns": "Condition number", - }, - "AB": { - "code": "AB", - "type": "Abort", - "description": "An event message was not sent due to User action", - "concerns": "Zone or point", - }, - "AN": { - "code": "AN", - "type": "Analog Restoral", - "description": "An analog fire sensor has been restored to normal operation", - "concerns": "Zone or point", - }, - "AR": { - "code": "AR", - "type": "AC Restoral", - "description": "AC power has been restored", - "concerns": "Unused", - }, - "AS": { - "code": "AS", - "type": "Analog Service", - "description": "An analog fire sensor needs to be cleaned or calibrated", - "concerns": "Zone or point", - }, - "AT": { - "code": "AT", - "type": "AC Trouble", - "description": "AC power has been failed", - "concerns": "Unused", - }, - "BA": { - "code": "BA", - "type": "Burglary Alarm", - "description": "Burglary zone has been violated while armed", - "concerns": "Zone or point", - }, - "BB": { - "code": "BB", - "type": "Burglary Bypass", - "description": "Burglary zone has been bypassed", - "concerns": "Zone or point", - }, - "BC": { - "code": "BC", - "type": "Burglary Cancel", - "description": "Alarm has been cancelled by authorized user", - "concerns": "User number", - }, - "BD": { - "code": "BD", - "type": "Swinger Trouble", - "description": "A non-fire zone has been violated after a Swinger Shutdown on the zone", - "concerns": "Zone or point", - }, - "BE": { - "code": "BE", - "type": "Swinger Trouble Restore", - "description": "A non-fire zone restores to normal from a Swinger Trouble state", - "concerns": "Zone or point", - }, - "BG": { - "code": "BG", - "type": "Unverified Event - Burglary", - "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", - "concerns": "Zone or point", - }, - "BH": { - "code": "BH", - "type": "Burglary Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "BJ": { - "code": "BJ", - "type": "Burglary Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "BM": { - "code": "BM", - "type": "Burglary Alarm - Cross Point", - "description": "Burglary alarm w/cross point also in alarm - alarm verified", - "concerns": "Zone or point", - }, - "BR": { - "code": "BR", - "type": "Burglary Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "BS": { - "code": "BS", - "type": "Burglary Supervisory", - "description": "Unsafe intrusion detection system condition", - "concerns": "Zone or point", - }, - "BT": { - "code": "BT", - "type": "Burglary Trouble", - "description": "Burglary zone disabled by fault", - "concerns": "Zone or point", - }, - "BU": { - "code": "BU", - "type": "Burglary Unbypass", - "description": "Zone bypass has been removed", - "concerns": "Zone or point", - }, - "BV": { - "code": "BV", - "type": "Burglary Verified", - "description": "A burglary alarm has occurred and been verified within programmed conditions. (zone or point not sent)", - "concerns": "Area number", - }, - "BX": { - "code": "BX", - "type": "Burglary Test", - "description": "Burglary zone activated during testing", - "concerns": "Zone or point", - }, - "BZ": { - "code": "BZ", - "type": "Missing Supervision", - "description": "A non-fire Supervisory point has gone missing", - "concerns": "Zone or point", - }, - "CA": { - "code": "CA", - "type": "Automatic Closing", - "description": "System armed automatically", - "concerns": "Area number", - }, - "CD": { - "code": "CD", - "type": "Closing Delinquent", - "description": "The system has not been armed for a programmed amount of time", - "concerns": "Area number", - }, - "CE": { - "code": "CE", - "type": "Closing Extend", - "description": "Extend closing time", - "concerns": "User number", - }, - "CF": { - "code": "CF", - "type": "Forced Closing", - "description": "System armed, some zones not ready", - "concerns": "User number", - }, - "CG": { - "code": "CG", - "type": "Close Area", - "description": "System has been partially armed", - "concerns": "Area number", - }, - "CI": { - "code": "CI", - "type": "Fail to Close", - "description": "An area has not been armed at the end of the closing window", - "concerns": "Area number", - }, - "CJ": { - "code": "CJ", - "type": "Late Close", - "description": "An area was armed after the closing window", - "concerns": "User number", - }, - "CK": { - "code": "CK", - "type": "Early Close", - "description": "An area was armed before the closing window", - "concerns": "User number", - }, - "CL": { - "code": "CL", - "type": "Closing Report", - "description": "System armed, normal", - "concerns": "User number", - }, - "CM": { - "code": "CM", - "type": "Missing Alarm - Recent Closing", - "description": "A point has gone missing within 2 minutes of closing", - "concerns": "Zone or point", - }, - "CO": { - "code": "CO", - "type": "Command Sent", - "description": "A command has been sent to an expansion/peripheral device", - "concerns": "Condition number", - }, - "CP": { - "code": "CP", - "type": "Automatic Closing", - "description": "System armed automatically", - "concerns": "User number", - }, - "CQ": { - "code": "CQ", - "type": "Remote Closing", - "description": "The system was armed from a remote location", - "concerns": "User number", - }, - "CR": { - "code": "CR", - "type": "Recent Closing", - "description": "An alarm occurred within five minutes after the system was closed", - "concerns": "User number", - }, - "CS": { - "code": "CS", - "type": "Closing Keyswitch", - "description": "Account has been armed by keyswitch", - "concerns": "Zone or point", - }, - "CT": { - "code": "CT", - "type": "Late to Open", - "description": "System was not disarmed on time", - "concerns": "Area number", - }, - "CW": { - "code": "CW", - "type": "Was Force Armed", - "description": "Header for a force armed session, forced point msgs may follow", - "concerns": "Area number", - }, - "CX": { - "code": "CX", - "type": "Custom Function Executed", - "description": "The panel has executed a preprogrammed set of instructions", - "concerns": "Custom Function number", - }, - "CZ": { - "code": "CZ", - "type": "Point Closing", - "description": "A point, as opposed to a whole area or account, has closed", - "concerns": "Zone or point", - }, - "DA": { - "code": "DA", - "type": "Card Assigned", - "description": "An access ID has been added to the controller", - "concerns": "User number", - }, - "DB": { - "code": "DB", - "type": "Card Deleted", - "description": "An access ID has been deleted from the controller", - "concerns": "User number", - }, - "DC": { - "code": "DC", - "type": "Access Closed", - "description": "Access to all users prohibited", - "concerns": "Door number", - }, - "DD": { - "code": "DD", - "type": "Access Denied", - "description": "Access denied, unknown code", - "concerns": "Door number", - }, - "DE": { - "code": "DE", - "type": "Request to Enter", - "description": "An access point was opened via a Request to Enter device", - "concerns": "Door number", - }, - "DF": { - "code": "DF", - "type": "Door Forced", - "description": "Door opened without access request", - "concerns": "Door number", - }, - "DG": { - "code": "DG", - "type": "Access Granted", - "description": "Door access granted", - "concerns": "Door number", - }, - "DH": { - "code": "DH", - "type": "Door Left Open - Restoral", - "description": "An access point in a Door Left Open state has restored", - "concerns": "Door number", - }, - "DI": { - "code": "DI", - "type": "Access Denied – Passback", - "description": "Access denied because credential has not exited area before attempting to re-enter same area", - "concerns": "Door number", - }, - "DJ": { - "code": "DJ", - "type": "Door Forced - Trouble", - "description": "An access point has been forced open in an unarmed area", - "concerns": "Door number", - }, - "DK": { - "code": "DK", - "type": "Access Lockout", - "description": "Access denied, known code", - "concerns": "Door number", - }, - "DL": { - "code": "DL", - "type": "Door Left Open - Alarm", - "description": "An open access point when open time expired in an armed area", - "concerns": "Door number", - }, - "DM": { - "code": "DM", - "type": "Door Left Open - Trouble", - "description": "An open access point when open time expired in an unarmed area", - "concerns": "Door number", - }, - "DN": { - "code": "DN", - "type": "Door Left Open (non-alarm, non-trouble)", - "description": "An access point was open when the door cycle time expired", - "concerns": "Door number", - }, - "DO": { - "code": "DO", - "type": "Access Open", - "description": "Access to authorized users allowed", - "concerns": "Door number", - }, - "DP": { - "code": "DP", - "type": "Access Denied - Unauthorized Time", - "description": "An access request was denied because the request is occurring outside the user’s authorized time window(s)", - "concerns": "Door number", - }, - "DQ": { - "code": "DQ", - "type": "Access Denied - Unauthorized Arming State", - "description": "An access request was denied because the user was not authorized in this area when the area was armed", - "concerns": "Door number", - }, - "DR": { - "code": "DR", - "type": "Door Restoral", - "description": "Access alarm/trouble condition eliminated", - "concerns": "Door number", - }, - "DS": { - "code": "DS", - "type": "Door Station", - "description": "Identifies door for next report", - "concerns": "Door number", - }, - "DT": { - "code": "DT", - "type": "Access Trouble", - "description": "Access system trouble", - "concerns": "Unused", - }, - "DU": { - "code": "DU", - "type": "Dealer ID", - "description": "Dealer ID number", - "concerns": "Dealer ID", - }, - "DV": { - "code": "DV", - "type": "Access Denied - Unauthorized Entry Level", - "description": "An access request was denied because the user is not authorized in this area", - "concerns": "Door number", - }, - "DW": { - "code": "DW", - "type": "Access Denied - Interlock", - "description": "An access request was denied because the doors associated Interlock point is open", - "concerns": "Door number", - }, - "DX": { - "code": "DX", - "type": "Request to Exit", - "description": "An access point was opened via a Request to Exit device", - "concerns": "Door number", - }, - "DY": { - "code": "DY", - "type": "Door Locked", - "description": "The door’s lock has been engaged", - "concerns": "Door number", - }, - "DZ": { - "code": "DZ", - "type": "Access Denied - Door Secured", - "description": "An access request was denied because the door has been placed in an Access Closed state", - "concerns": "Door number", - }, - "EA": { - "code": "EA", - "type": "Exit Alarm", - "description": "An exit zone remained violated at the end of the exit delay period", - "concerns": "Zone or point", - }, - "EE": { - "code": "EE", - "type": "Exit Error", - "description": "An exit zone remained violated at the end of the exit delay period", - "concerns": "User number", - }, - "EJ": { - "code": "EJ", - "type": "Expansion Tamper Restore", - "description": "Expansion device tamper restoral", - "concerns": "Expansion device number", - }, - "EM": { - "code": "EM", - "type": "Expansion Device Missing", - "description": "Expansion device missing", - "concerns": "Expansion device number", - }, - "EN": { - "code": "EN", - "type": "Expansion Missing Restore", - "description": "Expansion device communications re-established", - "concerns": "Expansion device number", - }, - "ER": { - "code": "ER", - "type": "Expansion Restoral", - "description": "Expansion device trouble eliminated", - "concerns": "Expander number", - }, - "ES": { - "code": "ES", - "type": "Expansion Device Tamper", - "description": "Expansion device enclosure tamper", - "concerns": "Expansion device number", - }, - "ET": { - "code": "ET", - "type": "Expansion Trouble", - "description": "Expansion device trouble", - "concerns": "Expander number", - }, - "EX": { - "code": "EX", - "type": "External Device Condition", - "description": "A specific reportable condition is detected on an external device", - "concerns": "Device number", - }, - "EZ": { - "code": "EZ", - "type": "Missing Alarm - Exit Error", - "description": "A point remained missing at the end of the exit delay period", - "concerns": "Point number", - }, - "FA": { - "code": "FA", - "type": "Fire Alarm", - "description": "Fire condition detected", - "concerns": "Zone or point", - }, - "FB": { - "code": "FB", - "type": "Fire Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "FC": { - "code": "FC", - "type": "Fire Cancel", - "description": "A Fire Alarm has been cancelled by an authorized person", - "concerns": "Zone or point", - }, - "FG": { - "code": "FG", - "type": "Unverified Event – Fire", - "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", - "concerns": "Zone or point", - }, - "FH": { - "code": "FH", - "type": "Fire Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "FI": { - "code": "FI", - "type": "Fire Test Begin", - "description": "The transmitter area's fire test has begun", - "concerns": "Area number", - }, - "FJ": { - "code": "FJ", - "type": "Fire Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "FK": { - "code": "FK", - "type": "Fire Test End", - "description": "The transmitter area's fire test has ended", - "concerns": "Area number", - }, - "FL": { - "code": "FL", - "type": "Fire Alarm Silenced", - "description": "The fire panel’s sounder was silenced by command", - "concerns": "Zone or point", - }, - "FM": { - "code": "FM", - "type": "Fire Alarm - Cross Point", - "description": "Fire Alarm with Cross Point also in alarm verifying the Fire Alarm", - "concerns": "Point number", - }, - "FQ": { - "code": "FQ", - "type": "Fire Supervisory Trouble Restore", - "description": "A fire supervisory zone that was in trouble condition has now restored to normal", - "concerns": "Zone or point", - }, - "FR": { - "code": "FR", - "type": "Fire Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "FS": { - "code": "FS", - "type": "Fire Supervisory", - "description": "Unsafe fire detection system condition", - "concerns": "Zone or point", - }, - "FT": { - "code": "FT", - "type": "Fire Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "FU": { - "code": "FU", - "type": "Fire Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "FV": { - "code": "FV", - "type": "Fire Supervision Restore", - "description": "A fire supervision zone that was in alarm has restored to normal", - "concerns": "Zone or point", - }, - "FW": { - "code": "FW", - "type": "Fire Supervisory Trouble", - "description": "A fire supervisory zone is now in a trouble condition", - "concerns": "Zone or point", - }, - "FX": { - "code": "FX", - "type": "Fire Test", - "description": "Fire zone activated during test", - "concerns": "Zone or point", - }, - "FY": { - "code": "FY", - "type": "Missing Fire Trouble", - "description": "A fire point is now logically missing", - "concerns": "Zone or point", - }, - "FZ": { - "code": "FZ", - "type": "Missing Fire Supervision", - "description": "A Fire Supervisory point has gone missing", - "concerns": "Zone or point", - }, - "GA": { - "code": "GA", - "type": "Gas Alarm", - "description": "Gas alarm condition detected", - "concerns": "Zone or point", - }, - "GB": { - "code": "GB", - "type": "Gas Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "GH": { - "code": "GH", - "type": "Gas Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "GJ": { - "code": "GJ", - "type": "Gas Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "GR": { - "code": "GR", - "type": "Gas Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "GS": { - "code": "GS", - "type": "Gas Supervisory", - "description": "Unsafe gas detection system condition", - "concerns": "Zone or point", - }, - "GT": { - "code": "GT", - "type": "Gas Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "GU": { - "code": "GU", - "type": "Gas Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "GX": { - "code": "GX", - "type": "Gas Test", - "description": "Zone activated during test", - "concerns": "Zone or point", - }, - "HA": { - "code": "HA", - "type": "Holdup Alarm", - "description": "Silent alarm, user under duress", - "concerns": "Zone or point", - }, - "HB": { - "code": "HB", - "type": "Holdup Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "HH": { - "code": "HH", - "type": "Holdup Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "HJ": { - "code": "HJ", - "type": "Holdup Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "HR": { - "code": "HR", - "type": "Holdup Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "HS": { - "code": "HS", - "type": "Holdup Supervisory", - "description": "Unsafe holdup system condition", - "concerns": "Zone or point", - }, - "HT": { - "code": "HT", - "type": "Holdup Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "HU": { - "code": "HU", - "type": "Holdup Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "IA": { - "code": "IA", - "type": "Equipment Failure Condition", - "description": "A specific, reportable condition is detected on a device", - "concerns": "Point number", - }, - "IR": { - "code": "IR", - "type": "Equipment Fail - Restoral", - "description": "The equipment condition has been restored to normal", - "concerns": "Point number", - }, - "JA": { - "code": "JA", - "type": "User code Tamper", - "description": "Too many unsuccessful attempts have been made to enter a user ID", - "concerns": "Area number", - }, - "JD": { - "code": "JD", - "type": "Date Changed", - "description": "The date was changed in the transmitter/receiver", - "concerns": "User number", - }, - "JH": { - "code": "JH", - "type": "Holiday Changed", - "description": "The transmitter's holiday schedule has been changed", - "concerns": "User number", - }, - "JK": { - "code": "JK", - "type": "Latchkey Alert", - "description": "A designated user passcode has not been entered during a scheduled time window", - "concerns": "User number", - }, - "JL": { - "code": "JL", - "type": "Log Threshold", - "description": "The transmitter's log memory has reached its threshold level", - "concerns": "Unused", - }, - "JO": { - "code": "JO", - "type": "Log Overflow", - "description": "The transmitter's log memory has overflowed", - "concerns": "Unused", - }, - "JP": { - "code": "JP", - "type": "User On Premises", - "description": "A designated user passcode has been used to gain access to the premises.", - "concerns": "User number", - }, - "JR": { - "code": "JR", - "type": "Schedule Executed", - "description": "An automatic scheduled event was executed", - "concerns": "Area number", - }, - "JS": { - "code": "JS", - "type": "Schedule Changed", - "description": "An automatic schedule was changed", - "concerns": "User number", - }, - "JT": { - "code": "JT", - "type": "Time Changed", - "description": "The time was changed in the transmitter/receiver", - "concerns": "User number", - }, - "JV": { - "code": "JV", - "type": "User code Changed", - "description": "A user's code has been changed", - "concerns": "User number", - }, - "JX": { - "code": "JX", - "type": "User code Deleted", - "description": "A user's code has been removed", - "concerns": "User number", - }, - "JY": { - "code": "JY", - "type": "User code Added", - "description": "A user’s code has been added", - "concerns": "User number", - }, - "JZ": { - "code": "JZ", - "type": "User Level Set", - "description": "A user’s authority level has been set", - "concerns": "User number", - }, - "KA": { - "code": "KA", - "type": "Heat Alarm", - "description": "High temperature detected on premise", - "concerns": "Zone or point", - }, - "KB": { - "code": "KB", - "type": "Heat Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "KH": { - "code": "KH", - "type": "Heat Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "KJ": { - "code": "KJ", - "type": "Heat Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "KR": { - "code": "KR", - "type": "Heat Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "KS": { - "code": "KS", - "type": "Heat Supervisory", - "description": "Unsafe heat detection system condition", - "concerns": "Zone or point", - }, - "KT": { - "code": "KT", - "type": "Heat Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "KU": { - "code": "KU", - "type": "Heat Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "LB": { - "code": "LB", - "type": "Local Program", - "description": "Begin local programming", - "concerns": "Unused", - }, - "LD": { - "code": "LD", - "type": "Local Program Denied", - "description": "Access code incorrect", - "concerns": "Unused", - }, - "LE": { - "code": "LE", - "type": "Listen-in Ended", - "description": "The listen-in session has been terminated", - "concerns": "Unused", - }, - "LF": { - "code": "LF", - "type": "Listen-in Begin", - "description": "The listen-in session with the RECEIVER has begun", - "concerns": "Unused", - }, - "LR": { - "code": "LR", - "type": "Phone Line Restoral", - "description": "Phone line restored to service", - "concerns": "Line number", - }, - "LS": { - "code": "LS", - "type": "Local Program Success", - "description": "Local programming successful", - "concerns": "Unused", - }, - "LT": { - "code": "LT", - "type": "Phone Line Trouble", - "description": "Phone line trouble report", - "concerns": "Line number", - }, - "LU": { - "code": "LU", - "type": "Local Program Fail", - "description": "Local programming unsuccessful", - "concerns": "Unused", - }, - "LX": { - "code": "LX", - "type": "Local Programming Ended", - "description": "A local programming session has been terminated", - "concerns": "Unused", - }, - "MA": { - "code": "MA", - "type": "Medical Alarm", - "description": "Emergency assistance request", - "concerns": "Zone or point", - }, - "MB": { - "code": "MB", - "type": "Medical Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "MH": { - "code": "MH", - "type": "Medical Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "MI": { - "code": "MI", - "type": "Message", - "description": "A canned message is being sent", - "concerns": "Message number", - }, - "MJ": { - "code": "MJ", - "type": "Medical Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "MR": { - "code": "MR", - "type": "Medical Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "MS": { - "code": "MS", - "type": "Medical Supervisory", - "description": "Unsafe system condition exists", - "concerns": "Zone or point", - }, - "MT": { - "code": "MT", - "type": "Medical Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "MU": { - "code": "MU", - "type": "Medical Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "NA": { - "code": "NA", - "type": "No Activity", - "description": "There has been no zone activity for a programmed amount of time", - "concerns": "Zone number", - }, - "NC": { - "code": "NC", - "type": "Network Condition", - "description": "A communications network has a specific reportable condition", - "concerns": "Network number", - }, - "NF": { - "code": "NF", - "type": "Forced Perimeter Arm", - "description": "Some zones/points not ready", - "concerns": "Area number", - }, - "NL": { - "code": "NL", - "type": "Perimeter Armed", - "description": "An area has been perimeter armed", - "concerns": "Area number", - }, - "NM": { - "code": "NM", - "type": "Perimeter Armed, User Defined", - "description": "A user defined area has been perimeter armed", - "concerns": "Area number", - }, - "NR": { - "code": "NR", - "type": "Network Restoral", - "description": "A communications network has returned to normal operation", - "concerns": "Network number", - }, - "NS": { - "code": "NS", - "type": "Activity Resumed", - "description": "A zone has detected activity after an alert", - "concerns": "Zone number", - }, - "NT": { - "code": "NT", - "type": "Network Failure", - "description": "A communications network has failed", - "concerns": "Network number", - }, - "OA": { - "code": "OA", - "type": "Automatic Opening", - "description": "System has disarmed automatically", - "concerns": "Area number", - }, - "OC": { - "code": "OC", - "type": "Cancel Report", - "description": "Untyped zone cancel", - "concerns": "User number", - }, - "OG": { - "code": "OG", - "type": "Open Area", - "description": "System has been partially disarmed", - "concerns": "Area number", - }, - "OH": { - "code": "OH", - "type": "Early to Open from Alarm", - "description": "An area in alarm was disarmed before the opening window", - "concerns": "User number", - }, - "OI": { - "code": "OI", - "type": "Fail to Open", - "description": "An area has not been armed at the end of the opening window", - "concerns": "Area number", - }, - "OJ": { - "code": "OJ", - "type": "Late Open", - "description": "An area was disarmed after the opening window", - "concerns": "User number", - }, - "OK": { - "code": "OK", - "type": "Early Open", - "description": "An area was disarmed before the opening window", - "concerns": "User number", - }, - "OL": { - "code": "OL", - "type": "Late to Open from Alarm", - "description": "An area in alarm was disarmed after the opening window", - "concerns": "User number", - }, - "OP": { - "code": "OP", - "type": "Opening Report", - "description": "Account was disarmed", - "concerns": "User number", - }, - "OQ": { - "code": "OQ", - "type": "Remote Opening", - "description": "The system was disarmed from a remote location", - "concerns": "User number", - }, - "OR": { - "code": "OR", - "type": "Disarm From Alarm", - "description": "Account in alarm was reset/disarmed", - "concerns": "User number", - }, - "OS": { - "code": "OS", - "type": "Opening Keyswitch", - "description": "Account has been disarmed by keyswitch", - "concerns": "Zone or point", - }, - "OT": { - "code": "OT", - "type": "Late To Close", - "description": "System was not armed on time", - "concerns": "User number", - }, - "OU": { - "code": "OU", - "type": "Output State – Trouble", - "description": "An output on a peripheral device or NAC is not functioning", - "concerns": "Output number", - }, - "OV": { - "code": "OV", - "type": "Output State – Restore", - "description": "An output on a peripheral device or NAC is back to normal operation", - "concerns": "Output number", - }, - "OZ": { - "code": "OZ", - "type": "Point Opening", - "description": "A point, rather than a full area or account, disarmed", - "concerns": "Zone or point", - }, - "PA": { - "code": "PA", - "type": "Panic Alarm", - "description": "Emergency assistance request, manually activated", - "concerns": "Zone or point", - }, - "PB": { - "code": "PB", - "type": "Panic Bypass", - "description": "Panic zone has been bypassed", - "concerns": "Zone or point", - }, - "PH": { - "code": "PH", - "type": "Panic Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "PJ": { - "code": "PJ", - "type": "Panic Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "PR": { - "code": "PR", - "type": "Panic Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "PS": { - "code": "PS", - "type": "Panic Supervisory", - "description": "Unsafe system condition exists", - "concerns": "Zone or point", - }, - "PT": { - "code": "PT", - "type": "Panic Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "PU": { - "code": "PU", - "type": "Panic Unbypass", - "description": "Panic zone bypass has been removed", - "concerns": "Zone or point", - }, - "QA": { - "code": "QA", - "type": "Emergency Alarm", - "description": "Emergency assistance request", - "concerns": "Zone or point", - }, - "QB": { - "code": "QB", - "type": "Emergency Bypass", - "description": "Zone has been bypassed", - "concerns": "Zone or point", - }, - "QH": { - "code": "QH", - "type": "Emergency Alarm Restore", - "description": "Alarm condition has been eliminated", - "concerns": "Zone or point", - }, - "QJ": { - "code": "QJ", - "type": "Emergency Trouble Restore", - "description": "Trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "QR": { - "code": "QR", - "type": "Emergency Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "QS": { - "code": "QS", - "type": "Emergency Supervisory", - "description": "Unsafe system condition exists", - "concerns": "Zone or point", - }, - "QT": { - "code": "QT", - "type": "Emergency Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "QU": { - "code": "QU", - "type": "Emergency Unbypass", - "description": "Bypass has been removed", - "concerns": "Zone or point", - }, - "RA": { - "code": "RA", - "type": "Remote Programmer Call Failed", - "description": "Transmitter failed to communicate with the remote programmer", - "concerns": "Unused", - }, - "RB": { - "code": "RB", - "type": "Remote Program Begin", - "description": "Remote programming session initiated", - "concerns": "Unused", - }, - "RC": { - "code": "RC", - "type": "Relay Close", - "description": "A relay has energized", - "concerns": "Relay number", - }, - "RD": { - "code": "RD", - "type": "Remote Program Denied", - "description": "Access passcode incorrect", - "concerns": "Unused", - }, - "RN": { - "code": "RN", - "type": "Remote Reset", - "description": "A TRANSMITTER was reset via a remote programmer", - "concerns": "Unused", - }, - "RO": { - "code": "RO", - "type": "Relay Open", - "description": "A relay has de-energized", - "concerns": "Relay number", - }, - "RP": { - "code": "RP", - "type": "Automatic Test", - "description": "Automatic communication test report", - "concerns": "Unused", - }, - "RR": { - "code": "RR", - "type": "Power Up", - "description": "System lost power, is now restored", - "concerns": "Unused", - }, - "RS": { - "code": "RS", - "type": "Remote Program Success", - "description": "Remote programming successful", - "concerns": "Unused", - }, - "RT": { - "code": "RT", - "type": "Data Lost", - "description": "Dialer data lost, transmission error", - "concerns": "Line number", - }, - "RU": { - "code": "RU", - "type": "Remote Program Fail", - "description": "Remote programming unsuccessful", - "concerns": "Unused", - }, - "RX": { - "code": "RX", - "type": "Manual Test", - "description": "Manual communication test report", - "concerns": "User number", - }, - "RY": { - "code": "RY", - "type": "Test Off Normal", - "description": "Test signal(s) indicates abnormal condition(s) exist", - "concerns": "Zone or point", - }, - "SA": { - "code": "SA", - "type": "Sprinkler Alarm", - "description": "Sprinkler flow condition exists", - "concerns": "Zone or point", - }, - "SB": { - "code": "SB", - "type": "Sprinkler Bypass", - "description": "Sprinkler zone has been bypassed", - "concerns": "Zone or point", - }, - "SC": { - "code": "SC", - "type": "Change of State", - "description": "An expansion/peripheral device is reporting a new condition or state change", - "concerns": "Condition number", - }, - "SH": { - "code": "SH", - "type": "Sprinkler Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "SJ": { - "code": "SJ", - "type": "Sprinkler Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "SR": { - "code": "SR", - "type": "Sprinkler Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "SS": { - "code": "SS", - "type": "Sprinkler Supervisory", - "description": "Unsafe sprinkler system condition", - "concerns": "Zone or point", - }, - "ST": { - "code": "ST", - "type": "Sprinkler Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "SU": { - "code": "SU", - "type": "Sprinkler Unbypass", - "description": "Sprinkler zone bypass has been removed", - "concerns": "Zone or point", - }, - "TA": { - "code": "TA", - "type": "Tamper Alarm", - "description": "Alarm equipment enclosure opened", - "concerns": "Zone or point", - }, - "TB": { - "code": "TB", - "type": "Tamper Bypass", - "description": "Tamper detection has been bypassed", - "concerns": "Zone or point", - }, - "TC": { - "code": "TC", - "type": "All Points Tested", - "description": "All point tested", - "concerns": "Unused", - }, - "TE": { - "code": "TE", - "type": "Test End", - "description": "Communicator restored to operation", - "concerns": "Unused", - }, - "TH": { - "code": "TH", - "type": "Tamper Alarm Restore", - "description": "An Expansion Device’s tamper switch restores to normal from an Alarm state", - "concerns": "Unused", - }, - "TJ": { - "code": "TJ", - "type": "Tamper Trouble Restore", - "description": "An Expansion Device’s tamper switch restores to normal from a Trouble state", - "concerns": "Unused", - }, - "TP": { - "code": "TP", - "type": "Walk Test Point", - "description": "This point was tested during a Walk Test", - "concerns": "Point number", - }, - "TR": { - "code": "TR", - "type": "Tamper Restoral", - "description": "Alarm equipment enclosure has been closed", - "concerns": "Zone or point", - }, - "TS": { - "code": "TS", - "type": "Test Start", - "description": "Communicator taken out of operation", - "concerns": "Unused", - }, - "TT": { - "code": "TT", - "type": "Tamper Trouble", - "description": "Equipment enclosure opened in disarmed state", - "concerns": "Zone or point", - }, - "TU": { - "code": "TU", - "type": "Tamper Unbypass", - "description": "Tamper detection bypass has been removed", - "concerns": "Zone or point", - }, - "TW": { - "code": "TW", - "type": "Area Watch Start", - "description": "Area watch feature has been activated", - "concerns": "Unused", - }, - "TX": { - "code": "TX", - "type": "Test Report", - "description": "An unspecified (manual or automatic) communicator test", - "concerns": "Unused", - }, - "TZ": { - "code": "TZ", - "type": "Area Watch End", - "description": "Area watch feature has been deactivated", - "concerns": "Unused", - }, - "UA": { - "code": "UA", - "type": "Untyped Zone Alarm", - "description": "Alarm condition from zone of unknown type", - "concerns": "Zone or point", - }, - "UB": { - "code": "UB", - "type": "Untyped Zone Bypass", - "description": "Zone of unknown type has been bypassed", - "concerns": "Zone or point", - }, - "UG": { - "code": "UG", - "type": "Unverified Event – Untyped", - "description": "A point assigned to a Cross Point group has gone into alarm but the Cross Point remained normal", - "concerns": "Zone or point", - }, - "UH": { - "code": "UH", - "type": "Untyped Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "UJ": { - "code": "UJ", - "type": "Untyped Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "UR": { - "code": "UR", - "type": "Untyped Zone Restoral", - "description": "Alarm/trouble condition eliminated from zone of unknown type", - "concerns": "Zone or point", - }, - "US": { - "code": "US", - "type": "Untyped Zone Supervisory", - "description": "Unsafe condition from zone of unknown type", - "concerns": "Zone or point", - }, - "UT": { - "code": "UT", - "type": "Untyped Zone Trouble", - "description": "Trouble condition from zone of unknown type", - "concerns": "Zone or point", - }, - "UU": { - "code": "UU", - "type": "Untyped Zone Unbypass", - "description": "Bypass on zone of unknown type has been removed", - "concerns": "Zone or point", - }, - "UX": { - "code": "UX", - "type": "Undefined", - "description": "An undefined alarm condition has occurred", - "concerns": "Unused", - }, - "UY": { - "code": "UY", - "type": "Untyped Missing Trouble", - "description": "A point or device which was not armed is now logically missing", - "concerns": "Zone or point", - }, - "UZ": { - "code": "UZ", - "type": "Untyped Missing Alarm", - "description": "A point or device which was armed is now logically missing", - "concerns": "Zone or point", - }, - "VI": { - "code": "VI", - "type": "Printer Paper In", - "description": "TRANSMITTER or RECEIVER paper in", - "concerns": "Printer number", - }, - "VO": { - "code": "VO", - "type": "Printer Paper Out", - "description": "TRANSMITTER or RECEIVER paper out", - "concerns": "Printer number", - }, - "VR": { - "code": "VR", - "type": "Printer Restore", - "description": "TRANSMITTER or RECEIVER trouble restored", - "concerns": "Printer number", - }, - "VT": { - "code": "VT", - "type": "Printer Trouble", - "description": "TRANSMITTER or RECEIVER trouble", - "concerns": "Printer number", - }, - "VX": { - "code": "VX", - "type": "Printer Test", - "description": "TRANSMITTER or RECEIVER test", - "concerns": "Printer number", - }, - "VY": { - "code": "VY", - "type": "Printer Online", - "description": "RECEIVER’S printer is now online", - "concerns": "Unused", - }, - "VZ": { - "code": "VZ", - "type": "Printer Offline", - "description": "RECEIVER’S printer is now offline", - "concerns": "Unused", - }, - "WA": { - "code": "WA", - "type": "Water Alarm", - "description": "Water detected at protected premises", - "concerns": "Zone or point", - }, - "WB": { - "code": "WB", - "type": "Water Bypass", - "description": "Water detection has been bypassed", - "concerns": "Zone or point", - }, - "WH": { - "code": "WH", - "type": "Water Alarm Restore", - "description": "Water alarm condition eliminated", - "concerns": "Zone or point", - }, - "WJ": { - "code": "WJ", - "type": "Water Trouble Restore", - "description": "Water trouble condition eliminated", - "concerns": "Zone or point", - }, - "WR": { - "code": "WR", - "type": "Water Restoral", - "description": "Water alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "WS": { - "code": "WS", - "type": "Water Supervisory", - "description": "Water unsafe water detection system condition", - "concerns": "Zone or point", - }, - "WT": { - "code": "WT", - "type": "Water Trouble", - "description": "Water zone disabled by fault", - "concerns": "Zone or point", - }, - "WU": { - "code": "WU", - "type": "Water Unbypass", - "description": "Water detection bypass has been removed", - "concerns": "Zone or point", - }, - "XA": { - "code": "XA", - "type": "Extra Account Report", - "description": "CS RECEIVER has received an event from a non-existent account", - "concerns": "Unused", - }, - "XE": { - "code": "XE", - "type": "Extra Point", - "description": "Panel has sensed an extra point not specified for this site", - "concerns": "Point number", - }, - "XF": { - "code": "XF", - "type": "Extra RF Point", - "description": "Panel has sensed an extra RF point not specified for this site", - "concerns": "Point number", - }, - "XH": { - "code": "XH", - "type": "RF Interference Restoral", - "description": "A radio device is no longer detecting RF Interference", - "concerns": "Receiver number", - }, - "XI": { - "code": "XI", - "type": "Sensor Reset", - "description": "A user has reset a sensor", - "concerns": "Zone or point", - }, - "XJ": { - "code": "XJ", - "type": "RF Receiver Tamper Restoral", - "description": "A Tamper condition at a premises RF Receiver has been restored", - "concerns": "Receiver number", - }, - "XL": { - "code": "XL", - "type": "Low Received Signal Strength", - "description": "The RF signal strength of a reported event is below minimum level", - "concerns": "Receiver number", - }, - "XM": { - "code": "XM", - "type": "Missing Alarm - Cross Point", - "description": "Missing Alarm verified by Cross Point in Alarm (or missing)", - "concerns": "Zone or point", - }, - "XQ": { - "code": "XQ", - "type": "RF Interference", - "description": "A radio device is detecting RF Interference", - "concerns": "Receiver number", - }, - "XR": { - "code": "XR", - "type": "Transmitter Battery Restoral", - "description": "Low battery has been corrected", - "concerns": "Zone or point", - }, - "XS": { - "code": "XS", - "type": "RF Receiver Tamper", - "description": "A Tamper condition at a premises receiver is detected", - "concerns": "Receiver number", - }, - "XT": { - "code": "XT", - "type": "Transmitter Battery Trouble", - "description": "Low battery in wireless transmitter", - "concerns": "Zone or point", - }, - "XW": { - "code": "XW", - "type": "Forced Point", - "description": "A point was forced out of the system at arm time", - "concerns": "Zone or point", - }, - "XX": { - "code": "XX", - "type": "Fail to Test", - "description": "A specific test from a panel was not received", - "concerns": "Unused", - }, - "YA": { - "code": "YA", - "type": "Bell Fault", - "description": "A trouble condition has been detected on a Local Bell, Siren, or Annunciator", - "concerns": "Unused", - }, - "YB": { - "code": "YB", - "type": "Busy Seconds", - "description": "Percent of time receiver's line card is on-line", - "concerns": "Line card number", - }, - "YC": { - "code": "YC", - "type": "Communications Fail", - "description": "RECEIVER and TRANSMITTER", - "concerns": "Unused", - }, - "YD": { - "code": "YD", - "type": "Receiver Line Card Trouble", - "description": "A line card identified by the passed address is in trouble", - "concerns": "Line card number", - }, - "YE": { - "code": "YE", - "type": "Receiver Line Card Restored", - "description": "A line card identified by the passed address is restored", - "concerns": "Line card number", - }, - "YF": { - "code": "YF", - "type": "Parameter Checksum Fail", - "description": "System data corrupted", - "concerns": "Unused", - }, - "YG": { - "code": "YG", - "type": "Parameter Changed", - "description": "A TRANSMITTER’S parameters have been changed", - "concerns": "Unused", - }, - "YH": { - "code": "YH", - "type": "Bell Restored", - "description": "A trouble condition has been restored on a Local Bell, Siren, or Annunciator", - "concerns": "Unused", - }, - "YI": { - "code": "YI", - "type": "Overcurrent Trouble", - "description": "An Expansion Device has detected an overcurrent condition", - "concerns": "Unused", - }, - "YJ": { - "code": "YJ", - "type": "Overcurrent Restore", - "description": "An Expansion Device has restored from an overcurrent condition", - "concerns": "Unused", - }, - "YK": { - "code": "YK", - "type": "Communications Restoral", - "description": "TRANSMITTER has resumed communication with a RECEIVER", - "concerns": "Unused", - }, - "YM": { - "code": "YM", - "type": "System Battery Missing", - "description": "TRANSMITTER/RECEIVER battery is missing", - "concerns": "Unused", - }, - "YN": { - "code": "YN", - "type": "Invalid Report", - "description": "TRANSMITTER has sent a packet with invalid data", - "concerns": "Unused", - }, - "YO": { - "code": "YO", - "type": "Unknown Message", - "description": "An unknown message was received from automation or the printer", - "concerns": "Unused", - }, - "YP": { - "code": "YP", - "type": "Power Supply Trouble", - "description": "TRANSMITTER/RECEIVER has a problem with the power supply", - "concerns": "Unused", - }, - "YQ": { - "code": "YQ", - "type": "Power Supply Restored", - "description": "TRANSMITTER’S/RECEIVER’S power supply has been restored", - "concerns": "Unused", - }, - "YR": { - "code": "YR", - "type": "System Battery Restoral", - "description": "Low battery has been corrected", - "concerns": "Unused", - }, - "YS": { - "code": "YS", - "type": "Communications Trouble", - "description": "RECEIVER and TRANSMITTER", - "concerns": "Unused", - }, - "YT": { - "code": "YT", - "type": "System Battery Trouble", - "description": "Low battery in control/communicator", - "concerns": "Unused", - }, - "YU": { - "code": "YU", - "type": "Diagnostic Error", - "description": "An expansion/peripheral device is reporting a diagnostic error", - "concerns": "Condition number", - }, - "YW": { - "code": "YW", - "type": "Watchdog Reset", - "description": "The TRANSMITTER created an internal reset", - "concerns": "Unused", - }, - "YX": { - "code": "YX", - "type": "Service Required", - "description": "A TRANSMITTER/RECEIVER needs service", - "concerns": "Unused", - }, - "YY": { - "code": "YY", - "type": "Status Report", - "description": "This is a header for an account status report transmission", - "concerns": "Unused", - }, - "YZ": { - "code": "YZ", - "type": "Service Completed", - "description": "Required TRANSMITTER / RECEIVER service completed", - "concerns": "Mfr defined", - }, - "ZA": { - "code": "ZA", - "type": "Freeze Alarm", - "description": "Low temperature detected at premises", - "concerns": "Zone or point", - }, - "ZB": { - "code": "ZB", - "type": "Freeze Bypass", - "description": "Low temperature detection has been bypassed", - "concerns": "Zone or point", - }, - "ZH": { - "code": "ZH", - "type": "Freeze Alarm Restore", - "description": "Alarm condition eliminated", - "concerns": "Zone or point", - }, - "ZJ": { - "code": "ZJ", - "type": "Freeze Trouble Restore", - "description": "Trouble condition eliminated", - "concerns": "Zone or point", - }, - "ZR": { - "code": "ZR", - "type": "Freeze Restoral", - "description": "Alarm/trouble condition has been eliminated", - "concerns": "Zone or point", - }, - "ZS": { - "code": "ZS", - "type": "Freeze Supervisory", - "description": "Unsafe freeze detection system condition", - "concerns": "Zone or point", - }, - "ZT": { - "code": "ZT", - "type": "Freeze Trouble", - "description": "Zone disabled by fault", - "concerns": "Zone or point", - }, - "ZU": { - "code": "ZU", - "type": "Freeze Unbypass", - "description": "Low temperature detection bypass removed", - "concerns": "Zone or point", - }, - } diff --git a/custom_components/sia/strings.json b/custom_components/sia/strings.json new file mode 100644 index 0000000..1eed82a --- /dev/null +++ b/custom_components/sia/strings.json @@ -0,0 +1,31 @@ +{ + "title": "SIA Alarm Systems", + "config": { + "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", + "additional_account": "Add more accounts?" + }, + "title": "Create a connection for SIA DC-09 based alarm systems." + } + }, + "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" + }, + "abort": { + "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." + } + } +} diff --git a/custom_components/sia/translations/en.json b/custom_components/sia/translations/en.json new file mode 100644 index 0000000..9cc5202 --- /dev/null +++ b/custom_components/sia/translations/en.json @@ -0,0 +1,31 @@ +{ + "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", + "additional_account": "Add more accounts?" + }, + "title": "Create a connection for SIA DC-09 based alarm systems." + } + } + }, + "title": "SIA Alarm Systems" +} \ No newline at end of file From 2c6eeb6ec1d073df31034f540006f50f7d955309 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 11 Jun 2020 10:17:50 +0200 Subject: [PATCH 23/63] small fixes in line with official PR --- custom_components/sia/__init__.py | 58 +++++++++++-------- custom_components/sia/alarm_control_panel.py | 39 ++++++++----- custom_components/sia/binary_sensor.py | 40 ++++++++----- custom_components/sia/config_flow.py | 10 ++-- custom_components/sia/const.py | 60 ++++++++------------ custom_components/sia/reactions.json | 29 ++++++++++ custom_components/sia/sensor.py | 45 ++++++++------- 7 files changed, 167 insertions(+), 114 deletions(-) create mode 100644 custom_components/sia/reactions.json diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 7c4bf9e..bd98da8 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -5,16 +5,22 @@ from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PORT, CONF_SENSORS, CONF_ZONE, + DEVICE_CLASS_TIMESTAMP, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.util.dt import utcnow +from homeassistant.util.json import load_json from .alarm_control_panel import SIAAlarmControlPanel from .binary_sensor import SIABinarySensor @@ -25,9 +31,6 @@ CONF_PING_INTERVAL, CONF_ZONES, DEVICE_CLASS_ALARM, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_TIMESTAMP, DOMAIN, HUB_SENSOR_NAME, HUB_ZONE, @@ -84,7 +87,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class SIAHub: """Class for SIA Hubs.""" - def __init__(self, hass, hub_config, entry_id, title): + def __init__( + self, hass: HomeAssistant, hub_config: dict, entry_id: str, title: str + ): """Create the SIAHub.""" self._hass = hass self.states = {} @@ -93,6 +98,7 @@ def __init__(self, hass, hub_config, entry_id, title): self._title = title self._accounts = hub_config[CONF_ACCOUNTS] self.shutdown_remove_listener = None + self._reactions = REACTIONS self._zones = [ { @@ -134,7 +140,7 @@ def __init__(self, hass, hub_config, entry_id, title): ) async def async_setup_hub(self): - """Add a device to the device_registry and register shutdown listener.""" + """Add a device to the device_registry, register shutdown listener, load reactions.""" device_registry = await dr.async_get_registry(self._hass) port = self._port for acc in self._accounts: @@ -144,22 +150,24 @@ async def async_setup_hub(self): identifiers={(DOMAIN, port, account)}, name=f"{port} - {account}", ) - self.shutdown_remove_listener = self.hass.bus.async_listen_once( + self.shutdown_remove_listener = self._hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self.async_shutdown ) - async def async_shutdown(self): + async def async_shutdown(self, _: Event): """Shutdown the SIA server.""" await self.sia_client.stop() - def _create_sensor(self, port, account, zone, entity_type, ping): + def _create_sensor( + self, port: int, account: str, zone: int, entity_type: str, ping: int + ): """Check if the entity exists, and creates otherwise.""" entity_id, entity_name = self._get_entity_id_and_name( account, zone, entity_type ) if entity_type == DEVICE_CLASS_ALARM: new_entity = SIAAlarmControlPanel( - entity_id, entity_name, port, account, zone, ping, self._hass, + entity_id, entity_name, port, account, zone, ping, self._hass ) elif entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): new_entity = SIABinarySensor( @@ -185,31 +193,31 @@ def _create_sensor(self, port, account, zone, entity_type, ping): ) self.states[entity_id] = new_entity - def _get_entity_id_and_name(self, account, zone=0, entity_type=None): + def _get_entity_id_and_name( + self, account: str, zone: int = 0, entity_type: str = None + ): """Give back a entity_id and name according to the variables.""" if zone == 0: return ( self._get_entity_id(account, zone, entity_type), f"{self._port} - {account} - Last Heartbeat", ) - else: - if entity_type: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - zone {zone} - {entity_type}", - ) - return None + if entity_type: + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - zone {zone} - {entity_type}", + ) + return None - def _get_entity_id(self, account, zone=0, entity_type=None): + def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: return f"{self._port}_{account}_{HUB_SENSOR_NAME}" - else: - if entity_type: - return f"{self._port}_{account}_{zone}_{entity_type}" - return None + if entity_type: + return f"{self._port}_{account}_{zone}_{entity_type}" + return None - def _get_ping_interval(self, account): + def _get_ping_interval(self, account: str): """Return the ping interval for specified account.""" for acc in self._accounts: if acc[CONF_ACCOUNT] == account: @@ -223,7 +231,7 @@ async def update_states(self, event: SIAEvent): """ # find the reactions for that code (if any) - reaction = REACTIONS.get(event.code) + reaction = self._reactions.get(event.code) if not reaction: _LOGGER.warning( "Unhandled event code: %s, Message: %s, Full event: %s", diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 70005b5..fac07f2 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,11 +1,13 @@ """Module for SIA Alarm Control Panels.""" import logging +from typing import Callable from homeassistant.components.alarm_control_panel import ( ENTITY_ID_FORMAT as ALARM_FORMAT, AlarmControlPanelEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ZONE, STATE_ALARM_ARMED_AWAY, @@ -14,7 +16,7 @@ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity @@ -32,7 +34,9 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_devices): +async def async_setup_entry( + hass, entry: ConfigEntry, async_add_devices: Callable[[], None] +) -> bool: """Set up sia_alarm_control_panel from a config entry.""" async_add_devices( [ @@ -48,7 +52,16 @@ async def async_setup_entry(hass, entry, async_add_devices): class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): """Class for SIA Alarm Control Panels.""" - def __init__(self, entity_id, name, port, account, zone, ping_interval, hass): + def __init__( + self, + entity_id: str, + name: str, + port: int, + account: str, + zone: int, + ping_interval: int, + hass: HomeAssistant, + ): """Create SIAAlarmControlPanel object.""" self.entity_id = ALARM_FORMAT.format(entity_id) self._unique_id = entity_id @@ -99,22 +112,22 @@ def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) @property - def name(self): + def name(self) -> str: """Get Name.""" return self._name @property - def ping_interval(self): + def ping_interval(self) -> int: """Get ping_interval.""" return str(self._ping_interval) @property - def state(self): + def state(self) -> str: """Get state.""" return self._state @property - def account(self): + def account(self) -> str: """Return device account.""" return self._account @@ -124,17 +137,17 @@ def unique_id(self) -> str: return self._unique_id @property - def available(self): + def available(self) -> bool: """Get availability.""" return self._is_available @property - def device_state_attributes(self): + def device_state_attributes(self) -> dict: """Return device attributes.""" return self._attr @state.setter - def state(self, state): + def state(self, state: str): """Set state.""" temp = self._old_state if state == PREVIOUS_STATE else state self._old_state = self._state @@ -146,7 +159,7 @@ async def assume_available(self): await self._async_track_unavailable() @callback - async def _async_track_unavailable(self): + async def _async_track_unavailable(self) -> bool: """Reset unavailability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() @@ -161,7 +174,7 @@ async def _async_track_unavailable(self): return False @callback - def _async_set_unavailable(self, now): + def _async_set_unavailable(self, _): """Set availability.""" self._remove_unavailability_tracker = None self._is_available = False @@ -173,7 +186,7 @@ def supported_features(self) -> int: return None @property - def device_info(self): + def device_info(self) -> dict: """Return the device_info.""" return { "identifiers": {(DOMAIN, self.unique_id)}, diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 8d716f0..ed4bd37 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,13 +1,15 @@ """Module for SIA Binary Sensors.""" import logging +from typing import Callable from homeassistant.components.binary_sensor import ( ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity @@ -24,7 +26,9 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_devices): +async def async_setup_entry( + hass, entry: ConfigEntry, async_add_devices: Callable[[], None] +) -> bool: """Set up sia_binary_sensor from a config entry.""" async_add_devices( [ @@ -41,7 +45,15 @@ class SIABinarySensor(BinarySensorEntity, RestoreEntity): """Class for SIA Binary Sensors.""" def __init__( - self, entity_id, name, device_class, port, account, zone, ping_interval, hass + self, + entity_id: str, + name: str, + device_class: str, + port: int, + account: str, + zone: int, + ping_interval: int, + hass: HomeAssistant, ): """Create SIABinarySensor object.""" @@ -85,12 +97,12 @@ def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) @property - def name(self): + def name(self) -> str: """Return name.""" return self._name @property - def ping_interval(self): + def ping_interval(self) -> int: """Get ping_interval.""" return str(self._ping_interval) @@ -100,39 +112,39 @@ def unique_id(self) -> str: return self._unique_id @property - def account(self): + def account(self) -> str: """Return device account.""" return self._account @property - def available(self): + def available(self) -> bool: """Return avalability.""" return self._is_available @property - def device_state_attributes(self): + def device_state_attributes(self) -> dict: """Return attributes.""" return self._attr @property - def device_class(self): + def device_class(self) -> str: """Return device class.""" return self._device_class @property - def state(self): + def state(self) -> str: """Return the state of the binary sensor.""" if self.is_on is None: return STATE_UNKNOWN return STATE_ON if self.is_on else STATE_OFF @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self._is_on @state.setter - def state(self, new_on): + def state(self, new_on: bool): """Set state.""" self._is_on = new_on self.async_schedule_update_ha_state() @@ -142,7 +154,7 @@ async def assume_available(self): await self._async_track_unavailable() @callback - async def _async_track_unavailable(self): + async def _async_track_unavailable(self) -> bool: """Track availability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() @@ -164,7 +176,7 @@ def _async_set_unavailable(self, now): self.async_schedule_update_ha_state() @property - def device_info(self): + def device_info(self) -> dict: """Return the device_info.""" return { "identifiers": {(DOMAIN, self.unique_id)}, diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 67827ca..4398c01 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -49,7 +49,7 @@ ) -def validate_input(data): +def validate_input(data: dict) -> bool: """Validate the input by the user.""" SIAAccount(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) @@ -74,7 +74,7 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH data = None - async def async_step_add_account(self, user_input=None): + async def async_step_add_account(self, user_input: dict = None): """Handle the additional accounts steps.""" errors = {} if user_input is not None: @@ -102,7 +102,7 @@ async def async_step_add_account(self, user_input=None): step_id="user", data_schema=ACCOUNT_SCHEMA, errors=errors, ) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict = None): """Handle the initial step.""" errors = {} if user_input is not None: @@ -128,13 +128,13 @@ async def async_step_user(self, user_input=None): self.data[CONF_ACCOUNTS].append(add_data) await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") self._abort_if_unique_id_configured() + if not user_input[CONF_ADDITIONAL_ACCOUNTS]: return self.async_create_entry( title=f"SIA Alarm on port {self.data[CONF_PORT]}", data=self.data, ) - else: - return await self.async_step_add_account() + return await self.async_step_add_account() except InvalidKeyFormatError: errors["base"] = "invalid_key_format" except InvalidKeyLengthError: diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index bf78c48..6a1d701 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -5,20 +5,8 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - DOMAIN as BINARY_SENSOR_DOMAIN, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - DEVICE_CLASS_TIMESTAMP, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, -) CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" @@ -33,32 +21,32 @@ HUB_SENSOR_NAME = "last_heartbeat" HUB_ZONE = 0 PING_INTERVAL_MARGIN = timedelta(seconds=30) -PREVIOUS_STATE = "PREVIOUS_STATE" +PREVIOUS_STATE = "previous_state" UTCNOW = "utcnow" -LAST_MESSAGE = "lastmessage" +LAST_MESSAGE = "last_message" PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { - "BA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, - "BR": {"type": DEVICE_CLASS_ALARM, "new_state": PREVIOUS_STATE}, - "CA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CF": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_CUSTOM_BYPASS}, - "CG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "CQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_AWAY}, - "GA": {"type": DEVICE_CLASS_SMOKE, "new_state": True}, - "GH": {"type": DEVICE_CLASS_SMOKE, "new_state": False}, - "NL": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_ARMED_NIGHT}, - "OA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OG": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OP": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OQ": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "OR": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_DISARMED}, - "RP": {"type": DEVICE_CLASS_TIMESTAMP, "new_state_eval": UTCNOW}, - "TA": {"type": DEVICE_CLASS_ALARM, "new_state": STATE_ALARM_TRIGGERED}, - "WA": {"type": DEVICE_CLASS_MOISTURE, "new_state": True}, - "WH": {"type": DEVICE_CLASS_MOISTURE, "new_state": False}, - "YG": {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE}, + "BA": {"type": "alarm", "new_state": "triggered"}, + "BR": {"type": "alarm", "new_state": "previous_state"}, + "CA": {"type": "alarm", "new_state": "armed_away"}, + "CF": {"type": "alarm", "new_state": "armed_custom_bypass"}, + "CG": {"type": "alarm", "new_state": "armed_away"}, + "CL": {"type": "alarm", "new_state": "armed_away"}, + "CP": {"type": "alarm", "new_state": "armed_away"}, + "CQ": {"type": "alarm", "new_state": "armed_away"}, + "GA": {"type": "smoke", "new_state": True}, + "GH": {"type": "smoke", "new_state": False}, + "NL": {"type": "alarm", "new_state": "armed_night"}, + "OA": {"type": "alarm", "new_state": "disarmed"}, + "OG": {"type": "alarm", "new_state": "disarmed"}, + "OP": {"type": "alarm", "new_state": "disarmed"}, + "OQ": {"type": "alarm", "new_state": "disarmed"}, + "OR": {"type": "alarm", "new_state": "disarmed"}, + "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, + "TA": {"type": "alarm", "new_state": "triggered"}, + "WA": {"type": "moisture", "new_state": True}, + "WH": {"type": "moisture", "new_state": False}, + "YG": {"type": "timestamp", "attr": "last_message"}, } diff --git a/custom_components/sia/reactions.json b/custom_components/sia/reactions.json new file mode 100644 index 0000000..1d2b071 --- /dev/null +++ b/custom_components/sia/reactions.json @@ -0,0 +1,29 @@ +{ + "BA": { "type": "alarm", "new_state": "triggered" }, + "BR": { "type": "alarm", "new_state": "previous_state" }, + "CA": { "type": "alarm", "new_state": "armed_away" }, + "CF": { + "type": "alarm", + "new_state": "armed_custom_bypass" + }, + "CG": { "type": "alarm", "new_state": "armed_away" }, + "CL": { "type": "alarm", "new_state": "armed_away" }, + "CP": { "type": "alarm", "new_state": "armed_away" }, + "CQ": { "type": "alarm", "new_state": "armed_away" }, + "GA": { "type": "smoke", "new_state": true }, + "GH": { "type": "smoke", "new_state": false }, + "NL": { + "type": "alarm", + "new_state": "armed_night" + }, + "OA": { "type": "alarm", "new_state": "disarmed" }, + "OG": { "type": "alarm", "new_state": "disarmed" }, + "OP": { "type": "alarm", "new_state": "disarmed" }, + "OQ": { "type": "alarm", "new_state": "disarmed" }, + "OR": { "type": "alarm", "new_state": "disarmed" }, + "RP": { "type": "timestamp", "new_state_eval": "utcnow" }, + "TA": { "type": "alarm", "new_state": "triggered" }, + "WA": { "type": "moisture", "new_state": true }, + "WH": { "type": "moisture", "new_state": false }, + "YG": { "type": "timestamp", "attr": "last_message" } +} diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index f08f605..a56877a 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -2,10 +2,12 @@ import datetime as dt import logging +from typing import Callable from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ZONE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow @@ -15,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_devices): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] +) -> bool: """Set up sia_sensor from a config entry.""" async_add_devices( [ @@ -33,15 +37,14 @@ class SIASensor(RestoreEntity): def __init__( self, - entity_id, - name, - device_class, - port, - account, - zone, - ping_interval, - hass - # self, entity_id, name, zone, account, ping_interval, hass, + entity_id: str, + name: str, + device_class: str, + port: int, + account: str, + zone: int, + ping_interval: int, + hass: HomeAssistant, ): """Create SIASensor object.""" self.entity_id = SENSOR_FORMAT.format(entity_id) @@ -80,7 +83,7 @@ def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) @property - def name(self): + def name(self) -> str: """Return name.""" return self._name @@ -90,47 +93,47 @@ def unique_id(self) -> str: return self._unique_id @property - def state(self): + def state(self) -> str: """Return state.""" return self._state.isoformat() @property - def account(self): + def account(self) -> str: """Return device account.""" return self._account @property - def device_state_attributes(self): + def device_state_attributes(self) -> dict: """Return attributes.""" return self._attr - def add_attribute(self, attr): + def add_attribute(self, attr: dict): """Update attributes.""" self._attr.update(attr) @property - def device_class(self): + def device_class(self) -> str: """Return device class.""" return self._device_class @state.setter - def state(self, state): + def state(self, state: dt.datetime): """Set state.""" self._state = state self.async_schedule_update_ha_state() @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" return "mdi:alarm-light-outline" @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "ISO8601" @property - def device_info(self): + def device_info(self) -> dict: """Return the device_info.""" return { "identifiers": {(DOMAIN, self.unique_id)}, From cec5d3e54e07839736bffc39a4e0a357f8cb6188 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 13 Jul 2020 16:42:57 +0200 Subject: [PATCH 24/63] Added additional codes for Ajax (based on SIA) --- custom_components/sia/const.py | 69 +++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 6a1d701..405e43a 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -28,25 +28,52 @@ PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { - "BA": {"type": "alarm", "new_state": "triggered"}, - "BR": {"type": "alarm", "new_state": "previous_state"}, - "CA": {"type": "alarm", "new_state": "armed_away"}, - "CF": {"type": "alarm", "new_state": "armed_custom_bypass"}, - "CG": {"type": "alarm", "new_state": "armed_away"}, - "CL": {"type": "alarm", "new_state": "armed_away"}, - "CP": {"type": "alarm", "new_state": "armed_away"}, - "CQ": {"type": "alarm", "new_state": "armed_away"}, - "GA": {"type": "smoke", "new_state": True}, - "GH": {"type": "smoke", "new_state": False}, - "NL": {"type": "alarm", "new_state": "armed_night"}, - "OA": {"type": "alarm", "new_state": "disarmed"}, - "OG": {"type": "alarm", "new_state": "disarmed"}, - "OP": {"type": "alarm", "new_state": "disarmed"}, - "OQ": {"type": "alarm", "new_state": "disarmed"}, - "OR": {"type": "alarm", "new_state": "disarmed"}, - "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, - "TA": {"type": "alarm", "new_state": "triggered"}, - "WA": {"type": "moisture", "new_state": True}, - "WH": {"type": "moisture", "new_state": False}, - "YG": {"type": "timestamp", "attr": "last_message"}, + "BA": { "type": "alarm", "new_state": "triggered" }, + "PA": { "type": "alarm", "new_state": "triggered" }, + "JA": { "type": "alarm", "new_state": "triggered" }, + "BR": { "type": "alarm", "new_state": "previous_state" }, + "CA": { "type": "alarm", "new_state": "armed_away" }, + "CF": { + "type": "alarm", + "new_state": "armed_custom_bypass" + }, + "CG": { "type": "alarm", "new_state": "armed_away" }, + "CL": { "type": "alarm", "new_state": "armed_away" }, + "CP": { "type": "alarm", "new_state": "armed_away" }, + "CQ": { "type": "alarm", "new_state": "armed_away" }, + "GA": { "type": "smoke", "new_state": True }, + "GH": { "type": "smoke", "new_state": False }, + "FA": { "type": "smoke", "new_state": True }, + "FH": { "type": "smoke", "new_state": False }, + "KA": { "type": "smoke", "new_state": True }, + "KH": { "type": "smoke", "new_state": False }, + "NL": { + "type": "alarm", + "new_state": "armed_night" + }, + "OA": { "type": "alarm", "new_state": "disarmed" }, + "OG": { "type": "alarm", "new_state": "disarmed" }, + "OP": { "type": "alarm", "new_state": "disarmed" }, + "OQ": { "type": "alarm", "new_state": "disarmed" }, + "OR": { "type": "alarm", "new_state": "disarmed" }, + "RP": { "type": "timestamp", "new_state_eval": "utcnow" }, + "TA": { "type": "alarm", "new_state": "triggered" }, + "WA": { "type": "moisture", "new_state": True }, + "WH": { "type": "moisture", "new_state": False }, + "YG": { "type": "timestamp", "attr": "last_message" }, + "YC": { "type": "timestamp", "attr": "last_message" }, + "ZZ": { "type": "timestamp", "attr": "last_message" }, + "ZY": { "type": "timestamp", "attr": "last_message" }, + "XI": { "type": "timestamp", "attr": "last_message" }, + "YM": { "type": "timestamp", "attr": "last_message" }, + "YA": { "type": "timestamp", "attr": "last_message" }, + "YS": { "type": "timestamp", "attr": "last_message" }, + "XQ": { "type": "timestamp", "attr": "last_message" }, + "XH": { "type": "timestamp", "attr": "last_message" }, + "AT": { "type": "timestamp", "attr": "last_message" }, + "AR": { "type": "timestamp", "attr": "last_message" }, + "YT": { "type": "timestamp", "attr": "last_message" }, + "YR": { "type": "timestamp", "attr": "last_message" }, + "TR": { "type": "timestamp", "attr": "last_message" } } + From 5c41cc44a90f2f17f862d27a07d174fc1dd9977f Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 16 Jul 2020 09:42:21 +0200 Subject: [PATCH 25/63] updated package version, added different approach to unhandled codes --- custom_components/sia/__init__.py | 37 ++++++++++++++------------ custom_components/sia/binary_sensor.py | 1 - custom_components/sia/manifest.json | 10 ++++--- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index bd98da8..3085b92 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -66,7 +66,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - await hass.data[DOMAIN][entry.entry_id].sia_client.stop() hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() unload_ok = all( @@ -131,13 +130,7 @@ def __init__( self.sia_client = SIAClient( "", self._port, self.sia_accounts, self.update_states ) - - for zone in self._zones: - ping = self._get_ping_interval(zone[CONF_ACCOUNT]) - for sensor in zone[CONF_SENSORS]: - self._create_sensor( - self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], sensor, ping - ) + self._create_sensors() async def async_setup_hub(self): """Add a device to the device_registry, register shutdown listener, load reactions.""" @@ -158,6 +151,15 @@ async def async_shutdown(self, _: Event): """Shutdown the SIA server.""" await self.sia_client.stop() + def _create_sensors(self): + """Create all the sensors.""" + for zone in self._zones: + ping = self._get_ping_interval(zone[CONF_ACCOUNT]) + for entity_type in zone[CONF_SENSORS]: + self._create_sensor( + self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], entity_type, ping + ) + def _create_sensor( self, port: int, account: str, zone: int, entity_type: str, ping: int ): @@ -166,11 +168,12 @@ def _create_sensor( account, zone, entity_type ) if entity_type == DEVICE_CLASS_ALARM: - new_entity = SIAAlarmControlPanel( + self.states[entity_id] = SIAAlarmControlPanel( entity_id, entity_name, port, account, zone, ping, self._hass ) - elif entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): - new_entity = SIABinarySensor( + return + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, @@ -180,8 +183,9 @@ def _create_sensor( ping, self._hass, ) - elif entity_type == DEVICE_CLASS_TIMESTAMP: - new_entity = SIASensor( + return + if entity_type == DEVICE_CLASS_TIMESTAMP: + self.states[entity_id] = SIASensor( entity_id, entity_name, entity_type, @@ -191,7 +195,6 @@ def _create_sensor( ping, self._hass, ) - self.states[entity_id] = new_entity def _get_entity_id_and_name( self, account: str, zone: int = 0, entity_type: str = None @@ -233,13 +236,13 @@ async def update_states(self, event: SIAEvent): # find the reactions for that code (if any) reaction = self._reactions.get(event.code) if not reaction: - _LOGGER.warning( - "Unhandled event code: %s, Message: %s, Full event: %s", + _LOGGER.info( + "Unhandled event code, will be set as attribute in the heartbeat. Code is: %s, Message: %s, Full event: %s", event.code, event.message, event.sia_string, ) - return + reaction = {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE} attr = reaction.get("attr") new_state = reaction.get("new_state") new_state_eval = reaction.get("new_state_eval") diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index ed4bd37..c35d888 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -56,7 +56,6 @@ def __init__( hass: HomeAssistant, ): """Create SIABinarySensor object.""" - self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) self._unique_id = entity_id self._name = name diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 090d1a0..22dbab4 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -3,6 +3,10 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": ["pysiaalarm==2.0.3"], - "codeowners": ["@eavanvalkenburg"] -} + "requirements": [ + "pysiaalarm==2.0.5" + ], + "codeowners": [ + "@eavanvalkenburg" + ] +} \ No newline at end of file From b82a43d84348db5def9c81aa5e03a37ddc36f639 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Thu, 16 Jul 2020 16:48:11 +0200 Subject: [PATCH 26/63] latest package --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 22dbab4..82fa27f 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.5" + "pysiaalarm==2.0.6" ], "codeowners": [ "@eavanvalkenburg" From cc42e76d51a9524ea0ef3c51d2543cca7a614a1f Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Fri, 17 Jul 2020 08:52:04 +0200 Subject: [PATCH 27/63] temp fix for hass is none issue --- custom_components/sia/__init__.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 3085b92..733ef22 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -262,11 +262,18 @@ async def update_states(self, event: SIAEvent): "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" } ) + + for entity in self.states.values(): + if entity.account == event.account and not isinstance(entity, SIASensor): + try: + await entity.assume_available() + except Exception: + pass - await asyncio.gather( - *[ - entity.assume_available() - for entity in self.states.values() - if entity.account == event.account and not isinstance(entity, SIASensor) - ] - ) + # await asyncio.gather( + # *[ + # entity.assume_available() + # for entity in self.states.values() + # if entity.account == event.account and not isinstance(entity, SIASensor) + # ] + # ) From f7c5293c8d321f3824e065c5d5da471bd450c1ce Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Wed, 12 Aug 2020 14:18:38 +0200 Subject: [PATCH 28/63] bumped the package and small change in availability --- README.md | 12 ++++++------ custom_components/sia/__init__.py | 25 ++++++++++--------------- custom_components/sia/manifest.json | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2aeca33..b27922e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![hacs][hacsbadge]](hacs) -_Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ +_Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ _Latest beta will be suggested for inclusion as a official integration._ @@ -12,12 +12,12 @@ This integration was tested with Ajax Systems security hub only. Other SIA hubs Platform | Description -- | -- -`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. -`alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. -`sensor` | Sensor with the last heartbeat message from your system, one per account. +`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. You can disable these sensors if you do not have those devices. +`alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. You can disable this sensor if you have zones defined with just sensors and no alarm. +`sensor` | Sensor with the last heartbeat message from your system, one per account. Please do not disable this sensor as it will show you the status of the connection. ## Features -- Alarm tracking with a alarm_control_panel component +- Alarm tracking with a alarm_control_panel component, but no alarm setting - Fire/gas tracker - Water leak tracker - AES-128 CBC encryption support @@ -57,7 +57,7 @@ Key | Type | Required | Description 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 +[SIA]: https://github.com/eavanvalkenburg/sia-ha [ch_sia]: https://github.com/Cheaterdev/sia-ha [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 733ef22..2778a1c 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -237,7 +237,7 @@ async def update_states(self, event: SIAEvent): reaction = self._reactions.get(event.code) if not reaction: _LOGGER.info( - "Unhandled event code, will be set as attribute in the heartbeat. Code is: %s, Message: %s, Full event: %s", + "Unhandled event code, will be set as attribute in the heartbeat sensor. Code is: %s, Message: %s, Full event: %s", event.code, event.message, event.sia_string, @@ -262,18 +262,13 @@ async def update_states(self, event: SIAEvent): "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" } ) - - for entity in self.states.values(): - if entity.account == event.account and not isinstance(entity, SIASensor): - try: - await entity.assume_available() - except Exception: - pass - # await asyncio.gather( - # *[ - # entity.assume_available() - # for entity in self.states.values() - # if entity.account == event.account and not isinstance(entity, SIASensor) - # ] - # ) + # ignore exceptions (those are returned now, but not read) to deal with disabled sensors. + await asyncio.gather( + *[ + entity.assume_available() + for entity in self.states.values() + if entity.account == event.account and not isinstance(entity, SIASensor) + ], + return_exceptions=True, + ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 82fa27f..3407198 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.6" + "pysiaalarm==2.0.7" ], "codeowners": [ "@eavanvalkenburg" From eddc097b58ca5e59f435c73e2d2de2f1c54d5f36 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Mon, 17 Aug 2020 17:01:29 +0200 Subject: [PATCH 29/63] added NP disarmed code --- custom_components/sia/const.py | 89 ++++++++++++++++------------------ 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 405e43a..c37efe4 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -28,52 +28,47 @@ PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { - "BA": { "type": "alarm", "new_state": "triggered" }, - "PA": { "type": "alarm", "new_state": "triggered" }, - "JA": { "type": "alarm", "new_state": "triggered" }, - "BR": { "type": "alarm", "new_state": "previous_state" }, - "CA": { "type": "alarm", "new_state": "armed_away" }, - "CF": { - "type": "alarm", - "new_state": "armed_custom_bypass" - }, - "CG": { "type": "alarm", "new_state": "armed_away" }, - "CL": { "type": "alarm", "new_state": "armed_away" }, - "CP": { "type": "alarm", "new_state": "armed_away" }, - "CQ": { "type": "alarm", "new_state": "armed_away" }, - "GA": { "type": "smoke", "new_state": True }, - "GH": { "type": "smoke", "new_state": False }, - "FA": { "type": "smoke", "new_state": True }, - "FH": { "type": "smoke", "new_state": False }, - "KA": { "type": "smoke", "new_state": True }, - "KH": { "type": "smoke", "new_state": False }, - "NL": { - "type": "alarm", - "new_state": "armed_night" - }, - "OA": { "type": "alarm", "new_state": "disarmed" }, - "OG": { "type": "alarm", "new_state": "disarmed" }, - "OP": { "type": "alarm", "new_state": "disarmed" }, - "OQ": { "type": "alarm", "new_state": "disarmed" }, - "OR": { "type": "alarm", "new_state": "disarmed" }, - "RP": { "type": "timestamp", "new_state_eval": "utcnow" }, - "TA": { "type": "alarm", "new_state": "triggered" }, - "WA": { "type": "moisture", "new_state": True }, - "WH": { "type": "moisture", "new_state": False }, - "YG": { "type": "timestamp", "attr": "last_message" }, - "YC": { "type": "timestamp", "attr": "last_message" }, - "ZZ": { "type": "timestamp", "attr": "last_message" }, - "ZY": { "type": "timestamp", "attr": "last_message" }, - "XI": { "type": "timestamp", "attr": "last_message" }, - "YM": { "type": "timestamp", "attr": "last_message" }, - "YA": { "type": "timestamp", "attr": "last_message" }, - "YS": { "type": "timestamp", "attr": "last_message" }, - "XQ": { "type": "timestamp", "attr": "last_message" }, - "XH": { "type": "timestamp", "attr": "last_message" }, - "AT": { "type": "timestamp", "attr": "last_message" }, - "AR": { "type": "timestamp", "attr": "last_message" }, - "YT": { "type": "timestamp", "attr": "last_message" }, - "YR": { "type": "timestamp", "attr": "last_message" }, - "TR": { "type": "timestamp", "attr": "last_message" } + "BA": {"type": "alarm", "new_state": "triggered"}, + "PA": {"type": "alarm", "new_state": "triggered"}, + "JA": {"type": "alarm", "new_state": "triggered"}, + "BR": {"type": "alarm", "new_state": "previous_state"}, + "CA": {"type": "alarm", "new_state": "armed_away"}, + "CF": {"type": "alarm", "new_state": "armed_custom_bypass"}, + "CG": {"type": "alarm", "new_state": "armed_away"}, + "CL": {"type": "alarm", "new_state": "armed_away"}, + "CP": {"type": "alarm", "new_state": "armed_away"}, + "CQ": {"type": "alarm", "new_state": "armed_away"}, + "GA": {"type": "smoke", "new_state": True}, + "GH": {"type": "smoke", "new_state": False}, + "FA": {"type": "smoke", "new_state": True}, + "FH": {"type": "smoke", "new_state": False}, + "KA": {"type": "smoke", "new_state": True}, + "KH": {"type": "smoke", "new_state": False}, + "NL": {"type": "alarm", "new_state": "armed_night"}, + "NP": {"type": "alarm", "new_state": "disarmed"}, + "OA": {"type": "alarm", "new_state": "disarmed"}, + "OG": {"type": "alarm", "new_state": "disarmed"}, + "OP": {"type": "alarm", "new_state": "disarmed"}, + "OQ": {"type": "alarm", "new_state": "disarmed"}, + "OR": {"type": "alarm", "new_state": "disarmed"}, + "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, + "TA": {"type": "alarm", "new_state": "triggered"}, + "WA": {"type": "moisture", "new_state": True}, + "WH": {"type": "moisture", "new_state": False}, + "YG": {"type": "timestamp", "attr": "last_message"}, + "YC": {"type": "timestamp", "attr": "last_message"}, + "ZZ": {"type": "timestamp", "attr": "last_message"}, + "ZY": {"type": "timestamp", "attr": "last_message"}, + "XI": {"type": "timestamp", "attr": "last_message"}, + "YM": {"type": "timestamp", "attr": "last_message"}, + "YA": {"type": "timestamp", "attr": "last_message"}, + "YS": {"type": "timestamp", "attr": "last_message"}, + "XQ": {"type": "timestamp", "attr": "last_message"}, + "XH": {"type": "timestamp", "attr": "last_message"}, + "AT": {"type": "timestamp", "attr": "last_message"}, + "AR": {"type": "timestamp", "attr": "last_message"}, + "YT": {"type": "timestamp", "attr": "last_message"}, + "YR": {"type": "timestamp", "attr": "last_message"}, + "TR": {"type": "timestamp", "attr": "last_message"}, } From 117388f5ad0e71ab0d00382885b156262aaa2266 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Fri, 4 Sep 2020 11:52:57 +0200 Subject: [PATCH 30/63] updates based on official and updated package --- custom_components/sia/__init__.py | 248 +------------------ custom_components/sia/alarm_control_panel.py | 51 ++-- custom_components/sia/binary_sensor.py | 16 +- custom_components/sia/hub.py | 219 ++++++++++++++++ custom_components/sia/manifest.json | 2 +- custom_components/sia/sensor.py | 13 +- 6 files changed, 284 insertions(+), 265 deletions(-) create mode 100644 custom_components/sia/hub.py diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 2778a1c..ba14212 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,73 +1,34 @@ """The sia integration.""" import asyncio -from datetime import timedelta -import logging -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PORT, - CONF_SENSORS, - CONF_ZONE, - DEVICE_CLASS_TIMESTAMP, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.util.dt import utcnow -from homeassistant.util.json import load_json - -from .alarm_control_panel import SIAAlarmControlPanel -from .binary_sensor import SIABinarySensor -from .const import ( - CONF_ACCOUNT, - CONF_ACCOUNTS, - CONF_ENCRYPTION_KEY, - CONF_PING_INTERVAL, - CONF_ZONES, - DEVICE_CLASS_ALARM, - DOMAIN, - HUB_SENSOR_NAME, - HUB_ZONE, - LAST_MESSAGE, - PLATFORMS, - REACTIONS, - UTCNOW, -) -from .sensor import SIASensor +from homeassistant.core import HomeAssistant -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORMS +from .hub import SIAHub async def async_setup(hass: HomeAssistant, config: dict): """Set up the sia component.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up sia from a config entry.""" - hass.data[DOMAIN][entry.entry_id] = SIAHub( - hass, entry.data, entry.entry_id, entry.title - ) - await hass.data[DOMAIN][entry.entry_id].async_setup_hub() + hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) + await hub.async_setup_hub() + hass.data[DOMAIN][entry.entry_id] = hub for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - hass.data[DOMAIN][entry.entry_id].sia_client.start(reuse_port=True) + hub.sia_client.start(reuse_port=True) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - await hass.data[DOMAIN][entry.entry_id].sia_client.stop() - hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() unload_ok = all( await asyncio.gather( *[ @@ -78,197 +39,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) if unload_ok: + await hass.data[DOMAIN][entry.entry_id].sia_client.stop() + hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SIAHub: - """Class for SIA Hubs.""" - - def __init__( - self, hass: HomeAssistant, hub_config: dict, entry_id: str, title: str - ): - """Create the SIAHub.""" - self._hass = hass - self.states = {} - self._port = int(hub_config[CONF_PORT]) - self.entry_id = entry_id - self._title = title - self._accounts = hub_config[CONF_ACCOUNTS] - self.shutdown_remove_listener = None - self._reactions = REACTIONS - - self._zones = [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: HUB_ZONE, - CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP], - } - for a in self._accounts - ] - self._zones.extend( - [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: z, - CONF_SENSORS: [ - DEVICE_CLASS_ALARM, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - ], - } - for a in self._accounts - for z in range(1, int(a[CONF_ZONES]) + 1) - ] - ) - - self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY)) - for a in self._accounts - ] - self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.update_states - ) - self._create_sensors() - - async def async_setup_hub(self): - """Add a device to the device_registry, register shutdown listener, load reactions.""" - device_registry = await dr.async_get_registry(self._hass) - port = self._port - for acc in self._accounts: - account = acc[CONF_ACCOUNT] - device_registry.async_get_or_create( - config_entry_id=self.entry_id, - identifiers={(DOMAIN, port, account)}, - name=f"{port} - {account}", - ) - self.shutdown_remove_listener = self._hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_shutdown - ) - - async def async_shutdown(self, _: Event): - """Shutdown the SIA server.""" - await self.sia_client.stop() - - def _create_sensors(self): - """Create all the sensors.""" - for zone in self._zones: - ping = self._get_ping_interval(zone[CONF_ACCOUNT]) - for entity_type in zone[CONF_SENSORS]: - self._create_sensor( - self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], entity_type, ping - ) - - def _create_sensor( - self, port: int, account: str, zone: int, entity_type: str, ping: int - ): - """Check if the entity exists, and creates otherwise.""" - entity_id, entity_name = self._get_entity_id_and_name( - account, zone, entity_type - ) - if entity_type == DEVICE_CLASS_ALARM: - self.states[entity_id] = SIAAlarmControlPanel( - entity_id, entity_name, port, account, zone, ping, self._hass - ) - return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): - self.states[entity_id] = SIABinarySensor( - entity_id, - entity_name, - entity_type, - port, - account, - zone, - ping, - self._hass, - ) - return - if entity_type == DEVICE_CLASS_TIMESTAMP: - self.states[entity_id] = SIASensor( - entity_id, - entity_name, - entity_type, - port, - account, - zone, - ping, - self._hass, - ) - - def _get_entity_id_and_name( - self, account: str, zone: int = 0, entity_type: str = None - ): - """Give back a entity_id and name according to the variables.""" - if zone == 0: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Last Heartbeat", - ) - if entity_type: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - zone {zone} - {entity_type}", - ) - return None - - def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): - """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{self._port}_{account}_{HUB_SENSOR_NAME}" - if entity_type: - return f"{self._port}_{account}_{zone}_{entity_type}" - return None - - def _get_ping_interval(self, account: str): - """Return the ping interval for specified account.""" - for acc in self._accounts: - if acc[CONF_ACCOUNT] == account: - return timedelta(minutes=acc[CONF_PING_INTERVAL]) - return None - - async def update_states(self, event: SIAEvent): - """Update the sensors. This can be both a new state and a new attribute. - - Whenever a message comes in and is a event that should cause a reaction, the connection is good, so reset the availability timer for all devices of that account, excluding the last heartbeat. - - """ - # find the reactions for that code (if any) - reaction = self._reactions.get(event.code) - if not reaction: - _LOGGER.info( - "Unhandled event code, will be set as attribute in the heartbeat sensor. Code is: %s, Message: %s, Full event: %s", - event.code, - event.message, - event.sia_string, - ) - reaction = {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE} - attr = reaction.get("attr") - new_state = reaction.get("new_state") - new_state_eval = reaction.get("new_state_eval") - entity_id = self._get_entity_id( - event.account, int(event.zone), reaction["type"] - ) - - if new_state: - self.states[entity_id].state = new_state - elif new_state_eval: - if new_state_eval == UTCNOW: - self.states[entity_id].state = utcnow() - if attr: - if attr == LAST_MESSAGE: - self.states[entity_id].add_attribute( - { - "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" - } - ) - - # ignore exceptions (those are returned now, but not read) to deal with disabled sensors. - await asyncio.gather( - *[ - entity.assume_available() - for entity in self.states.values() - if entity.account == event.account and not isinstance(entity, SIASensor) - ], - return_exceptions=True, - ) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index fac07f2..c61fc5d 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -15,8 +15,9 @@ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity @@ -45,7 +46,6 @@ async def async_setup_entry( if isinstance(device, SIAAlarmControlPanel) ] ) - return True @@ -60,7 +60,6 @@ def __init__( account: str, zone: int, ping_interval: int, - hass: HomeAssistant, ): """Create SIAAlarmControlPanel object.""" self.entity_id = ALARM_FORMAT.format(entity_id) @@ -70,7 +69,6 @@ def __init__( self._account = account self._zone = zone self._ping_interval = ping_interval - self.hass = hass self._should_poll = False self._is_available = True @@ -87,19 +85,24 @@ async def async_added_to_hass(self): """Once the panel is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() state = await self.async_get_last_state() - if state is not None and state.state is not None: - if state.state == STATE_ALARM_ARMED_AWAY: - self.state = STATE_ALARM_ARMED_AWAY - elif state.state == STATE_ALARM_ARMED_NIGHT: - self.state = STATE_ALARM_ARMED_NIGHT - elif state.state == STATE_ALARM_TRIGGERED: - self.state = STATE_ALARM_TRIGGERED - elif state.state == STATE_ALARM_DISARMED: - self.state = STATE_ALARM_DISARMED - elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: - self.state = STATE_ALARM_ARMED_CUSTOM_BYPASS - else: - self.state = None + _LOGGER.debug( + "Loading last state: %s", + state.state if state is not None and state.state is not None else "None", + ) + if ( + state is not None + and state.state is not None + and state.state + in [ + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + STATE_UNKNOWN, + ] + ): + self.state = state.state else: self.state = None await self._async_track_unavailable() @@ -146,17 +149,27 @@ def device_state_attributes(self) -> dict: """Return device attributes.""" return self._attr + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + @state.setter def state(self, state: str): """Set state.""" temp = self._old_state if state == PREVIOUS_STATE else state self._old_state = self._state self._state = temp - self.async_schedule_update_ha_state() + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" - await self._async_track_unavailable() + if not self.registry_entry.disabled: + await self._async_track_unavailable() @callback async def _async_track_unavailable(self) -> bool: diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index c35d888..f99dce0 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -53,7 +53,6 @@ def __init__( account: str, zone: int, ping_interval: int, - hass: HomeAssistant, ): """Create SIABinarySensor object.""" self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) @@ -64,7 +63,6 @@ def __init__( self._account = account self._zone = zone self._ping_interval = ping_interval - self.hass = hass self._should_poll = False self._is_on = None @@ -142,15 +140,25 @@ def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self._is_on + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + @state.setter def state(self, new_on: bool): """Set state.""" self._is_on = new_on - self.async_schedule_update_ha_state() + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" - await self._async_track_unavailable() + if not self.registry_entry.disabled: + await self._async_track_unavailable() @callback async def _async_track_unavailable(self) -> bool: diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py new file mode 100644 index 0000000..9393d40 --- /dev/null +++ b/custom_components/sia/hub.py @@ -0,0 +1,219 @@ +"""The sia hub.""" +import asyncio +from datetime import timedelta +import logging + +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, +) +from homeassistant.const import ( + CONF_PORT, + CONF_SENSORS, + CONF_ZONE, + DEVICE_CLASS_TIMESTAMP, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util.dt import utcnow + +from .alarm_control_panel import SIAAlarmControlPanel +from .binary_sensor import SIABinarySensor +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ENCRYPTION_KEY, + CONF_PING_INTERVAL, + CONF_ZONES, + DEVICE_CLASS_ALARM, + DOMAIN, + HUB_SENSOR_NAME, + HUB_ZONE, + LAST_MESSAGE, + REACTIONS, + UTCNOW, +) +from .sensor import SIASensor + +_LOGGER = logging.getLogger(__name__) + + +class SIAHub: + """Class for SIA Hubs.""" + + def __init__( + self, hass: HomeAssistant, hub_config: dict, entry_id: str, title: str + ): + """Create the SIAHub.""" + self._hass = hass + self.states = {} + self._port = int(hub_config[CONF_PORT]) + self.entry_id = entry_id + self._title = title + self._accounts = hub_config[CONF_ACCOUNTS] + self.shutdown_remove_listener = None + self._reactions = REACTIONS + + self._zones = [ + { + CONF_ACCOUNT: a[CONF_ACCOUNT], + CONF_ZONE: HUB_ZONE, + CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP], + } + for a in self._accounts + ] + self._zones.extend( + [ + { + CONF_ACCOUNT: a[CONF_ACCOUNT], + CONF_ZONE: z, + CONF_SENSORS: [ + DEVICE_CLASS_ALARM, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, + ], + } + for a in self._accounts + for z in range(1, int(a[CONF_ZONES]) + 1) + ] + ) + + self.sia_accounts = [ + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY)) + for a in self._accounts + ] + self.sia_client = SIAClient( + "", self._port, self.sia_accounts, self.update_states + ) + self._create_sensors() + + async def async_setup_hub(self): + """Add a device to the device_registry, register shutdown listener, load reactions.""" + device_registry = await dr.async_get_registry(self._hass) + port = self._port + for acc in self._accounts: + account = acc[CONF_ACCOUNT] + device_registry.async_get_or_create( + config_entry_id=self.entry_id, + identifiers={(DOMAIN, port, account)}, + name=f"{port} - {account}", + ) + self.shutdown_remove_listener = self._hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.async_shutdown + ) + + async def async_shutdown(self, _: Event): + """Shutdown the SIA server.""" + await self.sia_client.stop() + + def _create_sensors(self): + """Create all the sensors.""" + for zone in self._zones: + ping = self._get_ping_interval(zone[CONF_ACCOUNT]) + for entity_type in zone[CONF_SENSORS]: + self._create_sensor( + self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], entity_type, ping + ) + + def _create_sensor( + self, port: int, account: str, zone: int, entity_type: str, ping: int + ): + """Check if the entity exists, and creates otherwise.""" + entity_id, entity_name = self._get_entity_id_and_name( + account, zone, entity_type + ) + if entity_type == DEVICE_CLASS_ALARM: + self.states[entity_id] = SIAAlarmControlPanel( + entity_id, entity_name, port, account, zone, ping + ) + return + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + self.states[entity_id] = SIABinarySensor( + entity_id, entity_name, entity_type, port, account, zone, ping + ) + return + if entity_type == DEVICE_CLASS_TIMESTAMP: + self.states[entity_id] = SIASensor( + entity_id, entity_name, entity_type, port, account, zone, ping + ) + + def _get_entity_id_and_name( + self, account: str, zone: int = 0, entity_type: str = None + ): + """Give back a entity_id and name according to the variables.""" + if zone == 0: + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - Last Heartbeat", + ) + if entity_type: + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - zone {zone} - {entity_type}", + ) + return None + + def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): + """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" + if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{self._port}_{account}_{HUB_SENSOR_NAME}" + if entity_type: + return f"{self._port}_{account}_{zone}_{entity_type}" + return None + + def _get_ping_interval(self, account: str): + """Return the ping interval for specified account.""" + for acc in self._accounts: + if acc[CONF_ACCOUNT] == account: + return timedelta(minutes=acc[CONF_PING_INTERVAL]) + return None + + async def update_states(self, event: SIAEvent): + """Update the sensors. This can be both a new state and a new attribute. + + Whenever a message comes in and is a event that should cause a reaction, the connection is good, so reset the availability timer for all devices of that account, excluding the last heartbeat. + + """ + + # ignore exceptions (those are returned now, but not read) to deal with disabled sensors. + await asyncio.gather( + *[ + entity.assume_available() + for entity in self.states.values() + if entity.account == event.account and not isinstance(entity, SIASensor) + ], + return_exceptions=True, + ) + + # find the reactions for that code (if any) + reaction = self._reactions.get(event.code) + if not reaction: + _LOGGER.info( + "Unhandled event code, will be set as attribute in the heartbeat sensor. Code is: %s, Message: %s, Full event: %s", + event.code, + event.message, + event.sia_string, + ) + reaction = {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE} + attr = reaction.get("attr") + new_state = reaction.get("new_state") + new_state_eval = reaction.get("new_state_eval") + entity_id = self._get_entity_id( + event.account, int(event.zone), reaction["type"] + ) + + if new_state: + self.states[entity_id].state = new_state + elif new_state_eval: + if new_state_eval == UTCNOW: + self.states[entity_id].state = utcnow() + if attr: + if attr == LAST_MESSAGE: + self.states[entity_id].add_attribute( + { + "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" + } + ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 3407198..9dc7f58 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.7" + "pysiaalarm==2.0.8" ], "codeowners": [ "@eavanvalkenburg" diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index a56877a..9c5694d 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -44,7 +44,6 @@ def __init__( account: str, zone: int, ping_interval: int, - hass: HomeAssistant, ): """Create SIASensor object.""" self.entity_id = SENSOR_FORMAT.format(entity_id) @@ -55,7 +54,6 @@ def __init__( self._account = account self._zone = zone self._ping_interval = str(ping_interval) - self.hass = hass self._state = utcnow() self._should_poll = False @@ -111,6 +109,14 @@ def add_attribute(self, attr: dict): """Update attributes.""" self._attr.update(attr) + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + @property def device_class(self) -> str: """Return device class.""" @@ -120,7 +126,8 @@ def device_class(self) -> str: def state(self, state: dt.datetime): """Set state.""" self._state = state - self.async_schedule_update_ha_state() + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() @property def icon(self) -> str: From 89a878e831cc9bc5061260894c363671a9a9aef7 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Mon, 14 Sep 2020 14:35:53 +0200 Subject: [PATCH 31/63] fix for #23 --- custom_components/sia/hub.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 9393d40..7548175 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -205,12 +205,12 @@ async def update_states(self, event: SIAEvent): event.account, int(event.zone), reaction["type"] ) - if new_state: + if new_state is not None: self.states[entity_id].state = new_state - elif new_state_eval: + elif new_state_eval is not None: if new_state_eval == UTCNOW: self.states[entity_id].state = utcnow() - if attr: + if attr is not None: if attr == LAST_MESSAGE: self.states[entity_id].add_attribute( { From 112c7aa3c561519e0599d99eff6096a8244624c6 Mon Sep 17 00:00:00 2001 From: "E.A. van Valkenburg" Date: Tue, 15 Sep 2020 11:39:56 +0200 Subject: [PATCH 32/63] updated HACS info --- info.md | 41 ++++------------------------------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/info.md b/info.md index d2f4bc6..d38def9 100644 --- a/info.md +++ b/info.md @@ -36,51 +36,18 @@ Platform | Description 1. Click install. 1. Add at least the minimum configuration to your HA configuration, see below. -### Minimum config -This is the least amount of information that needs to be in your config. This will result in a `sensor.hubname_last_heartbeat` being added after reboot. Dynamically any other sensors are added. - -```yaml -sia: - port: port - hubs: - - name: hubname - account: account -``` - -{% endif %} -## Full configuration - -```yaml -sia: - port: port - hubs: - - name: hubname - account: account - encryption_key: password - ping_interval: pinginterval - zones: - - zone: 1 - name: zonename - sensors: - - alarm - - moisture - - smoke -``` ## Configuration options + Key | Type | Required | Description -- | -- | -- | -- `port` | `int` | `True` | Port that SIA will listen on. -`hubs` | `list` | `True` | List of all hubs to connect to. -`name` | `string` | `True` | Used to generate sensor ids. `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` | `False` | 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. -`zones` | `list` | `False` | Manual definition of all zones present, if unspecified, only the hub sensor is added, and new sensors are added based on messages coming in. -`zone` | `int` | `False` | ZoneID, must match the zone that the system sends, can be found in the log but also "discovered" -`name` | `string` | `False` | Zone name, is used for the friendly name of your sensors, when you have the same sensortypes in multiple zones and this is not set, a `_1, _2, etc` is added by HA automatically. -`sensors` | `list` | `False` | a list of sensors, must be of type: `alarm`, `moisture` (HA standard name for a leak sensor) or `smoke` +`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. *** From b822d4313cd983c4951212d5b072d78fb3d92c25 Mon Sep 17 00:00:00 2001 From: RichieFrame <33644730+RichieFrame@users.noreply.github.com> Date: Mon, 28 Sep 2020 23:12:54 -0500 Subject: [PATCH 33/63] Add keyswitch arm/disarm events to reactions array --- custom_components/sia/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index c37efe4..244ebe5 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -38,6 +38,7 @@ "CL": {"type": "alarm", "new_state": "armed_away"}, "CP": {"type": "alarm", "new_state": "armed_away"}, "CQ": {"type": "alarm", "new_state": "armed_away"}, + "CS": {"type": "alarm", "new_state": "armed_away"}, "GA": {"type": "smoke", "new_state": True}, "GH": {"type": "smoke", "new_state": False}, "FA": {"type": "smoke", "new_state": True}, @@ -51,6 +52,7 @@ "OP": {"type": "alarm", "new_state": "disarmed"}, "OQ": {"type": "alarm", "new_state": "disarmed"}, "OR": {"type": "alarm", "new_state": "disarmed"}, + "OS": {"type": "alarm", "new_state": "disarmed"}, "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, "TA": {"type": "alarm", "new_state": "triggered"}, "WA": {"type": "moisture", "new_state": True}, From 70d2a1751c8b118b2774391feb3b77f4d87c4648 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 30 Oct 2020 13:42:37 +0100 Subject: [PATCH 34/63] Added debug logging to readme --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b27922e..30a9974 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,17 @@ Key | Type | Required | Description ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. *** +## Debugging +To turn on debugging go into your `configuration.yaml` and add these lines: +```yaml +logger: + default: error + logs: + custom_components.sia: debug + pysiaalarm: debug +``` + [SIA]: https://github.com/eavanvalkenburg/sia-ha [ch_sia]: https://github.com/Cheaterdev/sia-ha [hacs]: https://github.com/custom-components/hacs -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge From 820162f3d6d265a750aeb4cdbf503c39fcbb8756 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 4 Jan 2021 15:22:08 +0100 Subject: [PATCH 35/63] new package --- custom_components/sia/hub.py | 2 +- custom_components/sia/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 7548175..6593068 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -202,7 +202,7 @@ async def update_states(self, event: SIAEvent): new_state = reaction.get("new_state") new_state_eval = reaction.get("new_state_eval") entity_id = self._get_entity_id( - event.account, int(event.zone), reaction["type"] + event.account, int(event.ri), reaction["type"] ) if new_state is not None: diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 9dc7f58..6724113 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.8" + "pysiaalarm==2.0.9beta-2" ], "codeowners": [ "@eavanvalkenburg" From 3e9b5e223711b8b3dc578abc5ceed7521a7257ba Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 4 Jan 2021 15:28:56 +0100 Subject: [PATCH 36/63] added codes --- custom_components/sia/const.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 244ebe5..aafb346 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -28,6 +28,8 @@ PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { + "AT": {"type": "timestamp", "attr": "last_message"}, + "AR": {"type": "timestamp", "attr": "last_message"}, "BA": {"type": "alarm", "new_state": "triggered"}, "PA": {"type": "alarm", "new_state": "triggered"}, "JA": {"type": "alarm", "new_state": "triggered"}, @@ -45,8 +47,10 @@ "FH": {"type": "smoke", "new_state": False}, "KA": {"type": "smoke", "new_state": True}, "KH": {"type": "smoke", "new_state": False}, + "NC": {"type": "alarm", "new_state": "armed_night"}, "NL": {"type": "alarm", "new_state": "armed_night"}, - "NP": {"type": "alarm", "new_state": "disarmed"}, + "NP": {"type": "alarm", "new_state": "previous_state"}, + "NO": {"type": "alarm", "new_state": "previous_state"}, "OA": {"type": "alarm", "new_state": "disarmed"}, "OG": {"type": "alarm", "new_state": "disarmed"}, "OP": {"type": "alarm", "new_state": "disarmed"}, @@ -59,18 +63,16 @@ "WH": {"type": "moisture", "new_state": False}, "YG": {"type": "timestamp", "attr": "last_message"}, "YC": {"type": "timestamp", "attr": "last_message"}, - "ZZ": {"type": "timestamp", "attr": "last_message"}, - "ZY": {"type": "timestamp", "attr": "last_message"}, "XI": {"type": "timestamp", "attr": "last_message"}, "YM": {"type": "timestamp", "attr": "last_message"}, "YA": {"type": "timestamp", "attr": "last_message"}, "YS": {"type": "timestamp", "attr": "last_message"}, "XQ": {"type": "timestamp", "attr": "last_message"}, "XH": {"type": "timestamp", "attr": "last_message"}, - "AT": {"type": "timestamp", "attr": "last_message"}, - "AR": {"type": "timestamp", "attr": "last_message"}, "YT": {"type": "timestamp", "attr": "last_message"}, "YR": {"type": "timestamp", "attr": "last_message"}, "TR": {"type": "timestamp", "attr": "last_message"}, + "ZZ": {"type": "timestamp", "attr": "last_message"}, + "ZY": {"type": "timestamp", "attr": "last_message"}, } From 8bb32f5e4ed4516c6b46e374fb6d23bb8cc88fa9 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 7 Jan 2021 10:39:33 +0100 Subject: [PATCH 37/63] updated package beta --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 6724113..5012576 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-2" + "pysiaalarm==2.0.9beta-3" ], "codeowners": [ "@eavanvalkenburg" From 1cfc1fc020cca3e767a90eff74c11d87dae41175 Mon Sep 17 00:00:00 2001 From: Laurynas Sakalauskas Date: Sat, 9 Jan 2021 13:32:07 +0200 Subject: [PATCH 38/63] Add power sensor --- custom_components/sia/const.py | 4 ++-- custom_components/sia/hub.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index aafb346..040e635 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -28,8 +28,8 @@ PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] REACTIONS = { - "AT": {"type": "timestamp", "attr": "last_message"}, - "AR": {"type": "timestamp", "attr": "last_message"}, + "AT": {"type": "power", "new_state": False}, + "AR": {"type": "power", "new_state": True}, "BA": {"type": "alarm", "new_state": "triggered"}, "PA": {"type": "alarm", "new_state": "triggered"}, "JA": {"type": "alarm", "new_state": "triggered"}, diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 6593068..8dcf8b0 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_POWER, ) from homeassistant.const import ( CONF_PORT, @@ -74,6 +75,7 @@ def __init__( DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_POWER, ], } for a in self._accounts @@ -130,7 +132,7 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) @@ -158,7 +160,7 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: + if zone == 0 and entity_type == DEVICE_CLASS_TIMESTAMP: return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" From 4383d1c0034a35d528a77bba2d50db72c0ef9a01 Mon Sep 17 00:00:00 2001 From: Laurynas Sakalauskas Date: Sat, 9 Jan 2021 14:12:23 +0200 Subject: [PATCH 39/63] restructure power sensor --- README.md | 3 ++- custom_components/sia/hub.py | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 30a9974..1f3a649 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This integration was tested with Ajax Systems security hub only. Other SIA hubs Platform | Description -- | -- -`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. You can disable these sensors if you do not have those devices. +`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. Power sensor for the hub. You can disable these sensors if you do not have those devices. `alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. You can disable this sensor if you have zones defined with just sensors and no alarm. `sensor` | Sensor with the last heartbeat message from your system, one per account. Please do not disable this sensor as it will show you the status of the connection. @@ -20,6 +20,7 @@ Platform | Description - Alarm tracking with a alarm_control_panel component, but no alarm setting - Fire/gas tracker - Water leak tracker +- Hub Power status tracker - AES-128 CBC encryption support ## Hub Setup (Ajax Systems Hub example) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 8dcf8b0..00550ba 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -62,7 +62,7 @@ def __init__( { CONF_ACCOUNT: a[CONF_ACCOUNT], CONF_ZONE: HUB_ZONE, - CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP], + CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_POWER], } for a in self._accounts ] @@ -75,7 +75,6 @@ def __init__( DEVICE_CLASS_ALARM, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, - DEVICE_CLASS_POWER, ], } for a in self._accounts @@ -132,11 +131,18 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) return + + if entity_type == DEVICE_CLASS_POWER: + self.states[entity_id] = SIABinarySensor( + entity_id, entity_name, entity_type, port, account, zone, ping + ) + return + if entity_type == DEVICE_CLASS_TIMESTAMP: self.states[entity_id] = SIASensor( entity_id, entity_name, entity_type, port, account, zone, ping @@ -147,8 +153,14 @@ def _get_entity_id_and_name( ): """Give back a entity_id and name according to the variables.""" if zone == 0: + if entity_type == DEVICE_CLASS_POWER: + return ( + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - Power", + ) + return ( - self._get_entity_id(account, zone, entity_type), + self._get_entity_id(account, zone, entity_type), f"{self._port} - {account} - Last Heartbeat", ) if entity_type: @@ -160,7 +172,9 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0 and entity_type == DEVICE_CLASS_TIMESTAMP: + if entity_type == DEVICE_CLASS_POWER: + return f"{self._port}_{account}_{entity_type}" + if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" From f954d6052881f14bef5ee65ca0bbadfa70face60 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 11 Jan 2021 16:17:58 +0100 Subject: [PATCH 40/63] cleaned power sensor code and added message attribute (#37) --- custom_components/sia/hub.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 00550ba..19dd973 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -131,18 +131,11 @@ def _create_sensor( entity_id, entity_name, port, account, zone, ping ) return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE): + if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): self.states[entity_id] = SIABinarySensor( entity_id, entity_name, entity_type, port, account, zone, ping ) return - - if entity_type == DEVICE_CLASS_POWER: - self.states[entity_id] = SIABinarySensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - return - if entity_type == DEVICE_CLASS_TIMESTAMP: self.states[entity_id] = SIASensor( entity_id, entity_name, entity_type, port, account, zone, ping @@ -153,15 +146,10 @@ def _get_entity_id_and_name( ): """Give back a entity_id and name according to the variables.""" if zone == 0: - if entity_type == DEVICE_CLASS_POWER: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Power", - ) - + entity_type_name = "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - Last Heartbeat", + self._get_entity_id(account, zone, entity_type), + f"{self._port} - {account} - {entity_type_name}", ) if entity_type: return ( @@ -172,10 +160,10 @@ def _get_entity_id_and_name( def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if entity_type == DEVICE_CLASS_POWER: + if zone == 0: + if entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{self._port}_{account}_{HUB_SENSOR_NAME}" return f"{self._port}_{account}_{entity_type}" - if zone == 0 or entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{self._port}_{account}_{HUB_SENSOR_NAME}" if entity_type: return f"{self._port}_{account}_{zone}_{entity_type}" return None @@ -221,15 +209,21 @@ async def update_states(self, event: SIAEvent): event.account, int(event.ri), reaction["type"] ) + #update state if new_state is not None: self.states[entity_id].state = new_state elif new_state_eval is not None: if new_state_eval == UTCNOW: self.states[entity_id].state = utcnow() + + #update standard attributes of the touched sensor and if necessary the last_message or other attributes + self.states[entity_id].add_attribute( { "last_message": {event.message} }) + self.states[entity_id].add_attribute( { "last_code": {event.code} }) + self.states[entity_id].add_attribute( { "last_update": {utcnow().isoformat()} }) if attr is not None: if attr == LAST_MESSAGE: self.states[entity_id].add_attribute( { - "last_message": f"{utcnow().isoformat()}: SIA: {event.sia_string}, Message: {event.message}" + "last_sia_event_string": "SIA: {event.sia_string}" } ) From b5d10a9a320b3374a3b6c54bbde4b23134cc210d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 14 Jan 2021 16:55:01 +0000 Subject: [PATCH 41/63] added devcontainer --- .devcontainer/configuration.yaml | 6 ++ .devcontainer/devcontainer.json | 31 +++++++ .devcontainer/readme.md | 43 ++++++++++ .gitignore | 138 ++++++++++++++++++++++++++++++- .vscode/settings.json | 6 ++ .vscode/tasks.json | 48 ++--------- 6 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 .devcontainer/configuration.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/readme.md create mode 100644 .vscode/settings.json diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..d95e37b --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,6 @@ +default_config: + + logger: + default: error + logs: + custom_components.sia: debug \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..28af27c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ludeeus/container:integration", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "runArgs": [ + "-v", + "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" + ], + "extensions": [ + "ms-python.vscode-pylance", + "github.vscode-pull-request-github", + "tabnine.tabnine-vscode" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.devcontainer/readme.md b/.devcontainer/readme.md new file mode 100644 index 0000000..a01c141 --- /dev/null +++ b/.devcontainer/readme.md @@ -0,0 +1,43 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. \ No newline at end of file diff --git a/.gitignore b/.gitignore index dbe9c82..49260ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,137 @@ -.vscode/ \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# PyCharm stuff: +.idea/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# HA Config directory for local testing +/Config/ + +**/.DS_Store \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3c0f3d6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.associations": { + "*.yaml": "home-assistant" + }, + "python.pythonPath": "C:\\Anaconda3\\python.exe" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0749072..7ab4ba8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,59 +2,27 @@ "version": "2.0.0", "tasks": [ { - "label": "Start Home Assistant on port 8124", + "label": "Run Home Assistant on port 9123", "type": "shell", - "command": "source .devcontainer/custom_component_helper && StartHomeAssistant", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container start", "problemMatcher": [] }, { - "label": "Upgrade Home Assistant to latest dev", + "label": "Run Home Assistant configuration against /config", "type": "shell", - "command": "source .devcontainer/custom_component_helper && UpdgradeHomeAssistantDev", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container check", "problemMatcher": [] }, { - "label": "Set Home Assistant Version", + "label": "Upgrade Home Assistant to latest dev", "type": "shell", - "command": "source .devcontainer/custom_component_helper && SetHomeAssistantVersion", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container install", "problemMatcher": [] }, { - "label": "Home Assistant Config Check", + "label": "Install a specific version of Home Assistant", "type": "shell", - "command": "source .devcontainer/custom_component_helper && HomeAssistantConfigCheck", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, + "command": "container set-version", "problemMatcher": [] } ] From 4b7269f8b8584996892f12a2097a61ea30754767 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:23:43 +0100 Subject: [PATCH 42/63] added add_attribute to alarm_control_panel --- custom_components/sia/alarm_control_panel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index c61fc5d..7e8bc64 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -149,6 +149,10 @@ def device_state_attributes(self) -> dict: """Return device attributes.""" return self._attr + def add_attribute(self, attr: dict): + """Update attributes.""" + self._attr.update(attr) + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. From 1b4ae7d725ae7c76973b7fc083ee05888bc6d5bf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:24:31 +0100 Subject: [PATCH 43/63] added add_attribute to binary --- custom_components/sia/binary_sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index f99dce0..005cc93 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -155,6 +155,10 @@ def state(self, new_on: bool): if not self.registry_entry.disabled: self.async_schedule_update_ha_state() + def add_attribute(self, attr: dict): + """Update attributes.""" + self._attr.update(attr) + async def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: From f3208ac26651cc8bb27cf07505b5377428a07709 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 21 Jan 2021 15:25:20 +0100 Subject: [PATCH 44/63] updated package --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 5012576..3f8ed83 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,9 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-3" + "pysiaalarm==2.0.9beta-4" ], "codeowners": [ "@eavanvalkenburg" ] -} \ No newline at end of file +} From e366f711b216d228ea454a120a3e69b5c4e1be8b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 26 Jan 2021 12:03:34 +0000 Subject: [PATCH 45/63] Rebuilt for Event based updating --- .devcontainer/configuration.yaml | 9 +- .devcontainer/devcontainer.json | 10 +- .vscode/launch.json | 23 +++ .vscode/settings.json | 2 +- custom_components/sia/__init__.py | 29 ++- custom_components/sia/alarm_control_panel.py | 101 +++++++++- custom_components/sia/binary_sensor.py | 121 ++++++++++- custom_components/sia/const.py | 76 ++----- custom_components/sia/helpers.py | 43 ++++ custom_components/sia/hub.py | 202 +++---------------- custom_components/sia/manifest.json | 2 +- custom_components/sia/reactions.json | 29 --- custom_components/sia/sensor.py | 83 ++++++-- test_zc.py | 20 ++ todo.md | 0 15 files changed, 437 insertions(+), 313 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 custom_components/sia/helpers.py delete mode 100644 custom_components/sia/reactions.json create mode 100644 test_zc.py create mode 100644 todo.md diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index d95e37b..ef41a5b 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -1,6 +1,7 @@ default_config: +debugpy: - logger: - default: error - logs: - custom_components.sia: debug \ No newline at end of file +logger: + default: info + logs: + custom_components.sia: debug \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28af27c..eff1f12 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration", + "image": "ludeeus/container:integration-debian", "context": "..", "appPort": [ "9123:8123" @@ -8,12 +8,16 @@ "postCreateCommand": "container install", "runArgs": [ "-v", - "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" + "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh", + // "--network=host", + "--add-host=host.docker.internal:host-gateway" ], "extensions": [ "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", "github.vscode-pull-request-github", - "tabnine.tabnine-vscode" + "redhat.vscode-yaml", + "esbenp.prettier-vscode" ], "settings": { "files.eol": "\n", diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f006b7f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Debug by attaching to local Home Asistant server using Remote Python Debugger. + // See https://www.home-assistant.io/integrations/debugpy/ + "name": "Home Assistant: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ], + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c0f3d6..678c1b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,5 @@ "files.associations": { "*.yaml": "home-assistant" }, - "python.pythonPath": "C:\\Anaconda3\\python.exe" + "python.pythonPath": "/usr/local/bin/python" } \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index ba14212..9e447f2 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -3,11 +3,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - -from .const import DOMAIN, PLATFORMS +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +from .const import DOMAIN, SIA_HUB, DATA_UNSUBSCRIBE from .hub import SIAHub +PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] + + async def async_setup(hass: HomeAssistant, config: dict): """Set up the sia component.""" hass.data.setdefault(DOMAIN, {}) @@ -17,8 +26,15 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up sia from a config entry.""" hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) + await hub.async_setup_hub() - hass.data[DOMAIN][entry.entry_id] = hub + + unsub = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hub.async_shutdown) + + hass.data[DOMAIN][entry.entry_id] = { + SIA_HUB: hub, + DATA_UNSUBSCRIBE: unsub, + } for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) @@ -37,10 +53,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ] ) ) + info = hass.data[DOMAIN].pop(entry.entry_id) - if unload_ok: - await hass.data[DOMAIN][entry.entry_id].sia_client.stop() - hass.data[DOMAIN][entry.entry_id].shutdown_remove_listener() - hass.data[DOMAIN].pop(entry.entry_id) + info[DATA_UNSUBSCRIBE]() + await info[SIA_HUB].sia_client.stop() return unload_ok diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index c61fc5d..e0d5673 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -9,6 +9,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_PORT, CONF_ZONE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, @@ -17,33 +18,79 @@ STATE_ALARM_TRIGGERED, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import callback, Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, + CONF_ACCOUNTS, CONF_PING_INTERVAL, + CONF_ZONES, DATA_UPDATED, + EVENT_CODE, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + SIA_EVENT, DOMAIN, PING_INTERVAL_MARGIN, - PREVIOUS_STATE, ) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) +DEVICE_CLASS_ALARM = "alarm" +PREVIOUS_STATE = "previous_state" + +CODE_CONSEQUENCES = { + "BA": STATE_ALARM_TRIGGERED, + "PA": STATE_ALARM_TRIGGERED, + "JA": STATE_ALARM_TRIGGERED, + "TA": STATE_ALARM_TRIGGERED, + "CA": STATE_ALARM_ARMED_AWAY, + "CG": STATE_ALARM_ARMED_AWAY, + "CL": STATE_ALARM_ARMED_AWAY, + "CP": STATE_ALARM_ARMED_AWAY, + "CQ": STATE_ALARM_ARMED_AWAY, + "CS": STATE_ALARM_ARMED_AWAY, + "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, + "OA": STATE_ALARM_DISARMED, + "OG": STATE_ALARM_DISARMED, + "OP": STATE_ALARM_DISARMED, + "OQ": STATE_ALARM_DISARMED, + "OR": STATE_ALARM_DISARMED, + "OS": STATE_ALARM_DISARMED, + "NC": STATE_ALARM_ARMED_NIGHT, + "NL": STATE_ALARM_ARMED_NIGHT, + "BR": PREVIOUS_STATE, + "NP": PREVIOUS_STATE, + "NO": PREVIOUS_STATE, +} + async def async_setup_entry( - hass, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] ) -> bool: - """Set up sia_alarm_control_panel from a config entry.""" + """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_devices( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIAAlarmControlPanel) + SIAAlarmControlPanel( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + zone, + acc[CONF_PING_INTERVAL], + ) + for acc in entry.data[CONF_ACCOUNTS] + for zone in range(1, acc[CONF_ZONES] + 1) ] ) return True @@ -68,7 +115,9 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = ping_interval + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._should_poll = False self._is_available = True @@ -77,8 +126,11 @@ def __init__( self._old_state = None self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: str(self._ping_interval), + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -109,6 +161,36 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the unavailability and event listener.""" + if self._unsub: + self._unsub() + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update states. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + await self.assume_available() + if int(event.data[EVENT_ZONE]) == self._zone: + new_state = CODE_CONSEQUENCES.get(event.data[EVENT_CODE]) + if new_state: + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + self.state = new_state @callback def _schedule_immediate_update(self): @@ -183,6 +265,7 @@ async def _async_track_unavailable(self) -> bool: ) if not self._is_available: self._is_available = True + self.async_schedule_update_ha_state() return True return False diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index f99dce0..a3a1645 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -6,38 +6,97 @@ from homeassistant.components.binary_sensor import ( ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, BinarySensorEntity, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SMOKE, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, callback, Event from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, + CONF_ACCOUNTS, CONF_PING_INTERVAL, + CONF_ZONES, DATA_UPDATED, + EVENT_CODE, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + HUB_ZONE, + SIA_EVENT, DOMAIN, PING_INTERVAL_MARGIN, ) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) +ZONE_DEVICES = [ + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_SMOKE, +] +CODE_CONSEQUENCES = { + "AT": (DEVICE_CLASS_POWER, False), + "AR": (DEVICE_CLASS_POWER, True), + "GA": (DEVICE_CLASS_SMOKE, True), + "GH": (DEVICE_CLASS_SMOKE, False), + "FA": (DEVICE_CLASS_SMOKE, True), + "FH": (DEVICE_CLASS_SMOKE, False), + "KA": (DEVICE_CLASS_SMOKE, True), + "KH": (DEVICE_CLASS_SMOKE, False), + "WA": (DEVICE_CLASS_MOISTURE, True), + "WH": (DEVICE_CLASS_MOISTURE, False), +} + async def async_setup_entry( - hass, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] ) -> bool: """Set up sia_binary_sensor from a config entry.""" - async_add_devices( + + devices = [ + SIABinarySensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + zone, + acc[CONF_PING_INTERVAL], + device_class, + ) + for acc in entry.data[CONF_ACCOUNTS] + for zone in range(1, acc[CONF_ZONES] + 1) + for device_class in ZONE_DEVICES + ] + devices.extend( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIABinarySensor) + SIABinarySensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + DEVICE_CLASS_POWER, + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + acc[CONF_PING_INTERVAL], + DEVICE_CLASS_POWER, + ) + for acc in entry.data[CONF_ACCOUNTS] ] ) - + async_add_devices(devices) return True @@ -48,11 +107,11 @@ def __init__( self, entity_id: str, name: str, - device_class: str, port: int, account: str, zone: int, ping_interval: int, + device_class: str, ): """Create SIABinarySensor object.""" self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) @@ -62,7 +121,9 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = ping_interval + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._should_poll = False self._is_on = None @@ -70,8 +131,11 @@ def __init__( self._remove_unavailability_tracker = None self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: str(self._ping_interval), + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -87,12 +151,47 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the unavailability and event listener.""" + if self._unsub: + self._unsub() + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() @callback def _schedule_immediate_update(self): """Schedule update.""" self.async_schedule_update_ha_state(True) + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update states. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + await self.assume_available() + if ( + int(event.data[EVENT_ZONE]) == self._zone + or self._device_class == DEVICE_CLASS_POWER + ): + device_class, new_state = CODE_CONSEQUENCES.get( + event.data[EVENT_CODE], (None, None) + ) + if new_state is not None and device_class == self._device_class: + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + self.state = new_state + @property def name(self) -> str: """Return name.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 040e635..f00649a 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,12 +1,9 @@ """Constants for the sia integration.""" - from datetime import timedelta -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +ATTR_LAST_MESSAGE = "last_message" +ATTR_LAST_CODE = "last_code" +ATTR_LAST_TIMESTAMP = "last_timestamp" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" @@ -14,65 +11,20 @@ CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" CONF_ZONES = "zones" + DOMAIN = "sia" +DATA_UNSUBSCRIBE = "unsubs" DATA_UPDATED = f"{DOMAIN}_data_updated" -DEFAULT_NAME = "SIA Alarm" -DEVICE_CLASS_ALARM = "alarm" +SIA_HUB = "sia_hub" +SIA_EVENT = "sia_event" HUB_SENSOR_NAME = "last_heartbeat" HUB_ZONE = 0 PING_INTERVAL_MARGIN = timedelta(seconds=30) -PREVIOUS_STATE = "previous_state" -UTCNOW = "utcnow" -LAST_MESSAGE = "last_message" - -PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN, ALARM_CONTROL_PANEL_DOMAIN] - -REACTIONS = { - "AT": {"type": "power", "new_state": False}, - "AR": {"type": "power", "new_state": True}, - "BA": {"type": "alarm", "new_state": "triggered"}, - "PA": {"type": "alarm", "new_state": "triggered"}, - "JA": {"type": "alarm", "new_state": "triggered"}, - "BR": {"type": "alarm", "new_state": "previous_state"}, - "CA": {"type": "alarm", "new_state": "armed_away"}, - "CF": {"type": "alarm", "new_state": "armed_custom_bypass"}, - "CG": {"type": "alarm", "new_state": "armed_away"}, - "CL": {"type": "alarm", "new_state": "armed_away"}, - "CP": {"type": "alarm", "new_state": "armed_away"}, - "CQ": {"type": "alarm", "new_state": "armed_away"}, - "CS": {"type": "alarm", "new_state": "armed_away"}, - "GA": {"type": "smoke", "new_state": True}, - "GH": {"type": "smoke", "new_state": False}, - "FA": {"type": "smoke", "new_state": True}, - "FH": {"type": "smoke", "new_state": False}, - "KA": {"type": "smoke", "new_state": True}, - "KH": {"type": "smoke", "new_state": False}, - "NC": {"type": "alarm", "new_state": "armed_night"}, - "NL": {"type": "alarm", "new_state": "armed_night"}, - "NP": {"type": "alarm", "new_state": "previous_state"}, - "NO": {"type": "alarm", "new_state": "previous_state"}, - "OA": {"type": "alarm", "new_state": "disarmed"}, - "OG": {"type": "alarm", "new_state": "disarmed"}, - "OP": {"type": "alarm", "new_state": "disarmed"}, - "OQ": {"type": "alarm", "new_state": "disarmed"}, - "OR": {"type": "alarm", "new_state": "disarmed"}, - "OS": {"type": "alarm", "new_state": "disarmed"}, - "RP": {"type": "timestamp", "new_state_eval": "utcnow"}, - "TA": {"type": "alarm", "new_state": "triggered"}, - "WA": {"type": "moisture", "new_state": True}, - "WH": {"type": "moisture", "new_state": False}, - "YG": {"type": "timestamp", "attr": "last_message"}, - "YC": {"type": "timestamp", "attr": "last_message"}, - "XI": {"type": "timestamp", "attr": "last_message"}, - "YM": {"type": "timestamp", "attr": "last_message"}, - "YA": {"type": "timestamp", "attr": "last_message"}, - "YS": {"type": "timestamp", "attr": "last_message"}, - "XQ": {"type": "timestamp", "attr": "last_message"}, - "XH": {"type": "timestamp", "attr": "last_message"}, - "YT": {"type": "timestamp", "attr": "last_message"}, - "YR": {"type": "timestamp", "attr": "last_message"}, - "TR": {"type": "timestamp", "attr": "last_message"}, - "ZZ": {"type": "timestamp", "attr": "last_message"}, - "ZY": {"type": "timestamp", "attr": "last_message"}, -} +EVENT_CODE = "code" +EVENT_ACCOUNT = "account" +EVENT_ZONE = "zone" +EVENT_PORT = "port" +EVENT_MESSAGE = "message" +EVENT_ID = "id" +EVENT_TIMESTAMP = "timestamp" diff --git a/custom_components/sia/helpers.py b/custom_components/sia/helpers.py new file mode 100644 index 0000000..d9aa371 --- /dev/null +++ b/custom_components/sia/helpers.py @@ -0,0 +1,43 @@ +from typing import Tuple +from datetime import timedelta + +from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from .const import HUB_SENSOR_NAME, HUB_ZONE + + +def GET_ENTITY_AND_NAME( + port: int, account: str, zone: int = 0, entity_type: str = None +) -> Tuple[str, str]: + """Give back a entity_id and name according to the variables.""" + if zone == HUB_ZONE: + entity_type_name = ( + "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" + ) + return ( + GET_ENTITY_ID(port, account, zone, entity_type), + f"{port} - {account} - {entity_type_name}", + ) + if entity_type: + return ( + GET_ENTITY_ID(port, account, zone, entity_type), + f"{port} - {account} - zone {zone} - {entity_type}", + ) + return None + + +def GET_PING_INTERVAL(ping: int) -> timedelta: + """Return the ping interval as timedelta.""" + return timedelta(minutes=ping) + + +def GET_ENTITY_ID( + port: int, account: str, zone: int = 0, entity_type: str = None +) -> str: + """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" + if zone == HUB_ZONE: + if entity_type == DEVICE_CLASS_TIMESTAMP: + return f"{port}_{account}_{HUB_SENSOR_NAME}" + return f"{port}_{account}_{entity_type}" + if entity_type: + return f"{port}_{account}_{zone}_{entity_type}" + return None diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 19dd973..76904f0 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,43 +1,31 @@ """The sia hub.""" import asyncio from datetime import timedelta +from typing import Tuple import logging from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_POWER, -) -from homeassistant.const import ( - CONF_PORT, - CONF_SENSORS, - CONF_ZONE, - DEVICE_CLASS_TIMESTAMP, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.core import Event, EventOrigin +from homeassistant.const import CONF_PORT from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util.dt import utcnow +from homeassistant.helpers.typing import EventType -from .alarm_control_panel import SIAAlarmControlPanel -from .binary_sensor import SIABinarySensor from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, - CONF_PING_INTERVAL, - CONF_ZONES, - DEVICE_CLASS_ALARM, + EVENT_CODE, + EVENT_ACCOUNT, + EVENT_ZONE, + EVENT_PORT, + EVENT_MESSAGE, + EVENT_ID, + EVENT_TIMESTAMP, DOMAIN, - HUB_SENSOR_NAME, - HUB_ZONE, - LAST_MESSAGE, - REACTIONS, - UTCNOW, + SIA_EVENT, ) -from .sensor import SIASensor _LOGGER = logging.getLogger(__name__) @@ -50,46 +38,18 @@ def __init__( ): """Create the SIAHub.""" self._hass = hass - self.states = {} self._port = int(hub_config[CONF_PORT]) self.entry_id = entry_id self._title = title self._accounts = hub_config[CONF_ACCOUNTS] - self.shutdown_remove_listener = None - self._reactions = REACTIONS - - self._zones = [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: HUB_ZONE, - CONF_SENSORS: [DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_POWER], - } - for a in self._accounts - ] - self._zones.extend( - [ - { - CONF_ACCOUNT: a[CONF_ACCOUNT], - CONF_ZONE: z, - CONF_SENSORS: [ - DEVICE_CLASS_ALARM, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, - ], - } - for a in self._accounts - for z in range(1, int(a[CONF_ZONES]) + 1) - ] - ) self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY)) + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), (300, 150)) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.update_states + "", self._port, self.sia_accounts, self.async_create_and_fire_event ) - self._create_sensors() async def async_setup_hub(self): """Add a device to the device_registry, register shutdown listener, load reactions.""" @@ -102,128 +62,24 @@ async def async_setup_hub(self): identifiers={(DOMAIN, port, account)}, name=f"{port} - {account}", ) - self.shutdown_remove_listener = self._hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_shutdown - ) async def async_shutdown(self, _: Event): """Shutdown the SIA server.""" await self.sia_client.stop() - def _create_sensors(self): - """Create all the sensors.""" - for zone in self._zones: - ping = self._get_ping_interval(zone[CONF_ACCOUNT]) - for entity_type in zone[CONF_SENSORS]: - self._create_sensor( - self._port, zone[CONF_ACCOUNT], zone[CONF_ZONE], entity_type, ping - ) - - def _create_sensor( - self, port: int, account: str, zone: int, entity_type: str, ping: int - ): - """Check if the entity exists, and creates otherwise.""" - entity_id, entity_name = self._get_entity_id_and_name( - account, zone, entity_type + async def async_create_and_fire_event(self, event: SIAEvent): + """Create a event on HA's bus, with the data from the SIAEvent.""" + event_data = { + EVENT_PORT: self._port, + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } + self._hass.bus.async_fire( + f"{SIA_EVENT}_{self._port}_{event.account}", + event_data, + origin=EventOrigin.remote, ) - if entity_type == DEVICE_CLASS_ALARM: - self.states[entity_id] = SIAAlarmControlPanel( - entity_id, entity_name, port, account, zone, ping - ) - return - if entity_type in (DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE, DEVICE_CLASS_POWER): - self.states[entity_id] = SIABinarySensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - return - if entity_type == DEVICE_CLASS_TIMESTAMP: - self.states[entity_id] = SIASensor( - entity_id, entity_name, entity_type, port, account, zone, ping - ) - - def _get_entity_id_and_name( - self, account: str, zone: int = 0, entity_type: str = None - ): - """Give back a entity_id and name according to the variables.""" - if zone == 0: - entity_type_name = "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - {entity_type_name}", - ) - if entity_type: - return ( - self._get_entity_id(account, zone, entity_type), - f"{self._port} - {account} - zone {zone} - {entity_type}", - ) - return None - - def _get_entity_id(self, account: str, zone: int = 0, entity_type: str = None): - """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == 0: - if entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{self._port}_{account}_{HUB_SENSOR_NAME}" - return f"{self._port}_{account}_{entity_type}" - if entity_type: - return f"{self._port}_{account}_{zone}_{entity_type}" - return None - - def _get_ping_interval(self, account: str): - """Return the ping interval for specified account.""" - for acc in self._accounts: - if acc[CONF_ACCOUNT] == account: - return timedelta(minutes=acc[CONF_PING_INTERVAL]) - return None - - async def update_states(self, event: SIAEvent): - """Update the sensors. This can be both a new state and a new attribute. - - Whenever a message comes in and is a event that should cause a reaction, the connection is good, so reset the availability timer for all devices of that account, excluding the last heartbeat. - - """ - - # ignore exceptions (those are returned now, but not read) to deal with disabled sensors. - await asyncio.gather( - *[ - entity.assume_available() - for entity in self.states.values() - if entity.account == event.account and not isinstance(entity, SIASensor) - ], - return_exceptions=True, - ) - - # find the reactions for that code (if any) - reaction = self._reactions.get(event.code) - if not reaction: - _LOGGER.info( - "Unhandled event code, will be set as attribute in the heartbeat sensor. Code is: %s, Message: %s, Full event: %s", - event.code, - event.message, - event.sia_string, - ) - reaction = {"type": DEVICE_CLASS_TIMESTAMP, "attr": LAST_MESSAGE} - attr = reaction.get("attr") - new_state = reaction.get("new_state") - new_state_eval = reaction.get("new_state_eval") - entity_id = self._get_entity_id( - event.account, int(event.ri), reaction["type"] - ) - - #update state - if new_state is not None: - self.states[entity_id].state = new_state - elif new_state_eval is not None: - if new_state_eval == UTCNOW: - self.states[entity_id].state = utcnow() - - #update standard attributes of the touched sensor and if necessary the last_message or other attributes - self.states[entity_id].add_attribute( { "last_message": {event.message} }) - self.states[entity_id].add_attribute( { "last_code": {event.code} }) - self.states[entity_id].add_attribute( { "last_update": {utcnow().isoformat()} }) - if attr is not None: - if attr == LAST_MESSAGE: - self.states[entity_id].add_attribute( - { - "last_sia_event_string": "SIA: {event.sia_string}" - } - ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 5012576..267169e 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-3" + "pysiaalarm==2.0.9beta-5" ], "codeowners": [ "@eavanvalkenburg" diff --git a/custom_components/sia/reactions.json b/custom_components/sia/reactions.json deleted file mode 100644 index 1d2b071..0000000 --- a/custom_components/sia/reactions.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "BA": { "type": "alarm", "new_state": "triggered" }, - "BR": { "type": "alarm", "new_state": "previous_state" }, - "CA": { "type": "alarm", "new_state": "armed_away" }, - "CF": { - "type": "alarm", - "new_state": "armed_custom_bypass" - }, - "CG": { "type": "alarm", "new_state": "armed_away" }, - "CL": { "type": "alarm", "new_state": "armed_away" }, - "CP": { "type": "alarm", "new_state": "armed_away" }, - "CQ": { "type": "alarm", "new_state": "armed_away" }, - "GA": { "type": "smoke", "new_state": true }, - "GH": { "type": "smoke", "new_state": false }, - "NL": { - "type": "alarm", - "new_state": "armed_night" - }, - "OA": { "type": "alarm", "new_state": "disarmed" }, - "OG": { "type": "alarm", "new_state": "disarmed" }, - "OP": { "type": "alarm", "new_state": "disarmed" }, - "OQ": { "type": "alarm", "new_state": "disarmed" }, - "OR": { "type": "alarm", "new_state": "disarmed" }, - "RP": { "type": "timestamp", "new_state_eval": "utcnow" }, - "TA": { "type": "alarm", "new_state": "triggered" }, - "WA": { "type": "moisture", "new_state": true }, - "WH": { "type": "moisture", "new_state": false }, - "YG": { "type": "timestamp", "attr": "last_message" } -} diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 9c5694d..52c2787 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -6,13 +6,28 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ZONE -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP +from homeassistant.core import HomeAssistant, callback, Event from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow -from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DATA_UPDATED, DOMAIN +from .const import ( + ATTR_LAST_CODE, + ATTR_LAST_MESSAGE, + ATTR_LAST_TIMESTAMP, + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_PING_INTERVAL, + DATA_UPDATED, + EVENT_CODE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, + HUB_ZONE, + SIA_EVENT, + DOMAIN, +) +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -23,12 +38,22 @@ async def async_setup_entry( """Set up sia_sensor from a config entry.""" async_add_devices( [ - device - for device in hass.data[DOMAIN][entry.entry_id].states.values() - if isinstance(device, SIASensor) + SIASensor( + *GET_ENTITY_AND_NAME( + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + DEVICE_CLASS_TIMESTAMP, + ), + entry.data[CONF_PORT], + acc[CONF_ACCOUNT], + HUB_ZONE, + acc[CONF_PING_INTERVAL], + DEVICE_CLASS_TIMESTAMP, + ) + for acc in entry.data[CONF_ACCOUNTS] ] ) - return True @@ -39,11 +64,11 @@ def __init__( self, entity_id: str, name: str, - device_class: str, port: int, account: str, zone: int, ping_interval: int, + device_class: str, ): """Create SIASensor object.""" self.entity_id = SENSOR_FORMAT.format(entity_id) @@ -53,14 +78,19 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = str(ping_interval) + self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" + self._unsub = None self._state = utcnow() self._should_poll = False self._attr = { CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self._ping_interval, + CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, + ATTR_LAST_MESSAGE: None, + ATTR_LAST_CODE: None, + ATTR_LAST_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -74,17 +104,46 @@ async def async_added_to_hass(self): async_dispatcher_connect( self.hass, DATA_UPDATED, self._schedule_immediate_update ) + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) + self.async_on_remove(self._sia_on_remove) + + @callback + def _sia_on_remove(self): + """Remove the event listener.""" + if self._unsub: + self._unsub() @callback def _schedule_immediate_update(self): """Schedule update.""" self.async_schedule_update_ha_state(True) + async def async_handle_event(self, event: Event): + """Listen to events for this port and account and update the state and attributes.""" + self._attr.update( + { + ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], + ATTR_LAST_CODE: event.data[EVENT_CODE], + ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], + } + ) + if event.data[EVENT_CODE] == "RP": + self.state = utcnow() + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() + @property def name(self) -> str: """Return name.""" return self._name + @property + def ping_interval(self) -> int: + """Get ping_interval.""" + return str(self._ping_interval) + @property def unique_id(self) -> str: """Get unique_id.""" @@ -116,7 +175,7 @@ def should_poll(self) -> bool: False if entity pushes its state to HA. """ return False - + @property def device_class(self) -> str: """Return device class.""" @@ -126,8 +185,6 @@ def device_class(self) -> str: def state(self, state: dt.datetime): """Set state.""" self._state = state - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() @property def icon(self) -> str: diff --git a/test_zc.py b/test_zc.py new file mode 100644 index 0000000..f754477 --- /dev/null +++ b/test_zc.py @@ -0,0 +1,20 @@ +from zeroconf import ServiceBrowser, Zeroconf + + +class MyListener: + + def remove_service(self, zeroconf, type, name): + print("Service %s removed" % (name,)) + + def add_service(self, zeroconf, type, name): + info = zeroconf.get_service_info(type, name) + print("Service %s added, service info: %s" % (name, info)) + + +zeroconf = Zeroconf(apple_p2p=False) +listener = MyListener() +browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener) +try: + input("Press enter to exit...\n\n") +finally: + zeroconf.close() \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e69de29 From 1cc84a13b4e93a3f3d4a3ec2645f6412013caeec Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 1 Feb 2021 15:42:06 +0000 Subject: [PATCH 46/63] cleaned up code --- custom_components/sia/__init__.py | 19 +++------- custom_components/sia/alarm_control_panel.py | 9 ++--- custom_components/sia/binary_sensor.py | 9 ++--- custom_components/sia/const.py | 2 - custom_components/sia/hub.py | 39 +++++++++++--------- custom_components/sia/sensor.py | 9 +---- 6 files changed, 35 insertions(+), 52 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 9e447f2..c1d4ff2 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -3,14 +3,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from .const import DOMAIN, SIA_HUB, DATA_UNSUBSCRIBE +from .const import DOMAIN from .hub import SIAHub @@ -29,16 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): await hub.async_setup_hub() - unsub = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hub.async_shutdown) - - hass.data[DOMAIN][entry.entry_id] = { - SIA_HUB: hub, - DATA_UNSUBSCRIBE: unsub, - } + hass.data[DOMAIN][entry.entry_id] = hub for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) + hub.sia_client.start(reuse_port=True) return True @@ -53,9 +48,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ] ) ) - info = hass.data[DOMAIN].pop(entry.entry_id) - - info[DATA_UNSUBSCRIBE]() - await info[SIA_HUB].sia_client.stop() - + if unload_ok: + hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) + await hub.async_shutdown() return unload_ok diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index e0d5673..ae69534 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -191,6 +191,8 @@ async def async_handle_event(self, event: Event): } ) self.state = new_state + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() @callback def _schedule_immediate_update(self): @@ -233,10 +235,7 @@ def device_state_attributes(self) -> dict: @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @state.setter @@ -245,8 +244,6 @@ def state(self, state: str): temp = self._old_state if state == PREVIOUS_STATE else state self._old_state = self._state self._state = temp - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index a3a1645..9be2e7d 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -191,6 +191,8 @@ async def async_handle_event(self, event: Event): } ) self.state = new_state + if not self.registry_entry.disabled: + self.async_schedule_update_ha_state() @property def name(self) -> str: @@ -241,18 +243,13 @@ def is_on(self) -> bool: @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @state.setter def state(self, new_on: bool): """Set state.""" self._is_on = new_on - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() async def assume_available(self): """Reset unavalability tracker.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index f00649a..5c574b7 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -13,9 +13,7 @@ CONF_ZONES = "zones" DOMAIN = "sia" -DATA_UNSUBSCRIBE = "unsubs" DATA_UPDATED = f"{DOMAIN}_data_updated" -SIA_HUB = "sia_hub" SIA_EVENT = "sia_event" HUB_SENSOR_NAME = "last_heartbeat" HUB_ZONE = 0 diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 76904f0..be62073 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -6,11 +6,9 @@ from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent -from homeassistant.core import Event, EventOrigin -from homeassistant.const import CONF_PORT -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, EventOrigin, HomeAssistant +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import EventType from .const import ( CONF_ACCOUNT, @@ -27,6 +25,8 @@ SIA_EVENT, ) +ALLOWED_TIMEBAND = (300, 150) + _LOGGER = logging.getLogger(__name__) @@ -43,8 +43,9 @@ def __init__( self._title = title self._accounts = hub_config[CONF_ACCOUNTS] + self._remove_shutdown_listener = None self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), (300, 150)) + SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), ALLOWED_TIMEBAND) for a in self._accounts ] self.sia_client = SIAClient( @@ -62,24 +63,28 @@ async def async_setup_hub(self): identifiers={(DOMAIN, port, account)}, name=f"{port} - {account}", ) + self._remove_shutdown_listener = self._hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self.async_shutdown + ) - async def async_shutdown(self, _: Event): + async def async_shutdown(self, _: Event = None): """Shutdown the SIA server.""" + if self._remove_shutdown_listener: + self._remove_shutdown_listener() await self.sia_client.stop() async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" - event_data = { - EVENT_PORT: self._port, - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - } self._hass.bus.async_fire( - f"{SIA_EVENT}_{self._port}_{event.account}", - event_data, + event_type=f"{SIA_EVENT}_{self._port}_{event.account}", + event_data={ + EVENT_PORT: self._port, + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + }, origin=EventOrigin.remote, ) diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 52c2787..7bb54e6 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -164,16 +164,9 @@ def device_state_attributes(self) -> dict: """Return attributes.""" return self._attr - def add_attribute(self, attr: dict): - """Update attributes.""" - self._attr.update(attr) - @property def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ + """Return False if entity pushes its state to HA.""" return False @property From 5ed289583167766c028dafca56a743045e8a06f5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 5 Mar 2021 07:55:50 +0000 Subject: [PATCH 47/63] add version to manifest --- custom_components/sia/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 44c7886..b5f0f25 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -8,5 +8,6 @@ ], "codeowners": [ "@eavanvalkenburg" - ] + ], + "version": "0.4.0b2" } From cf6bd7941b8e95ffb95a06c1292921986aa22575 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 5 Mar 2021 07:58:05 +0000 Subject: [PATCH 48/63] added version to container --- config/configuration.yaml | 6 ++++++ custom_components/sia/manifest.json | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 config/configuration.yaml 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/manifest.json b/custom_components/sia/manifest.json index 5012576..ee12b67 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -8,5 +8,6 @@ ], "codeowners": [ "@eavanvalkenburg" - ] + ], + "version": "0.3.11" } \ No newline at end of file From 804b21044c4b7dd09ee2e29baa64fff54d0955d3 Mon Sep 17 00:00:00 2001 From: Qxlkdr <33372537+Qxlkdr@users.noreply.github.com> Date: Thu, 25 Mar 2021 12:38:26 +0100 Subject: [PATCH 49/63] Update README.md Updated Account/Object number and added information in step 9. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30a9974..f8c3101 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,13 @@ Platform | Description 1. Select "SIA Protocol". 2. Enable "Connect on demand". -3. Place Account Id - 3-16 ASCII hex characters. For example AAA. +3. Place Account Id/Object number - 3-16 ASCII hex characters. For example AAA. 4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. 5. Insert Home Assistant listening port. This port must not be used with anything else. 6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. 7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. 8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. +9. Keep in mind that Monitoring Station will say "Connected" in the app if configured correctly. The sensors will have state "Unknown" until they get a new state. Arm/disarm to update the alarm sensor as an example. ## Installation From 4bca2047db1190c156c015564c1671fa7a176f1f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 7 Apr 2021 16:05:45 +0000 Subject: [PATCH 50/63] update to latest version of package, improvements in code --- .devcontainer/configuration.yaml | 2 +- .devcontainer/devcontainer.json | 6 +- custom_components/sia/__init__.py | 7 +- custom_components/sia/alarm_control_panel.py | 71 ++++++++-------- custom_components/sia/binary_sensor.py | 66 +++++++-------- custom_components/sia/config_flow.py | 86 ++++++++++---------- custom_components/sia/const.py | 14 ++-- custom_components/sia/helpers.py | 29 ++++++- custom_components/sia/hub.py | 31 ++----- custom_components/sia/manifest.json | 4 +- custom_components/sia/sensor.py | 54 ++++-------- hacs.json | 2 +- 12 files changed, 170 insertions(+), 202 deletions(-) diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index ef41a5b..de5ad0e 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -4,4 +4,4 @@ debugpy: logger: default: info logs: - custom_components.sia: debug \ No newline at end of file + custom_components.sia: debug diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index eff1f12..4f7329d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,6 +30,8 @@ "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } + "files.trimTrailingWhitespace": true, + "remote.autoForwardPorts": false + }, + "forwardPorts": [5678, 8126] } \ No newline at end of file diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index c1d4ff2..e491af9 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,18 +1,17 @@ """The sia integration.""" import asyncio -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .const import DOMAIN from .hub import SIAHub - PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] @@ -34,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) - hub.sia_client.start(reuse_port=True) + await hub.sia_client.start(reuse_port=True) return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index ae69534..2b5344d 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -2,6 +2,7 @@ import logging from typing import Callable +from pysiaalarm import SIAEvent from homeassistant.components.alarm_control_panel import ( ENTITY_ID_FORMAT as ALARM_FORMAT, @@ -25,15 +26,14 @@ from homeassistant.util.dt import utcnow from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, CONF_ZONES, DATA_UPDATED, + EVENT_ACCOUNT, EVENT_CODE, + EVENT_ID, EVENT_ZONE, EVENT_MESSAGE, EVENT_TIMESTAMP, @@ -41,7 +41,7 @@ DOMAIN, PING_INTERVAL_MARGIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) @@ -75,10 +75,10 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up SIA alarm_control_panel(s) from a config entry.""" - async_add_devices( + async_add_entities( [ SIAAlarmControlPanel( *GET_ENTITY_AND_NAME( @@ -119,7 +119,6 @@ def __init__( self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None - self._should_poll = False self._is_available = True self._remove_unavailability_tracker = None self._state = None @@ -128,9 +127,12 @@ def __init__( CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, + EVENT_ACCOUNT: None, + EVENT_CODE: None, + EVENT_ID: None, + EVENT_ZONE: None, + EVENT_MESSAGE: None, + EVENT_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -158,16 +160,14 @@ async def async_added_to_hass(self): else: self.state = None await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the unavailability and event listener.""" if self._unsub: self._unsub() @@ -180,23 +180,16 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ await self.assume_available() - if int(event.data[EVENT_ZONE]) == self._zone: - new_state = CODE_CONSEQUENCES.get(event.data[EVENT_CODE]) - if new_state: - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) - self.state = new_state - if not self.registry_entry.disabled: - self.async_schedule_update_ha_state() - - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) + sia_event = SIAEvent.from_dict(event.data) + if int(sia_event.ri) != self._zone: + return + new_state = CODE_CONSEQUENCES.get(sia_event.code) + if not new_state: + return + self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self.state = new_state + if self.enabled: + self.async_schedule_update_ha_state() @property def name(self) -> str: @@ -213,6 +206,13 @@ def state(self) -> str: """Get state.""" return self._state + @state.setter + def state(self, state: str): + """Set state.""" + temp = self._old_state if state == PREVIOUS_STATE else state + self._old_state = self._state + self._state = temp + @property def account(self) -> str: """Return device account.""" @@ -238,13 +238,6 @@ def should_poll(self) -> bool: """Return False if entity pushes its state to HA.""" return False - @state.setter - def state(self, state: str): - """Set state.""" - temp = self._old_state if state == PREVIOUS_STATE else state - self._old_state = self._state - self._state = temp - async def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 9be2e7d..80d5546 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -4,39 +4,41 @@ from typing import Callable from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, - BinarySensorEntity, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_POWER, DEVICE_CLASS_SMOKE, ) +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, +) +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow +from pysiaalarm import SIAEvent from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, CONF_ZONES, DATA_UPDATED, + DOMAIN, + EVENT_ACCOUNT, EVENT_CODE, + EVENT_ID, EVENT_ZONE, EVENT_MESSAGE, EVENT_TIMESTAMP, HUB_ZONE, - SIA_EVENT, - DOMAIN, PING_INTERVAL_MARGIN, + SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) @@ -59,7 +61,7 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up sia_binary_sensor from a config entry.""" @@ -96,7 +98,7 @@ async def async_setup_entry( for acc in entry.data[CONF_ACCOUNTS] ] ) - async_add_devices(devices) + async_add_entities(devices) return True @@ -125,7 +127,6 @@ def __init__( self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None - self._should_poll = False self._is_on = None self._is_available = True self._remove_unavailability_tracker = None @@ -133,9 +134,12 @@ def __init__( CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, + EVENT_ACCOUNT: None, + EVENT_CODE: None, + EVENT_ID: None, + EVENT_ZONE: None, + EVENT_MESSAGE: None, + EVENT_TIMESTAMP: None, } async def async_added_to_hass(self): @@ -148,50 +152,36 @@ async def async_added_to_hass(self): elif state.state == STATE_OFF: self._is_on = False await self._async_track_unavailable() - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the unavailability and event listener.""" if self._unsub: self._unsub() if self._remove_unavailability_tracker: self._remove_unavailability_tracker() - @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - async def async_handle_event(self, event: Event): """Listen to events for this port and account and update states. If the port and account combo receives any message it means it is online and can therefore be set to available. """ await self.assume_available() - if ( - int(event.data[EVENT_ZONE]) == self._zone - or self._device_class == DEVICE_CLASS_POWER - ): + sia_event = SIAEvent.from_dict(event.data) + sia_event.message_type = sia_event.message_type.value + if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: device_class, new_state = CODE_CONSEQUENCES.get( - event.data[EVENT_CODE], (None, None) + sia_event.code, (None, None) ) if new_state is not None and device_class == self._device_class: - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) + self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) self.state = new_state - if not self.registry_entry.disabled: + if self.enabled: self.async_schedule_update_ha_state() @property diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 4398c01..d9cd870 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,6 +1,10 @@ """Config flow for sia integration.""" import logging +import voluptuous as vol +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_PORT +from homeassistant.data_entry_flow import AbortFlow from pysiaalarm import ( InvalidAccountFormatError, InvalidAccountLengthError, @@ -8,11 +12,6 @@ InvalidKeyLengthError, SIAAccount, ) -import voluptuous as vol - -from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_PORT -from homeassistant.data_entry_flow import AbortFlow from .const import ( CONF_ACCOUNT, @@ -49,9 +48,9 @@ ) -def validate_input(data: dict) -> bool: +def validate_input(data: dict): """Validate the input by the user.""" - SIAAccount(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) + SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) try: ping = int(data[CONF_PING_INTERVAL]) @@ -64,8 +63,6 @@ def validate_input(data: dict) -> bool: except AssertionError: raise InvalidZones - return True - class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" @@ -79,12 +76,10 @@ async def async_step_add_account(self, user_input: dict = None): errors = {} if user_input is not None: try: - if validate_input(user_input): - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) - if user_input[CONF_ADDITIONAL_ACCOUNTS]: - return await self.async_step_add_account() + validate_input(user_input) + self.update_data(user_input) + if user_input[CONF_ADDITIONAL_ACCOUNTS]: + return await self.async_step_add_account() except InvalidKeyFormatError: errors["base"] = "invalid_key_format" except InvalidKeyLengthError: @@ -99,7 +94,9 @@ async def async_step_add_account(self, user_input: dict = None): errors["base"] = "invalid_zones" return self.async_show_form( - step_id="user", data_schema=ACCOUNT_SCHEMA, errors=errors, + step_id="user", + data_schema=ACCOUNT_SCHEMA, + errors=errors, ) async def async_step_user(self, user_input: dict = None): @@ -107,34 +104,16 @@ async def async_step_user(self, user_input: dict = None): errors = {} if user_input is not None: try: - if validate_input(user_input): - if not self.data: - self.data = { - CONF_PORT: user_input[CONF_PORT], - CONF_ACCOUNTS: [ - { - CONF_ACCOUNT: user_input[CONF_ACCOUNT], - CONF_ENCRYPTION_KEY: user_input.get( - CONF_ENCRYPTION_KEY - ), - CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], - CONF_ZONES: user_input[CONF_ZONES], - } - ], - } - else: - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) - await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") - self._abort_if_unique_id_configured() - - if not user_input[CONF_ADDITIONAL_ACCOUNTS]: - return self.async_create_entry( - title=f"SIA Alarm on port {self.data[CONF_PORT]}", - data=self.data, - ) - return await self.async_step_add_account() + validate_input(user_input) + await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") + self._abort_if_unique_id_configured() + self.update_data(user_input) + if not user_input[CONF_ADDITIONAL_ACCOUNTS]: + return self.async_create_entry( + title=f"SIA Alarm on port {self.data[CONF_PORT]}", + data=self.data, + ) + return await self.async_step_add_account() except InvalidKeyFormatError: errors["base"] = "invalid_key_format" except InvalidKeyLengthError: @@ -157,6 +136,25 @@ async def async_step_user(self, user_input: dict = None): step_id="user", data_schema=HUB_SCHEMA, errors=errors ) + def update_data(self, user_input): + """Parse the user_input and store in self.data.""" + if not self.data: + self.data = { + CONF_PORT: user_input[CONF_PORT], + CONF_ACCOUNTS: [ + { + CONF_ACCOUNT: user_input[CONF_ACCOUNT], + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), + CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], + CONF_ZONES: user_input[CONF_ZONES], + } + ], + } + else: + add_data = user_input.copy() + add_data.pop(CONF_ADDITIONAL_ACCOUNTS) + self.data[CONF_ACCOUNTS].append(add_data) + class InvalidPing(exceptions.HomeAssistantError): """Error to indicate there is invalid ping interval.""" diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 5c574b7..531afc0 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,9 +1,6 @@ """Constants for the sia integration.""" from datetime import timedelta -ATTR_LAST_MESSAGE = "last_message" -ATTR_LAST_CODE = "last_code" -ATTR_LAST_TIMESTAMP = "last_timestamp" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" @@ -19,10 +16,9 @@ HUB_ZONE = 0 PING_INTERVAL_MARGIN = timedelta(seconds=30) -EVENT_CODE = "code" -EVENT_ACCOUNT = "account" +EVENT_CODE = "last_code" +EVENT_ACCOUNT = "last_account" EVENT_ZONE = "zone" -EVENT_PORT = "port" -EVENT_MESSAGE = "message" -EVENT_ID = "id" -EVENT_TIMESTAMP = "timestamp" +EVENT_MESSAGE = "last_message" +EVENT_ID = "last_id" +EVENT_TIMESTAMP = "last_timestamp" diff --git a/custom_components/sia/helpers.py b/custom_components/sia/helpers.py index d9aa371..5c78988 100644 --- a/custom_components/sia/helpers.py +++ b/custom_components/sia/helpers.py @@ -1,8 +1,19 @@ -from typing import Tuple from datetime import timedelta +from typing import Tuple from homeassistant.const import DEVICE_CLASS_TIMESTAMP -from .const import HUB_SENSOR_NAME, HUB_ZONE +from pysiaalarm import SIAEvent + +from .const import ( + HUB_SENSOR_NAME, + HUB_ZONE, + EVENT_ACCOUNT, + EVENT_CODE, + EVENT_ID, + EVENT_ZONE, + EVENT_MESSAGE, + EVENT_TIMESTAMP, +) def GET_ENTITY_AND_NAME( @@ -41,3 +52,17 @@ def GET_ENTITY_ID( if entity_type: return f"{port}_{account}_{zone}_{entity_type}" return None + + +def SIA_EVENT_TO_ATTR(event: SIAEvent) -> dict: + """Create the attributes dict from a SIAEvent.""" + return ( + { + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } + ) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index be62073..1568460 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,26 +1,15 @@ """The sia hub.""" -import asyncio -from datetime import timedelta -from typing import Tuple import logging -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent - -from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.helpers import device_registry as dr +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, - EVENT_CODE, - EVENT_ACCOUNT, - EVENT_ZONE, - EVENT_PORT, - EVENT_MESSAGE, - EVENT_ID, - EVENT_TIMESTAMP, DOMAIN, SIA_EVENT, ) @@ -54,6 +43,7 @@ def __init__( async def async_setup_hub(self): """Add a device to the device_registry, register shutdown listener, load reactions.""" + _LOGGER.debug("Setting up SIA Hub.") device_registry = await dr.async_get_registry(self._hass) port = self._port for acc in self._accounts: @@ -75,16 +65,13 @@ async def async_shutdown(self, _: Event = None): async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" + # Get rid of account, because it might contain encryption key. + event.sia_account = None + # Change the message_type to value because otherwise it is not JSON serializable. + event.message_type = event.message_type.value + # Fire event! self._hass.bus.async_fire( event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data={ - EVENT_PORT: self._port, - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - }, + event_data=event.to_dict(), origin=EventOrigin.remote, ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index b5f0f25..cb5e29f 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==2.0.9beta-5" + "pysiaalarm==3.0.0b3" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.4.0b2" + "version": "0.5.0b1" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 7bb54e6..23186ba 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,5 +1,4 @@ """Module for SIA Sensors.""" - import datetime as dt import logging from typing import Callable @@ -7,36 +6,31 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP -from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow +from pysiaalarm import SIAEvent from .const import ( - ATTR_LAST_CODE, - ATTR_LAST_MESSAGE, - ATTR_LAST_TIMESTAMP, CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, DATA_UPDATED, - EVENT_CODE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, + DOMAIN, HUB_ZONE, SIA_EVENT, - DOMAIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL +from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_devices: Callable[[], None] + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] ) -> bool: """Set up sia_sensor from a config entry.""" - async_add_devices( + async_add_entities( [ SIASensor( *GET_ENTITY_AND_NAME( @@ -83,55 +77,39 @@ def __init__( self._unsub = None self._state = utcnow() - self._should_poll = False self._attr = { CONF_ACCOUNT: self._account, CONF_PING_INTERVAL: self.ping_interval, CONF_ZONE: self._zone, - ATTR_LAST_MESSAGE: None, - ATTR_LAST_CODE: None, - ATTR_LAST_TIMESTAMP: None, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Once the sensor is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") - else: - return - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + + async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) - self.async_on_remove(self._sia_on_remove) + self.async_on_remove(self._async_sia_on_remove) @callback - def _sia_on_remove(self): + def _async_sia_on_remove(self): """Remove the event listener.""" if self._unsub: self._unsub() - @callback - def _schedule_immediate_update(self): - """Schedule update.""" - self.async_schedule_update_ha_state(True) - async def async_handle_event(self, event: Event): """Listen to events for this port and account and update the state and attributes.""" - self._attr.update( - { - ATTR_LAST_MESSAGE: event.data[EVENT_MESSAGE], - ATTR_LAST_CODE: event.data[EVENT_CODE], - ATTR_LAST_TIMESTAMP: event.data[EVENT_TIMESTAMP], - } - ) - if event.data[EVENT_CODE] == "RP": + sia_event = SIAEvent.from_dict(event.data) + sia_event.message_type = sia_event.message_type.value + self._attr.update(sia_event.to_dict()) + if sia_event.code == "RP": self.state = utcnow() - if not self.registry_entry.disabled: + if self.enabled: self.async_schedule_update_ha_state() @property diff --git a/hacs.json b/hacs.json index 4117330..159968d 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "SIA", - "domains": ["binary_sensor"] + "domains": ["binary_sensor", "alarm_control_panel", "sensor"] } \ No newline at end of file From ead83a54c7ce81cf3fe94ad80f1f15da050f0892 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 12 Apr 2021 14:19:53 +0000 Subject: [PATCH 51/63] new package and in sync with PR --- custom_components/sia/__init__.py | 12 ++++++-- custom_components/sia/alarm_control_panel.py | 23 +++++++++----- custom_components/sia/binary_sensor.py | 27 ++++++++++------- custom_components/sia/hub.py | 7 +---- custom_components/sia/manifest.json | 4 +-- custom_components/sia/sensor.py | 16 ++++++---- .../sia/{helpers.py => utils.py} | 30 +++++++++---------- 7 files changed, 69 insertions(+), 50 deletions(-) rename custom_components/sia/{helpers.py => utils.py} (72%) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index e491af9..94c861c 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -7,7 +7,9 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .hub import SIAHub @@ -24,16 +26,20 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up sia from a config entry.""" hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) - await hub.async_setup_hub() hass.data[DOMAIN][entry.entry_id] = hub + try: + await hub.sia_client.start(reuse_port=True) + except OSError: + raise ConfigEntryNotReady( + "SIA Server at port %s could not start.", entry.data[CONF_PORT] + ) + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - - await hub.sia_client.start(reuse_port=True) return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 2b5344d..ca732de 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -41,7 +41,7 @@ DOMAIN, PING_INTERVAL_MARGIN, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ async def async_setup_entry( async_add_entities( [ SIAAlarmControlPanel( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM ), entry.data[CONF_PORT], @@ -115,7 +115,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -159,11 +159,18 @@ async def async_added_to_hass(self): self.state = state.state else: self.state = None - await self._async_track_unavailable() async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_alarm() + + def setup_sia_alarm(self): + """Run the setup of the alarm control panel.""" + self.assume_available() + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -186,7 +193,7 @@ async def async_handle_event(self, event: Event): new_state = CODE_CONSEQUENCES.get(sia_event.code) if not new_state: return - self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self._attr.update(sia_event_to_attr(sia_event)) self.state = new_state if self.enabled: self.async_schedule_update_ha_state() @@ -238,13 +245,13 @@ def should_poll(self) -> bool: """Return False if entity pushes its state to HA.""" return False - async def assume_available(self): + def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: - await self._async_track_unavailable() + self._async_track_unavailable() @callback - async def _async_track_unavailable(self) -> bool: + def _async_track_unavailable(self) -> bool: """Reset unavailability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 80d5546..642ad9f 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -38,7 +38,7 @@ PING_INTERVAL_MARGIN, SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ async def async_setup_entry( devices = [ SIABinarySensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class ), entry.data[CONF_PORT], @@ -83,7 +83,7 @@ async def async_setup_entry( devices.extend( [ SIABinarySensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], HUB_ZONE, @@ -123,7 +123,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -151,11 +151,18 @@ async def async_added_to_hass(self): self._is_on = True elif state.state == STATE_OFF: self._is_on = False - await self._async_track_unavailable() async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_entity() + + def setup_sia_entity(self): + """Run the setup of the alarm control panel.""" + self.assume_available() + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -171,7 +178,7 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ - await self.assume_available() + self.assume_available() sia_event = SIAEvent.from_dict(event.data) sia_event.message_type = sia_event.message_type.value if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: @@ -179,7 +186,7 @@ async def async_handle_event(self, event: Event): sia_event.code, (None, None) ) if new_state is not None and device_class == self._device_class: - self._attr.update(SIA_EVENT_TO_ATTR(sia_event)) + self._attr.update(sia_event_to_attr(sia_event)) self.state = new_state if self.enabled: self.async_schedule_update_ha_state() @@ -241,13 +248,13 @@ def state(self, new_on: bool): """Set state.""" self._is_on = new_on - async def assume_available(self): + def assume_available(self): """Reset unavalability tracker.""" if not self.registry_entry.disabled: - await self._async_track_unavailable() + self._async_track_unavailable() @callback - async def _async_track_unavailable(self) -> bool: + def _async_track_unavailable(self) -> bool: """Track availability.""" if self._remove_unavailability_tracker: self._remove_unavailability_tracker() diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 1568460..25592f7 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -65,13 +65,8 @@ async def async_shutdown(self, _: Event = None): async def async_create_and_fire_event(self, event: SIAEvent): """Create a event on HA's bus, with the data from the SIAEvent.""" - # Get rid of account, because it might contain encryption key. - event.sia_account = None - # Change the message_type to value because otherwise it is not JSON serializable. - event.message_type = event.message_type.value - # Fire event! self._hass.bus.async_fire( event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data=event.to_dict(), + event_data=event.to_dict(encode_json=True), origin=EventOrigin.remote, ) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index cb5e29f..4102100 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b3" + "pysiaalarm==3.0.0b4" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b1" + "version": "0.5.0b2" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 23186ba..6bfab53 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -21,7 +21,7 @@ HUB_ZONE, SIA_EVENT, ) -from .helpers import GET_ENTITY_AND_NAME, GET_PING_INTERVAL, SIA_EVENT_TO_ATTR +from .utils import get_entity_and_name, get_ping_interval _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities( [ SIASensor( - *GET_ENTITY_AND_NAME( + *get_entity_and_name( entry.data[CONF_PORT], acc[CONF_ACCOUNT], HUB_ZONE, @@ -72,7 +72,7 @@ def __init__( self._port = port self._account = account self._zone = zone - self._ping_interval = GET_PING_INTERVAL(ping_interval) + self._ping_interval = get_ping_interval(ping_interval) self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" self._unsub = None @@ -94,6 +94,13 @@ async def async_added_to_hass(self) -> None: self._unsub = self.hass.bus.async_listen( self._event_listener_str, self.async_handle_event ) + self.setup_sia_entity() + + def setup_sia_entity(self): + """Run the setup of the sensor.""" + self._unsub = self.hass.bus.async_listen( + self._event_listener_str, self.async_handle_event + ) self.async_on_remove(self._async_sia_on_remove) @callback @@ -105,8 +112,7 @@ def _async_sia_on_remove(self): async def async_handle_event(self, event: Event): """Listen to events for this port and account and update the state and attributes.""" sia_event = SIAEvent.from_dict(event.data) - sia_event.message_type = sia_event.message_type.value - self._attr.update(sia_event.to_dict()) + self._attr.update(event.data) if sia_event.code == "RP": self.state = utcnow() if self.enabled: diff --git a/custom_components/sia/helpers.py b/custom_components/sia/utils.py similarity index 72% rename from custom_components/sia/helpers.py rename to custom_components/sia/utils.py index 5c78988..ca51e02 100644 --- a/custom_components/sia/helpers.py +++ b/custom_components/sia/utils.py @@ -16,7 +16,7 @@ ) -def GET_ENTITY_AND_NAME( +def get_entity_and_name( port: int, account: str, zone: int = 0, entity_type: str = None ) -> Tuple[str, str]: """Give back a entity_id and name according to the variables.""" @@ -25,23 +25,23 @@ def GET_ENTITY_AND_NAME( "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" ) return ( - GET_ENTITY_ID(port, account, zone, entity_type), + get_entity_id(port, account, zone, entity_type), f"{port} - {account} - {entity_type_name}", ) if entity_type: return ( - GET_ENTITY_ID(port, account, zone, entity_type), + get_entity_id(port, account, zone, entity_type), f"{port} - {account} - zone {zone} - {entity_type}", ) return None -def GET_PING_INTERVAL(ping: int) -> timedelta: +def get_ping_interval(ping: int) -> timedelta: """Return the ping interval as timedelta.""" return timedelta(minutes=ping) -def GET_ENTITY_ID( +def get_entity_id( port: int, account: str, zone: int = 0, entity_type: str = None ) -> str: """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" @@ -54,15 +54,13 @@ def GET_ENTITY_ID( return None -def SIA_EVENT_TO_ATTR(event: SIAEvent) -> dict: +def sia_event_to_attr(event: SIAEvent) -> dict: """Create the attributes dict from a SIAEvent.""" - return ( - { - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, - } - ) + return { + EVENT_ACCOUNT: event.account, + EVENT_ZONE: event.ri, + EVENT_CODE: event.code, + EVENT_MESSAGE: event.message, + EVENT_ID: event.id, + EVENT_TIMESTAMP: event.timestamp, + } From 34db4a91032585833f1964a7283baa3d4fbe2636 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 07:03:01 +0000 Subject: [PATCH 52/63] fixed await assume_available --- custom_components/sia/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index ca732de..72bc612 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -186,7 +186,7 @@ async def async_handle_event(self, event: Event): If the port and account combo receives any message it means it is online and can therefore be set to available. """ - await self.assume_available() + self.assume_available() sia_event = SIAEvent.from_dict(event.data) if int(sia_event.ri) != self._zone: return From 8c5043364ac2a3778cd62a1560884a0d3a112db2 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 07:04:08 +0000 Subject: [PATCH 53/63] incremented version --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 4102100..d69c4ae 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,5 +9,5 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b2" + "version": "0.5.0b3" } From f2fe4d29d7f8ca9db5a5e872270e027eaf13c9c4 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 13 Apr 2021 14:09:06 +0000 Subject: [PATCH 54/63] new package and integration version incremented --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index d69c4ae..0c9eb8d 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b4" + "pysiaalarm==3.0.0b5" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b3" + "version": "0.5.0b4" } From bbe80eccbdc78869d3c87609b3b7d131410e39ad Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 15 Apr 2021 07:51:52 +0000 Subject: [PATCH 55/63] new package version --- custom_components/sia/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 0c9eb8d..8285981 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b5" + "pysiaalarm==3.0.0b6" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b4" + "version": "0.5.0b5" } From 5845a53e0b97979721d4fb6bb243dab4eaada209 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sat, 8 May 2021 11:34:47 +0000 Subject: [PATCH 56/63] new version of config_entry with migration and fix for #45 --- custom_components/sia/__init__.py | 19 ++- custom_components/sia/config_flow.py | 137 ++++++++++----------- custom_components/sia/const.py | 1 + custom_components/sia/manifest.json | 5 +- custom_components/sia/strings.json | 26 ++-- custom_components/sia/translations/en.json | 12 ++ 6 files changed, 119 insertions(+), 81 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 94c861c..1b28334 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,5 +1,6 @@ """The sia integration.""" import asyncio +import logging from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -7,7 +8,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -16,6 +17,8 @@ PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: dict): """Set up the sia component.""" @@ -57,3 +60,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) await hub.async_shutdown() return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + data = config_entry.data.copy() + data[CONF_PROTOCOL] = "TCP" + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=data) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + return True diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index d9cd870..442c324 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,10 +1,6 @@ """Config flow for sia integration.""" import logging -import voluptuous as vol -from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_PORT -from homeassistant.data_entry_flow import AbortFlow from pysiaalarm import ( InvalidAccountFormatError, InvalidAccountLengthError, @@ -12,12 +8,18 @@ InvalidKeyLengthError, SIAAccount, ) +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.data_entry_flow import AbortFlow from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ADDITIONAL_ACCOUNTS, CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, CONF_PING_INTERVAL, CONF_ZONES, DOMAIN, @@ -29,10 +31,12 @@ HUB_SCHEMA = vol.Schema( { vol.Required(CONF_PORT): int, + vol.Optional(CONF_PROTOCOL, default="TCP"): vol.In(["TCP", "UDP"]), vol.Required(CONF_ACCOUNT): str, vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, + vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -43,6 +47,7 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, + vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -55,105 +60,95 @@ def validate_input(data: dict): try: ping = int(data[CONF_PING_INTERVAL]) assert 1 <= ping <= 1440 - except AssertionError: - raise InvalidPing + except AssertionError as invalid_ping: + raise InvalidPing from invalid_ping try: zones = int(data[CONF_ZONES]) assert zones > 0 - except AssertionError: - raise InvalidZones + except AssertionError as invalid_zone: + raise InvalidZones from invalid_zone class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - data = None + VERSION = 2 + + def __init__(self): + """Initialize the config flow.""" + self._data = {} async def async_step_add_account(self, user_input: dict = None): """Handle the additional accounts steps.""" - errors = {} - if user_input is not None: - try: - validate_input(user_input) - self.update_data(user_input) - if user_input[CONF_ADDITIONAL_ACCOUNTS]: - return await self.async_step_add_account() - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - - return self.async_show_form( - step_id="user", - data_schema=ACCOUNT_SCHEMA, - errors=errors, - ) + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=ACCOUNT_SCHEMA, + errors={}, + ) async def async_step_user(self, user_input: dict = None): """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=HUB_SCHEMA, errors={} + ) errors = {} - if user_input is not None: - try: - validate_input(user_input) - await self.async_set_unique_id(f"{DOMAIN}_{self.data[CONF_PORT]}") - self._abort_if_unique_id_configured() - self.update_data(user_input) - if not user_input[CONF_ADDITIONAL_ACCOUNTS]: - return self.async_create_entry( - title=f"SIA Alarm on port {self.data[CONF_PORT]}", - data=self.data, - ) - return await self.async_step_add_account() - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - except AbortFlow: - return self.async_abort(reason="already_configured") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors=errors + try: + validate_input(user_input) + except InvalidKeyFormatError: + errors["base"] = "invalid_key_format" + except InvalidKeyLengthError: + errors["base"] = "invalid_key_length" + except InvalidAccountFormatError: + errors["base"] = "invalid_account_format" + except InvalidAccountLengthError: + errors["base"] = "invalid_account_length" + except InvalidPing: + errors["base"] = "invalid_ping" + except InvalidZones: + errors["base"] = "invalid_zones" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + if errors: + return self.async_show_form( + step_id="user", data_schema=HUB_SCHEMA, errors=errors + ) + self.update_data(user_input) + await self.async_set_unique_id(f"{DOMAIN}_{self._data[CONF_PORT]}") + try: + self._abort_if_unique_id_configured() + except AbortFlow: + return self.async_abort(reason="already_configured") + + if user_input[CONF_ADDITIONAL_ACCOUNTS]: + return await self.async_step_add_account() + return self.async_create_entry( + title=f"SIA Alarm on port {self._data[CONF_PORT]}", + data=self._data, ) def update_data(self, user_input): """Parse the user_input and store in self.data.""" - if not self.data: - self.data = { + if not self._data: + self._data = { CONF_PORT: user_input[CONF_PORT], + CONF_PROTOCOL: user_input[CONF_PROTOCOL], CONF_ACCOUNTS: [ { CONF_ACCOUNT: user_input[CONF_ACCOUNT], CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], CONF_ZONES: user_input[CONF_ZONES], + CONF_IGNORE_TIMESTAMPS: user_input[CONF_IGNORE_TIMESTAMPS], } ], } else: add_data = user_input.copy() add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self.data[CONF_ACCOUNTS].append(add_data) + self._data[CONF_ACCOUNTS].append(add_data) class InvalidPing(exceptions.HomeAssistantError): diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 531afc0..512fb5a 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -8,6 +8,7 @@ CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" CONF_ZONES = "zones" +CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" DOMAIN = "sia" DATA_UPDATED = f"{DOMAIN}_data_updated" diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 8285981..8268c82 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -4,10 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", "requirements": [ - "pysiaalarm==3.0.0b6" + "pysiaalarm==3.0.0b12" ], "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b5" + "version": "0.5.0b6", + "iot_class": "local_push" } diff --git a/custom_components/sia/strings.json b/custom_components/sia/strings.json index 1eed82a..1d7aeba 100644 --- a/custom_components/sia/strings.json +++ b/custom_components/sia/strings.json @@ -4,15 +4,27 @@ "step": { "user": { "data": { - "name": "Name", - "port": "Port", - "account": "Account", + "port": "[%key:common::config_flow::data::port%]", + "protocol": "Protocol", + "account": "Account ID", "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", - "additional_account": "Add more accounts?" + "ignore_timestamps": "Ignore the timestamp check", + "additional_account": "Additional accounts" }, - "title": "Create a connection for SIA DC-09 based alarm systems." + "title": "Create a connection for SIA based alarm systems." + }, + "additional_account": { + "data": { + "account": "[%key:component::sia::config::step::user::data::account%]", + "encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]", + "ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]", + "zones": "[%key:component::sia::config::step::user::data::zones%]", + "ignore_timestamps": "[%key:component::sia::config::step::user::data::ignore_timestamps%]", + "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" + }, + "title": "Add another account to the current port." } }, "error": { @@ -22,10 +34,10 @@ "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", "invalid_zones": "There needs to be at least 1 zone.", - "unknown": "Unexpected error" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This SIA Port is already used, please select another or recreate the existing with an extra account." + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } diff --git a/custom_components/sia/translations/en.json b/custom_components/sia/translations/en.json index 9cc5202..c46bc9e 100644 --- a/custom_components/sia/translations/en.json +++ b/custom_components/sia/translations/en.json @@ -21,9 +21,21 @@ "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", + "ignore_timestamps": "Ignore the timestamp check", "additional_account": "Add more accounts?" }, "title": "Create a connection for SIA DC-09 based alarm systems." + }, + "additional_account": { + "data": { + "account": "Account", + "encryption_key": "Encryption Key", + "ping_interval": "Ping Interval (min)", + "zones": "Number of zones for the account", + "ignore_timestamps": "Ignore the timestamp check", + "additional_account": "Add more accounts?" + }, + "title": "Add another account to the current port." } } }, From dc7f65e4a43735a77be3fc5f051a5ea048cb4f24 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:21:13 +0000 Subject: [PATCH 57/63] complete implementation of timestamp and protocol --- custom_components/sia/config_flow.py | 2 +- custom_components/sia/const.py | 4 ++++ custom_components/sia/hub.py | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 442c324..9adf167 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -131,7 +131,7 @@ async def async_step_user(self, user_input: dict = None): def update_data(self, user_input): """Parse the user_input and store in self.data.""" - if not self._data: + if not self._data or user_input.get(CONF_PORT): self._data = { CONF_PORT: user_input[CONF_PORT], CONF_PROTOCOL: user_input[CONF_PROTOCOL], diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 512fb5a..6e32d5b 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -23,3 +23,7 @@ EVENT_MESSAGE = "last_message" EVENT_ID = "last_id" EVENT_TIMESTAMP = "last_timestamp" + + +DEFAULT_TIMEBAND = (80, 40) +IGNORED_TIMEBAND = (3600, 1800) \ No newline at end of file diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 25592f7..8376abe 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,20 +1,22 @@ """The sia hub.""" import logging -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, EventOrigin, HomeAssistant from homeassistant.helpers import device_registry as dr -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent +from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent, CommunicationsProtocol from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, + CONF_IGNORE_TIMESTAMPS, DOMAIN, SIA_EVENT, + IGNORED_TIMEBAND, + DEFAULT_TIMEBAND, ) -ALLOWED_TIMEBAND = (300, 150) _LOGGER = logging.getLogger(__name__) @@ -31,14 +33,19 @@ def __init__( self.entry_id = entry_id self._title = title self._accounts = hub_config[CONF_ACCOUNTS] + self._protocol = hub_config[CONF_PROTOCOL] self._remove_shutdown_listener = None self.sia_accounts = [ - SIAAccount(a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), ALLOWED_TIMEBAND) + SIAAccount( + a[CONF_ACCOUNT], + a.get(CONF_ENCRYPTION_KEY), + IGNORED_TIMEBAND if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, + ) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.async_create_and_fire_event + "", self._port, self.sia_accounts, self.async_create_and_fire_event, CommunicationsProtocol(self._protocol) ) async def async_setup_hub(self): From 5a8af955be560227811a50e0119e223259fc8cdf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:33:52 +0000 Subject: [PATCH 58/63] auto assign issues setup --- .github/auto_assign-issues.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/auto_assign-issues.yml diff --git a/.github/auto_assign-issues.yml b/.github/auto_assign-issues.yml new file mode 100644 index 0000000..34d6a88 --- /dev/null +++ b/.github/auto_assign-issues.yml @@ -0,0 +1,8 @@ +# If enabled, auto-assigns users when a new issue is created +# Defaults to true, allows you to install the app globally, and disable on a per-repo basis +addAssignees: true + +# The list of users to assign to new issues. +# If empty or not provided, the repository owner is assigned +assignees: + - eavanvalkenburg \ No newline at end of file From 0d68b63b05aea6db4f520586cd89f8fb229b6123 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 10 May 2021 06:57:46 +0000 Subject: [PATCH 59/63] updated version in manifest --- custom_components/sia/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 8268c82..27b4df9 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,6 +9,6 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b6", + "version": "0.5.0b8", "iot_class": "local_push" } From eb0fa8faa30c726ca3e98cfa6812658441d429ac Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 11 May 2021 06:29:43 +0000 Subject: [PATCH 60/63] fixed keyerror for timestamps --- custom_components/sia/__init__.py | 5 +++-- custom_components/sia/hub.py | 10 ++++++++-- custom_components/sia/manifest.json | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 1b28334..8e8a0bc 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN +from .const import DOMAIN, CONF_IGNORE_TIMESTAMPS from .hub import SIAHub PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] @@ -66,9 +66,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) - if config_entry.version == 1: + if config_entry.version < 2: data = config_entry.data.copy() data[CONF_PROTOCOL] = "TCP" + data[CONF_IGNORE_TIMESTAMPS] = False config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index 8376abe..a4c93f2 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -40,12 +40,18 @@ def __init__( SIAAccount( a[CONF_ACCOUNT], a.get(CONF_ENCRYPTION_KEY), - IGNORED_TIMEBAND if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, + IGNORED_TIMEBAND + if a.get(CONF_IGNORE_TIMESTAMPS, False) + else DEFAULT_TIMEBAND, ) for a in self._accounts ] self.sia_client = SIAClient( - "", self._port, self.sia_accounts, self.async_create_and_fire_event, CommunicationsProtocol(self._protocol) + "", + self._port, + self.sia_accounts, + self.async_create_and_fire_event, + CommunicationsProtocol(self._protocol), ) async def async_setup_hub(self): diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index 27b4df9..fb8f87d 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -9,6 +9,6 @@ "codeowners": [ "@eavanvalkenburg" ], - "version": "0.5.0b8", + "version": "0.5.0b9", "iot_class": "local_push" } From 3b8f9072ddb09b5a23e149a526c3c92341611519 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 6 Aug 2021 16:20:15 +0200 Subject: [PATCH 61/63] callout for official itnegration --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8c3101..6a3f882 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ -[![hacs][hacsbadge]](hacs) + +## OFFICIAL INTEGRATION IS NOW IN HA! + +Make sure to delete the current integraiton, in your Integrations page, then delete the HACS custom component, reboot and then input your config in the official +integration config. There are some settings, most importantly ignoring timestamps, in a options flow (press configure after installing the integration). + + +## OFFICIAL INTEGRATION IS NOW IN HA! +---------------- + _Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ -_Latest beta will be suggested for inclusion as a official integration._ **This component will set up the following platforms.** From ab4af27dd2674b0d9237f18af2b3576802bba7cf Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 20 Oct 2021 15:15:11 +0000 Subject: [PATCH 62/63] getting in sync with official --- .devcontainer/devcontainer.json | 2 +- README.md | 16 +- custom_components/sia/__init__.py | 65 +--- custom_components/sia/alarm_control_panel.py | 274 +++----------- custom_components/sia/binary_sensor.py | 358 ++++++------------- custom_components/sia/config_flow.py | 228 ++++++++---- custom_components/sia/const.py | 43 ++- custom_components/sia/hub.py | 158 +++++--- custom_components/sia/manifest.json | 10 +- custom_components/sia/sensor.py | 211 ++++------- custom_components/sia/sia_entity_base.py | 132 +++++++ custom_components/sia/strings.json | 20 +- custom_components/sia/utils.py | 114 +++--- 13 files changed, 763 insertions(+), 868 deletions(-) create mode 100644 custom_components/sia/sia_entity_base.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f7329d..2418999 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration-debian", + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", "context": "..", "appPort": [ "9123:8123" diff --git a/README.md b/README.md index 1f3a649..5417948 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,14 @@ _Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ -_Latest beta will be suggested for inclusion as a official integration._ +This is the new stream which will be kept in sync with the [official integration][official]. + +In order to switch from an earlier version of the custom component to do this do not install this version over an existing version of the custom component, delete your integration in the integrations page, then update and reenter your config in the integrations page. + +If you are running the official integration, then you can install this one and things will work. + +The config used by earlier versions (before 1.0) is different and will cause errors, it is in sync with the official integration though! + **This component will set up the following platforms.** @@ -25,8 +32,8 @@ Platform | Description ## Hub Setup (Ajax Systems Hub example) -1. Select "SIA Protocol". -2. Enable "Connect on demand". +1. Select "SIA Protocol". +2. Enable "Connect on demand". 3. Place Account Id - 3-16 ASCII hex characters. For example AAA. 4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. 5. Insert Home Assistant listening port. This port must not be used with anything else. @@ -64,7 +71,7 @@ To turn on debugging go into your `configuration.yaml` and add these lines: logger: default: error logs: - custom_components.sia: debug + custom_components.sia: debug pysiaalarm: debug ``` @@ -72,3 +79,4 @@ logger: [ch_sia]: https://github.com/Cheaterdev/sia-ha [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[official]: https://www.home-assistant.io/integrations/sia/ diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py index 8e8a0bc..9bca9a5 100644 --- a/custom_components/sia/__init__.py +++ b/custom_components/sia/__init__.py @@ -1,77 +1,34 @@ """The sia integration.""" -import asyncio -import logging - -from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, -) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_PROTOCOL +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_IGNORE_TIMESTAMPS +from .const import DOMAIN, PLATFORMS from .hub import SIAHub -PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: dict): - """Set up the sia component.""" - hass.data.setdefault(DOMAIN, {}) - return True - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up sia from a config entry.""" - hub = SIAHub(hass, entry.data, entry.entry_id, entry.title) + hub: SIAHub = SIAHub(hass, entry) await hub.async_setup_hub() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = hub try: await hub.sia_client.start(reuse_port=True) - except OSError: + except OSError as exc: raise ConfigEntryNotReady( - "SIA Server at port %s could not start.", entry.data[CONF_PORT] - ) - - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) + f"SIA Server at port {entry.data[CONF_PORT]} could not start." + ) from exc + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] - ) - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) await hub.async_shutdown() return unload_ok - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): - """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - - if config_entry.version < 2: - data = config_entry.data.copy() - data[CONF_PROTOCOL] = "TCP" - data[CONF_IGNORE_TIMESTAMPS] = False - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=data) - - _LOGGER.info("Migration to version %s successful", config_entry.version) - return True diff --git a/custom_components/sia/alarm_control_panel.py b/custom_components/sia/alarm_control_panel.py index 72bc612..5a6a4f6 100644 --- a/custom_components/sia/alarm_control_panel.py +++ b/custom_components/sia/alarm_control_panel.py @@ -1,59 +1,40 @@ """Module for SIA Alarm Control Panels.""" +from __future__ import annotations import logging -from typing import Callable +from typing import Any + from pysiaalarm import SIAEvent -from homeassistant.components.alarm_control_panel import ( - ENTITY_ID_FORMAT as ALARM_FORMAT, - AlarmControlPanelEntity, -) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_PORT, - CONF_ZONE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, - STATE_UNKNOWN, + STATE_UNAVAILABLE, ) -from homeassistant.core import callback, Event, HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.dt import utcnow +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import ( - CONF_ACCOUNT, - CONF_ACCOUNTS, - CONF_PING_INTERVAL, - CONF_ZONES, - DATA_UPDATED, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, - SIA_EVENT, - DOMAIN, - PING_INTERVAL_MARGIN, -) -from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr +from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, SIA_UNIQUE_ID_FORMAT_ALARM +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) DEVICE_CLASS_ALARM = "alarm" PREVIOUS_STATE = "previous_state" -CODE_CONSEQUENCES = { - "BA": STATE_ALARM_TRIGGERED, +CODE_CONSEQUENCES: dict[str, StateType] = { "PA": STATE_ALARM_TRIGGERED, "JA": STATE_ALARM_TRIGGERED, "TA": STATE_ALARM_TRIGGERED, + "BA": STATE_ALARM_TRIGGERED, "CA": STATE_ALARM_ARMED_AWAY, + "CB": STATE_ALARM_ARMED_AWAY, "CG": STATE_ALARM_ARMED_AWAY, "CL": STATE_ALARM_ARMED_AWAY, "CP": STATE_ALARM_ARMED_AWAY, @@ -61,6 +42,7 @@ "CS": STATE_ALARM_ARMED_AWAY, "CF": STATE_ALARM_ARMED_CUSTOM_BYPASS, "OA": STATE_ALARM_DISARMED, + "OB": STATE_ALARM_DISARMED, "OG": STATE_ALARM_DISARMED, "OP": STATE_ALARM_DISARMED, "OQ": STATE_ALARM_DISARMED, @@ -75,214 +57,56 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_entities( - [ - SIAAlarmControlPanel( - *get_entity_and_name( - entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, DEVICE_CLASS_ALARM - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - zone, - acc[CONF_PING_INTERVAL], - ) - for acc in entry.data[CONF_ACCOUNTS] - for zone in range(1, acc[CONF_ZONES] + 1) - ] + SIAAlarmControlPanel(entry, account_data, zone) + for account_data in entry.data[CONF_ACCOUNTS] + for zone in range( + 1, + entry.options[CONF_ACCOUNTS][account_data[CONF_ACCOUNT]][CONF_ZONES] + 1, + ) ) - return True -class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): +class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): """Class for SIA Alarm Control Panels.""" def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, + entry: ConfigEntry, + account_data: dict[str, Any], zone: int, - ping_interval: int, - ): + ) -> None: """Create SIAAlarmControlPanel object.""" - self.entity_id = ALARM_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._is_available = True - self._remove_unavailability_tracker = None - self._state = None - self._old_state = None - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - EVENT_ACCOUNT: None, - EVENT_CODE: None, - EVENT_ID: None, - EVENT_ZONE: None, - EVENT_MESSAGE: None, - EVENT_TIMESTAMP: None, - } - - async def async_added_to_hass(self): - """Once the panel is added, see if it was there before and pull in that state.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - _LOGGER.debug( - "Loading last state: %s", - state.state if state is not None and state.state is not None else "None", - ) - if ( - state is not None - and state.state is not None - and state.state - in [ - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, - STATE_UNKNOWN, - ] - ): - self.state = state.state - else: - self.state = None - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event - ) - self.setup_sia_alarm() + super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) + self._attr_state: StateType = None + self._old_state: StateType = None - def setup_sia_alarm(self): - """Run the setup of the alarm control panel.""" - self.assume_available() - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( + self._entry.entry_id, self._account, self._zone ) - self.async_on_remove(self._async_sia_on_remove) - - @callback - def _async_sia_on_remove(self): - """Remove the unavailability and event listener.""" - if self._unsub: - self._unsub() - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - - async def async_handle_event(self, event: Event): - """Listen to events for this port and account and update states. - If the port and account combo receives any message it means it is online and can therefore be set to available. - """ - self.assume_available() - sia_event = SIAEvent.from_dict(event.data) - if int(sia_event.ri) != self._zone: - return - new_state = CODE_CONSEQUENCES.get(sia_event.code) - if not new_state: - return - self._attr.update(sia_event_to_attr(sia_event)) - self.state = new_state - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Get Name.""" - return self._name - - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - - @property - def state(self) -> str: - """Get state.""" - return self._state - - @state.setter - def state(self, state: str): - """Set state.""" - temp = self._old_state if state == PREVIOUS_STATE else state - self._old_state = self._state - self._state = temp - - @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def unique_id(self) -> str: - """Get unique_id.""" - return self._unique_id - - @property - def available(self) -> bool: - """Get availability.""" - return self._is_available - - @property - def device_state_attributes(self) -> dict: - """Return device attributes.""" - return self._attr - - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False - - def assume_available(self): - """Reset unavalability tracker.""" - if not self.registry_entry.disabled: - self._async_track_unavailable() - - @callback - def _async_track_unavailable(self) -> bool: - """Reset unavailability.""" - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, - self._async_set_unavailable, - utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, - ) - if not self._is_available: - self._is_available = True - self.async_schedule_update_ha_state() - return True - return False - - @callback - def _async_set_unavailable(self, _): - """Set availability.""" - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the alarm control panel.""" + new_state = CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + if new_state == PREVIOUS_STATE: + new_state = self._old_state + self._attr_state, self._old_state = new_state, self._attr_state + + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None: + self._attr_state = last_state.state + if self.state == STATE_UNAVAILABLE: + self._attr_available = False @property def supported_features(self) -> int: """Return the list of supported features.""" - return None - - @property - def device_info(self) -> dict: - """Return the device_info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), - } + return 0 diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py index 642ad9f..eec4f9b 100644 --- a/custom_components/sia/binary_sensor.py +++ b/custom_components/sia/binary_sensor.py @@ -1,285 +1,163 @@ """Module for SIA Binary Sensors.""" +from __future__ import annotations +from collections.abc import Iterable import logging -from typing import Callable +from typing import Any + +from pysiaalarm import SIAEvent from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, DEVICE_CLASS_POWER, DEVICE_CLASS_SMOKE, + BinarySensorEntity, ) -from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT as BINARY_SENSOR_FORMAT, -) -from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_ZONE, STATE_OFF, STATE_ON, STATE_UNKNOWN -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.dt import utcnow -from pysiaalarm import SIAEvent +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, - CONF_PING_INTERVAL, CONF_ZONES, - DATA_UPDATED, - DOMAIN, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, - HUB_ZONE, - PING_INTERVAL_MARGIN, - SIA_EVENT, + SIA_HUB_ZONE, + SIA_UNIQUE_ID_FORMAT_BINARY, ) -from .utils import get_entity_and_name, get_ping_interval, sia_event_to_attr +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) -ZONE_DEVICES = [ - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_SMOKE, -] -CODE_CONSEQUENCES = { - "AT": (DEVICE_CLASS_POWER, False), - "AR": (DEVICE_CLASS_POWER, True), - "GA": (DEVICE_CLASS_SMOKE, True), - "GH": (DEVICE_CLASS_SMOKE, False), - "FA": (DEVICE_CLASS_SMOKE, True), - "FH": (DEVICE_CLASS_SMOKE, False), - "KA": (DEVICE_CLASS_SMOKE, True), - "KH": (DEVICE_CLASS_SMOKE, False), - "WA": (DEVICE_CLASS_MOISTURE, True), - "WH": (DEVICE_CLASS_MOISTURE, False), + +POWER_CODE_CONSEQUENCES: dict[str, bool] = { + "AT": False, + "AR": True, +} + +SMOKE_CODE_CONSEQUENCES: dict[str, bool] = { + "GA": True, + "GH": False, + "FA": True, + "FH": False, + "KA": True, + "KH": False, } +MOISTURE_CODE_CONSEQUENCES: dict[str, bool] = { + "WA": True, + "WH": False, +} -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: - """Set up sia_binary_sensor from a config entry.""" - devices = [ - SIABinarySensor( - *get_entity_and_name( - entry.data[CONF_PORT], acc[CONF_ACCOUNT], zone, device_class - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - zone, - acc[CONF_PING_INTERVAL], - device_class, - ) - for acc in entry.data[CONF_ACCOUNTS] - for zone in range(1, acc[CONF_ZONES] + 1) - for device_class in ZONE_DEVICES - ] - devices.extend( - [ - SIABinarySensor( - *get_entity_and_name( - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - DEVICE_CLASS_POWER, - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - acc[CONF_PING_INTERVAL], - DEVICE_CLASS_POWER, - ) - for acc in entry.data[CONF_ACCOUNTS] - ] - ) - async_add_entities(devices) - return True +def generate_binary_sensors(entry) -> Iterable[SIABinarySensorBase]: + """Generate binary sensors. + + For each Account there is one power sensor with zone == 0. + For each Zone in each Account there is one smoke and one moisture sensor. + """ + for account in entry.data[CONF_ACCOUNTS]: + yield SIABinarySensorPower(entry, account) + zones = entry.options[CONF_ACCOUNTS][account[CONF_ACCOUNT]][CONF_ZONES] + for zone in range(1, zones + 1): + yield SIABinarySensorSmoke(entry, account, zone) + yield SIABinarySensorMoisture(entry, account, zone) -class SIABinarySensor(BinarySensorEntity, RestoreEntity): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SIA binary sensors from a config entry.""" + async_add_entities(generate_binary_sensors(entry)) + + +class SIABinarySensorBase(SIABaseEntity, BinarySensorEntity): """Class for SIA Binary Sensors.""" def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, + entry: ConfigEntry, + account_data: dict[str, Any], zone: int, - ping_interval: int, device_class: str, - ): - """Create SIABinarySensor object.""" - self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._device_class = device_class - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._is_on = None - self._is_available = True - self._remove_unavailability_tracker = None - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - EVENT_ACCOUNT: None, - EVENT_CODE: None, - EVENT_ID: None, - EVENT_ZONE: None, - EVENT_MESSAGE: None, - EVENT_TIMESTAMP: None, - } + ) -> None: + """Initialize a base binary sensor.""" + super().__init__(entry, account_data, zone, device_class) - async def async_added_to_hass(self): - """Add sensor to HASS.""" - await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state is not None: - if state.state == STATE_ON: - self._is_on = True - elif state.state == STATE_OFF: - self._is_on = False - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( + self._entry.entry_id, self._account, self._zone, self._attr_device_class ) - self.setup_sia_entity() - def setup_sia_entity(self): - """Run the setup of the alarm control panel.""" - self.assume_available() - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event - ) - self.async_on_remove(self._async_sia_on_remove) - - @callback - def _async_sia_on_remove(self): - """Remove the unavailability and event listener.""" - if self._unsub: - self._unsub() - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - - async def async_handle_event(self, event: Event): - """Listen to events for this port and account and update states. - - If the port and account combo receives any message it means it is online and can therefore be set to available. - """ - self.assume_available() - sia_event = SIAEvent.from_dict(event.data) - sia_event.message_type = sia_event.message_type.value - if int(sia_event.ri) == self._zone or self._device_class == DEVICE_CLASS_POWER: - device_class, new_state = CODE_CONSEQUENCES.get( - sia_event.code, (None, None) - ) - if new_state is not None and device_class == self._device_class: - self._attr.update(sia_event_to_attr(sia_event)) - self.state = new_state - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Return name.""" - return self._name + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None and last_state.state is not None: + if last_state.state == STATE_ON: + self._attr_is_on = True + elif last_state.state == STATE_OFF: + self._attr_is_on = False + elif last_state.state == STATE_UNAVAILABLE: + self._attr_available = False - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id +class SIABinarySensorMoisture(SIABinarySensorBase): + """Class for Moisture Binary Sensors.""" - @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def available(self) -> bool: - """Return avalability.""" - return self._is_available - - @property - def device_state_attributes(self) -> dict: - """Return attributes.""" - return self._attr - - @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Moisture binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_MOISTURE) + self._attr_entity_registry_enabled_default = False - @property - def state(self) -> str: - """Return the state of the binary sensor.""" - if self.is_on is None: - return STATE_UNKNOWN - return STATE_ON if self.is_on else STATE_OFF + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = MOISTURE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - return self._is_on - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False +class SIABinarySensorSmoke(SIABinarySensorBase): + """Class for Smoke Binary Sensors.""" - @state.setter - def state(self, new_on: bool): - """Set state.""" - self._is_on = new_on + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Smoke binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_SMOKE) + self._attr_entity_registry_enabled_default = False - def assume_available(self): - """Reset unavalability tracker.""" - if not self.registry_entry.disabled: - self._async_track_unavailable() + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = SMOKE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state - @callback - def _async_track_unavailable(self) -> bool: - """Track availability.""" - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, - self._async_set_unavailable, - utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, - ) - if not self._is_available: - self._is_available = True - return True - return False - @callback - def _async_set_unavailable(self, now): - """Set unavailable.""" - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() +class SIABinarySensorPower(SIABinarySensorBase): + """Class for Power Binary Sensors.""" - @property - def device_info(self) -> dict: - """Return the device_info.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), - } + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: + """Initialize a Power binary sensor.""" + super().__init__(entry, account_data, SIA_HUB_ZONE, DEVICE_CLASS_POWER) + self._attr_entity_registry_enabled_default = True + + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = POWER_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py index 9adf167..c43faf5 100644 --- a/custom_components/sia/config_flow.py +++ b/custom_components/sia/config_flow.py @@ -1,5 +1,10 @@ """Config flow for sia integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy import logging +from typing import Any from pysiaalarm import ( InvalidAccountFormatError, @@ -10,9 +15,10 @@ ) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import CONF_PORT, CONF_PROTOCOL -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ACCOUNT, @@ -23,7 +29,9 @@ CONF_PING_INTERVAL, CONF_ZONES, DOMAIN, + TITLE, ) +from .hub import SIAHub _LOGGER = logging.getLogger(__name__) @@ -36,7 +44,6 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, - vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) @@ -47,113 +54,176 @@ vol.Optional(CONF_ENCRYPTION_KEY): str, vol.Required(CONF_PING_INTERVAL, default=1): int, vol.Required(CONF_ZONES, default=1): int, - vol.Required(CONF_IGNORE_TIMESTAMPS, default=False): bool, vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool, } ) +DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} -def validate_input(data: dict): - """Validate the input by the user.""" - SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) +def validate_input(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the input by the user.""" try: - ping = int(data[CONF_PING_INTERVAL]) - assert 1 <= ping <= 1440 - except AssertionError as invalid_ping: - raise InvalidPing from invalid_ping - try: - zones = int(data[CONF_ZONES]) - assert zones > 0 - except AssertionError as invalid_zone: - raise InvalidZones from invalid_zone + SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) + except InvalidKeyFormatError: + return {"base": "invalid_key_format"} + except InvalidKeyLengthError: + return {"base": "invalid_key_length"} + except InvalidAccountFormatError: + return {"base": "invalid_account_format"} + except InvalidAccountLengthError: + return {"base": "invalid_account_length"} + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception from SIAAccount: %s", exc) + return {"base": "unknown"} + if not 1 <= data[CONF_PING_INTERVAL] <= 1440: + return {"base": "invalid_ping"} + return validate_zones(data) + + +def validate_zones(data: dict[str, Any]) -> dict[str, str] | None: + """Validate the zones field.""" + if data[CONF_ZONES] == 0: + return {"base": "invalid_zones"} + return None class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for sia.""" - VERSION = 2 + VERSION: int = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return SIAOptionsFlowHandler(config_entry) def __init__(self): """Initialize the config flow.""" - self._data = {} - - async def async_step_add_account(self, user_input: dict = None): - """Handle the additional accounts steps.""" - if user_input is None: + self._data: dict[str, Any] = {} + self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}} + + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + """Handle the initial user step.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_input(user_input) + if user_input is None or errors is not None: return self.async_show_form( - step_id="user", - data_schema=ACCOUNT_SCHEMA, - errors={}, + step_id="user", data_schema=HUB_SCHEMA, errors=errors ) + return await self.async_handle_data_and_route(user_input) - async def async_step_user(self, user_input: dict = None): - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors={} - ) - errors = {} - try: - validate_input(user_input) - except InvalidKeyFormatError: - errors["base"] = "invalid_key_format" - except InvalidKeyLengthError: - errors["base"] = "invalid_key_length" - except InvalidAccountFormatError: - errors["base"] = "invalid_account_format" - except InvalidAccountLengthError: - errors["base"] = "invalid_account_length" - except InvalidPing: - errors["base"] = "invalid_ping" - except InvalidZones: - errors["base"] = "invalid_zones" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - if errors: + async def async_step_add_account( + self, user_input: dict[str, Any] = None + ) -> FlowResult: + """Handle the additional accounts steps.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_input(user_input) + if user_input is None or errors is not None: return self.async_show_form( - step_id="user", data_schema=HUB_SCHEMA, errors=errors + step_id="add_account", data_schema=ACCOUNT_SCHEMA, errors=errors ) - self.update_data(user_input) - await self.async_set_unique_id(f"{DOMAIN}_{self._data[CONF_PORT]}") - try: - self._abort_if_unique_id_configured() - except AbortFlow: - return self.async_abort(reason="already_configured") + return await self.async_handle_data_and_route(user_input) + + async def async_handle_data_and_route( + self, user_input: dict[str, Any] + ) -> FlowResult: + """Handle the user_input, check if configured and route to the right next step or create entry.""" + self._update_data(user_input) + + self._async_abort_entries_match({CONF_PORT: self._data[CONF_PORT]}) if user_input[CONF_ADDITIONAL_ACCOUNTS]: return await self.async_step_add_account() return self.async_create_entry( - title=f"SIA Alarm on port {self._data[CONF_PORT]}", + title=TITLE.format(self._data[CONF_PORT]), data=self._data, + options=self._options, ) - def update_data(self, user_input): - """Parse the user_input and store in self.data.""" + def _update_data(self, user_input: dict[str, Any]) -> None: + """Parse the user_input and store in data and options attributes. + + If there is a port in the input or no data, assume it is fully new and overwrite. + Add the default options and overwrite the zones in options. + """ if not self._data or user_input.get(CONF_PORT): self._data = { CONF_PORT: user_input[CONF_PORT], CONF_PROTOCOL: user_input[CONF_PROTOCOL], - CONF_ACCOUNTS: [ + CONF_ACCOUNTS: [], + } + account = user_input[CONF_ACCOUNT] + self._data[CONF_ACCOUNTS].append( + { + CONF_ACCOUNT: account, + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), + CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], + } + ) + self._options[CONF_ACCOUNTS].setdefault(account, deepcopy(DEFAULT_OPTIONS)) + self._options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] + + +class SIAOptionsFlowHandler(config_entries.OptionsFlow): + """Handle SIA options.""" + + def __init__(self, config_entry): + """Initialize SIA options flow.""" + self.config_entry = config_entry + self.options = deepcopy(dict(config_entry.options)) + self.hub: SIAHub | None = None + self.accounts_todo: list = [] + + async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: + """Manage the SIA options.""" + self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + assert self.hub is not None + assert self.hub.sia_accounts is not None + self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] + return await self.async_step_options() + + async def async_step_options(self, user_input: dict[str, Any] = None) -> FlowResult: + """Create the options step for a account.""" + errors: dict[str, str] | None = None + if user_input is not None: + errors = validate_zones(user_input) + if user_input is None or errors is not None: + account = self.accounts_todo[0] + return self.async_show_form( + step_id="options", + description_placeholders={"account": account}, + data_schema=vol.Schema( { - CONF_ACCOUNT: user_input[CONF_ACCOUNT], - CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), - CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL], - CONF_ZONES: user_input[CONF_ZONES], - CONF_IGNORE_TIMESTAMPS: user_input[CONF_IGNORE_TIMESTAMPS], + vol.Optional( + CONF_ZONES, + default=self.options[CONF_ACCOUNTS][account][CONF_ZONES], + ): int, + vol.Optional( + CONF_IGNORE_TIMESTAMPS, + default=self.options[CONF_ACCOUNTS][account][ + CONF_IGNORE_TIMESTAMPS + ], + ): bool, } - ], - } - else: - add_data = user_input.copy() - add_data.pop(CONF_ADDITIONAL_ACCOUNTS) - self._data[CONF_ACCOUNTS].append(add_data) - - -class InvalidPing(exceptions.HomeAssistantError): - """Error to indicate there is invalid ping interval.""" - + ), + errors=errors, + last_step=self.last_step, + ) -class InvalidZones(exceptions.HomeAssistantError): - """Error to indicate there is invalid number of zones.""" + account = self.accounts_todo.pop(0) + self.options[CONF_ACCOUNTS][account][CONF_IGNORE_TIMESTAMPS] = user_input[ + CONF_IGNORE_TIMESTAMPS + ] + self.options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES] + if self.accounts_todo: + return await self.async_step_options() + return self.async_create_entry(title="", data=self.options) + + @property + def last_step(self) -> bool: + """Return if this is the last step.""" + return len(self.accounts_todo) <= 1 diff --git a/custom_components/sia/const.py b/custom_components/sia/const.py index 6e32d5b..6a63c33 100644 --- a/custom_components/sia/const.py +++ b/custom_components/sia/const.py @@ -1,29 +1,34 @@ """Constants for the sia integration.""" -from datetime import timedelta +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, +) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN] +DOMAIN = "sia" + +ATTR_CODE = "last_code" +ATTR_ZONE = "last_zone" +ATTR_MESSAGE = "last_message" +ATTR_ID = "last_id" +ATTR_TIMESTAMP = "last_timestamp" + +TITLE = "SIA Alarm on port {}" CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" CONF_ADDITIONAL_ACCOUNTS = "additional_account" -CONF_PING_INTERVAL = "ping_interval" CONF_ENCRYPTION_KEY = "encryption_key" -CONF_ZONES = "zones" CONF_IGNORE_TIMESTAMPS = "ignore_timestamps" +CONF_PING_INTERVAL = "ping_interval" +CONF_ZONES = "zones" -DOMAIN = "sia" -DATA_UPDATED = f"{DOMAIN}_data_updated" -SIA_EVENT = "sia_event" -HUB_SENSOR_NAME = "last_heartbeat" -HUB_ZONE = 0 -PING_INTERVAL_MARGIN = timedelta(seconds=30) - -EVENT_CODE = "last_code" -EVENT_ACCOUNT = "last_account" -EVENT_ZONE = "zone" -EVENT_MESSAGE = "last_message" -EVENT_ID = "last_id" -EVENT_TIMESTAMP = "last_timestamp" - +SIA_NAME_FORMAT = "{} - {} - zone {} - {}" +SIA_NAME_FORMAT_SENSOR = "{} - {} - Last Ping" +SIA_UNIQUE_ID_FORMAT_ALARM = "{}_{}_{}" +SIA_UNIQUE_ID_FORMAT_BINARY = "{}_{}_{}_{}" +SIA_HUB_ZONE = 0 +SIA_UNIQUE_ID_FORMAT_SENSOR = "{}_{}_last_ping" -DEFAULT_TIMEBAND = (80, 40) -IGNORED_TIMEBAND = (3600, 1800) \ No newline at end of file +SIA_EVENT = "sia_event_{}_{}" diff --git a/custom_components/sia/hub.py b/custom_components/sia/hub.py index a4c93f2..af7d59d 100644 --- a/custom_components/sia/hub.py +++ b/custom_components/sia/hub.py @@ -1,85 +1,147 @@ """The sia hub.""" +from __future__ import annotations + +from copy import deepcopy import logging +from typing import Any + +from pysiaalarm.aio import CommunicationsProtocol +from pysiaalarm.aio import SIAAccount +from pysiaalarm.aio import SIAClient +from pysiaalarm.aio import SIAEvent +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, EventOrigin, HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from pysiaalarm.aio import SIAAccount, SIAClient, SIAEvent, CommunicationsProtocol +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ENCRYPTION_KEY, CONF_IGNORE_TIMESTAMPS, + CONF_ZONES, DOMAIN, + PLATFORMS, SIA_EVENT, - IGNORED_TIMEBAND, - DEFAULT_TIMEBAND, ) - +from .utils import get_event_data_from_sia_event _LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEBAND = (80, 40) +IGNORED_TIMEBAND = (3600, 1800) + + class SIAHub: """Class for SIA Hubs.""" def __init__( - self, hass: HomeAssistant, hub_config: dict, entry_id: str, title: str - ): + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: """Create the SIAHub.""" - self._hass = hass - self._port = int(hub_config[CONF_PORT]) - self.entry_id = entry_id - self._title = title - self._accounts = hub_config[CONF_ACCOUNTS] - self._protocol = hub_config[CONF_PROTOCOL] - - self._remove_shutdown_listener = None - self.sia_accounts = [ - SIAAccount( - a[CONF_ACCOUNT], - a.get(CONF_ENCRYPTION_KEY), - IGNORED_TIMEBAND - if a.get(CONF_IGNORE_TIMESTAMPS, False) - else DEFAULT_TIMEBAND, - ) - for a in self._accounts - ] - self.sia_client = SIAClient( - "", - self._port, - self.sia_accounts, - self.async_create_and_fire_event, - CommunicationsProtocol(self._protocol), - ) + self._hass: HomeAssistant = hass + self._entry: ConfigEntry = entry + self._port: int = entry.data[CONF_PORT] + self._title: str = entry.title + self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) + self._protocol: str = entry.data[CONF_PROTOCOL] + self.sia_accounts: list[SIAAccount] | None = None + self.sia_client: SIAClient = None - async def async_setup_hub(self): + async def async_setup_hub(self) -> None: """Add a device to the device_registry, register shutdown listener, load reactions.""" - _LOGGER.debug("Setting up SIA Hub.") + self.update_accounts() device_registry = await dr.async_get_registry(self._hass) - port = self._port for acc in self._accounts: account = acc[CONF_ACCOUNT] device_registry.async_get_or_create( - config_entry_id=self.entry_id, - identifiers={(DOMAIN, port, account)}, - name=f"{port} - {account}", + config_entry_id=self._entry.entry_id, + identifiers={(DOMAIN, f"{self._port}_{account}")}, + name=f"{self._port} - {account}", ) - self._remove_shutdown_listener = self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self.async_shutdown + self._entry.async_on_unload( + self._entry.add_update_listener(self.async_config_entry_updated) + ) + self._entry.async_on_unload( + self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_shutdown) ) - async def async_shutdown(self, _: Event = None): + async def async_shutdown(self, _: Event = None) -> None: """Shutdown the SIA server.""" - if self._remove_shutdown_listener: - self._remove_shutdown_listener() await self.sia_client.stop() - async def async_create_and_fire_event(self, event: SIAEvent): - """Create a event on HA's bus, with the data from the SIAEvent.""" + async def async_create_and_fire_event(self, event: SIAEvent) -> None: + """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. + + The created event is handled by default for only a small subset for each platform (there are about 320 SIA Codes defined, only 22 of those are used in the alarm_control_panel), a user can choose to build other automation or even entities on the same event for SIA codes not handled by the built-in platforms. + + """ + _LOGGER.debug( + "Adding event to dispatch and bus for code %s for port %s and account %s", + event.code, + self._port, + event.account, + ) + async_dispatcher_send( + self._hass, SIA_EVENT.format(self._port, event.account), event + ) self._hass.bus.async_fire( - event_type=f"{SIA_EVENT}_{self._port}_{event.account}", - event_data=event.to_dict(encode_json=True), - origin=EventOrigin.remote, + event_type=SIA_EVENT.format(self._port, event.account), + event_data=get_event_data_from_sia_event(event), ) + + def update_accounts(self): + """Update the SIA_Accounts variable.""" + self._load_options() + self.sia_accounts = [ + SIAAccount( + account_id=a[CONF_ACCOUNT], + key=a.get(CONF_ENCRYPTION_KEY), + allowed_timeband=IGNORED_TIMEBAND + if a[CONF_IGNORE_TIMESTAMPS] + else DEFAULT_TIMEBAND, + ) + for a in self._accounts + ] + if self.sia_client is not None: + self.sia_client.accounts = self.sia_accounts + return + self.sia_client = SIAClient( + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), + ) + + def _load_options(self) -> None: + """Store attributes to avoid property call overhead since they are called frequently.""" + options = dict(self._entry.options) + for acc in self._accounts: + acc_id = acc[CONF_ACCOUNT] + if acc_id in options[CONF_ACCOUNTS]: + acc[CONF_IGNORE_TIMESTAMPS] = options[CONF_ACCOUNTS][acc_id][ + CONF_IGNORE_TIMESTAMPS + ] + acc[CONF_ZONES] = options[CONF_ACCOUNTS][acc_id][CONF_ZONES] + + @staticmethod + async def async_config_entry_updated( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Handle signals of config entry being updated. + + First, update the accounts, this will reflect any changes with ignore_timestamps. + Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. + + """ + if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): + return + hub.update_accounts() + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json index fb8f87d..ab97121 100644 --- a/custom_components/sia/manifest.json +++ b/custom_components/sia/manifest.json @@ -3,12 +3,8 @@ "name": "SIA Alarm Systems", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sia", - "requirements": [ - "pysiaalarm==3.0.0b12" - ], - "codeowners": [ - "@eavanvalkenburg" - ], - "version": "0.5.0b9", + "requirements": ["pysiaalarm==3.0.3b1"], + "codeowners": ["@eavanvalkenburg"], + "version": "1.0.0", "iot_class": "local_push" } diff --git a/custom_components/sia/sensor.py b/custom_components/sia/sensor.py index 6bfab53..3bea41f 100644 --- a/custom_components/sia/sensor.py +++ b/custom_components/sia/sensor.py @@ -1,54 +1,47 @@ """Module for SIA Sensors.""" -import datetime as dt +from __future__ import annotations + +from datetime import datetime as dt, timedelta import logging -from typing import Callable +from typing import Any + +from pysiaalarm import SIAEvent -from homeassistant.components.sensor import ENTITY_ID_FORMAT as SENSOR_FORMAT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_ZONE, DEVICE_CLASS_TIMESTAMP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.const import CONF_PORT, DEVICE_CLASS_TIMESTAMP +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from pysiaalarm import SIAEvent from .const import ( CONF_ACCOUNT, CONF_ACCOUNTS, CONF_PING_INTERVAL, - DATA_UPDATED, DOMAIN, - HUB_ZONE, SIA_EVENT, + SIA_NAME_FORMAT_SENSOR, + SIA_UNIQUE_ID_FORMAT_SENSOR, ) -from .utils import get_entity_and_name, get_ping_interval +from .utils import get_attr_from_sia_event _LOGGER = logging.getLogger(__name__) +REGULAR_ICON = "mdi:clock-check" +LATE_ICON = "mdi:clock-alert" + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable[[], None] -) -> bool: + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up sia_sensor from a config entry.""" async_add_entities( - [ - SIASensor( - *get_entity_and_name( - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - DEVICE_CLASS_TIMESTAMP, - ), - entry.data[CONF_PORT], - acc[CONF_ACCOUNT], - HUB_ZONE, - acc[CONF_PING_INTERVAL], - DEVICE_CLASS_TIMESTAMP, - ) - for acc in entry.data[CONF_ACCOUNTS] - ] + SIASensor(entry, account_data) for account_data in entry.data[CONF_ACCOUNTS] ) - return True class SIASensor(RestoreEntity): @@ -56,128 +49,82 @@ class SIASensor(RestoreEntity): def __init__( self, - entity_id: str, - name: str, - port: int, - account: str, - zone: int, - ping_interval: int, - device_class: str, - ): + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: """Create SIASensor object.""" - self.entity_id = SENSOR_FORMAT.format(entity_id) - self._unique_id = entity_id - self._name = name - self._device_class = device_class - self._port = port - self._account = account - self._zone = zone - self._ping_interval = get_ping_interval(ping_interval) - self._event_listener_str = f"{SIA_EVENT}_{port}_{account}" - self._unsub = None - - self._state = utcnow() - self._attr = { - CONF_ACCOUNT: self._account, - CONF_PING_INTERVAL: self.ping_interval, - CONF_ZONE: self._zone, - } + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: timedelta = timedelta( + minutes=self._account_data[CONF_PING_INTERVAL] + ) + + self._state: dt = utcnow() + self._cancel_icon_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes: dict[str, Any] = {} + self._attr_icon = REGULAR_ICON + self._attr_unit_of_measurement = "ISO8601" + self._attr_device_class = DEVICE_CLASS_TIMESTAMP + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT_SENSOR.format(self._port, self._account) + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_SENSOR.format( + self._entry.entry_id, self._account + ) async def async_added_to_hass(self) -> None: """Once the sensor is added, see if it was there before and pull in that state.""" await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state is not None: - self.state = dt.datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S.%f%z") - - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state is not None: + self._state = dt.fromisoformat(last_state.state) + self.async_update_icon() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIA_EVENT.format(self._port, self._account), + self.async_handle_event, + ) ) - self.setup_sia_entity() - - def setup_sia_entity(self): - """Run the setup of the sensor.""" - self._unsub = self.hass.bus.async_listen( - self._event_listener_str, self.async_handle_event + self.async_on_remove( + async_track_time_interval( + self.hass, self.async_update_icon, self._ping_interval + ) ) - self.async_on_remove(self._async_sia_on_remove) @callback - def _async_sia_on_remove(self): - """Remove the event listener.""" - if self._unsub: - self._unsub() - - async def async_handle_event(self, event: Event): + def async_handle_event(self, sia_event: SIAEvent): """Listen to events for this port and account and update the state and attributes.""" - sia_event = SIAEvent.from_dict(event.data) - self._attr.update(event.data) + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) if sia_event.code == "RP": - self.state = utcnow() - if self.enabled: - self.async_schedule_update_ha_state() - - @property - def name(self) -> str: - """Return name.""" - return self._name + self._state = utcnow() + self.async_update_icon() - @property - def ping_interval(self) -> int: - """Get ping_interval.""" - return str(self._ping_interval) - - @property - def unique_id(self) -> str: - """Get unique_id.""" - return self._unique_id + @callback + def async_update_icon(self, *_) -> None: + """Update the icon.""" + if self._state < utcnow() - self._ping_interval: + self._attr_icon = LATE_ICON + else: + self._attr_icon = REGULAR_ICON + self.async_write_ha_state() @property - def state(self) -> str: + def state(self) -> StateType: """Return state.""" return self._state.isoformat() @property - def account(self) -> str: - """Return device account.""" - return self._account - - @property - def device_state_attributes(self) -> dict: - """Return attributes.""" - return self._attr - - @property - def should_poll(self) -> bool: - """Return False if entity pushes its state to HA.""" - return False - - @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class - - @state.setter - def state(self, state: dt.datetime): - """Set state.""" - self._state = state - - @property - def icon(self) -> str: - """Return the icon to use in the frontend, if any.""" - return "mdi:alarm-light-outline" - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return "ISO8601" - - @property - def device_info(self) -> dict: + def device_info(self) -> DeviceInfo: """Return the device_info.""" + assert self._attr_unique_id is not None + assert self._attr_name is not None return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "via_device": (DOMAIN, self._port, self._account), + "name": self._attr_name, + "identifiers": {(DOMAIN, self._attr_unique_id)}, + "via_device": (DOMAIN, f"{self._port}_{self._account}"), } diff --git a/custom_components/sia/sia_entity_base.py b/custom_components/sia/sia_entity_base.py new file mode 100644 index 0000000..0a84615 --- /dev/null +++ b/custom_components/sia/sia_entity_base.py @@ -0,0 +1,132 @@ +"""Module for SIA Base Entity.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import CALLBACK_TYPE, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DOMAIN, SIA_EVENT, SIA_NAME_FORMAT +from .utils import get_attr_from_sia_event, get_unavailability_interval + +_LOGGER = logging.getLogger(__name__) + + +class SIABaseEntity(RestoreEntity): + """Base class for SIA entities.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + device_class: str, + ) -> None: + """Create SIABaseEntity object.""" + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + self._zone: int = zone + self._attr_device_class: str = device_class + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: int = self._account_data[CONF_PING_INTERVAL] + + self._cancel_availability_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes = {} + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT.format( + self._port, self._account, self._zone, self._attr_device_class + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass. + + Overridden from Entity. + + 1. register the dispatcher and add the callback to on_remove + 2. get previous state from storage and pass to entity specific function + 3. if available: create availability cb + """ + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIA_EVENT.format(self._port, self._account), + self.async_handle_event, + ) + ) + self.handle_last_state(await self.async_get_last_state()) + if self._attr_available: + self.async_create_availability_cb() + + @abstractmethod + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + Overridden from Entity. + """ + if self._cancel_availability_cb: + self._cancel_availability_cb() + + @callback + def async_handle_event(self, sia_event: SIAEvent) -> None: + """Listen to dispatcher events for this port and account and update state and attributes. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + _LOGGER.debug("Received event: %s", sia_event) + if int(sia_event.ri) == self._zone: + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) + self.update_state(sia_event) + self.async_reset_availability_cb() + self.async_write_ha_state() + + @abstractmethod + def update_state(self, sia_event: SIAEvent) -> None: + """Do the entity specific state updates.""" + + @callback + def async_reset_availability_cb(self) -> None: + """Reset availability cb by cancelling the current and creating a new one.""" + self._attr_available = True + if self._cancel_availability_cb: + self._cancel_availability_cb() + self.async_create_availability_cb() + + def async_create_availability_cb(self) -> None: + """Create a availability cb and return the callback.""" + self._cancel_availability_cb = async_call_later( + self.hass, + get_unavailability_interval(self._ping_interval), + self.async_set_unavailable, + ) + + @callback + def async_set_unavailable(self, _) -> None: + """Set unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info.""" + assert self._attr_name is not None + assert self.unique_id is not None + return { + "name": self._attr_name, + "identifiers": {(DOMAIN, self.unique_id)}, + "via_device": (DOMAIN, f"{self._port}_{self._account}"), + } diff --git a/custom_components/sia/strings.json b/custom_components/sia/strings.json index 1d7aeba..fe648c2 100644 --- a/custom_components/sia/strings.json +++ b/custom_components/sia/strings.json @@ -1,5 +1,4 @@ { - "title": "SIA Alarm Systems", "config": { "step": { "user": { @@ -10,7 +9,6 @@ "encryption_key": "Encryption Key", "ping_interval": "Ping Interval (min)", "zones": "Number of zones for the account", - "ignore_timestamps": "Ignore the timestamp check", "additional_account": "Additional accounts" }, "title": "Create a connection for SIA based alarm systems." @@ -21,7 +19,6 @@ "encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]", "ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]", "zones": "[%key:component::sia::config::step::user::data::zones%]", - "ignore_timestamps": "[%key:component::sia::config::step::user::data::ignore_timestamps%]", "additional_account": "[%key:component::sia::config::step::user::data::additional_account%]" }, "title": "Add another account to the current port." @@ -29,15 +26,24 @@ }, "error": { "invalid_key_format": "The key is not a hex value, please use only 0-9 and A-F.", - "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 characters hex characters.", + "invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 hex characters.", "invalid_account_format": "The account is not a hex value, please use only 0-9 and A-F.", "invalid_account_length": "The account is not the right length, it has to be between 3 and 16 characters.", "invalid_ping": "The ping interval needs to be between 1 and 1440 minutes.", "invalid_zones": "There needs to be at least 1 zone.", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Ignore the timestamp check of the SIA events", + "zones": "[%key:component::sia::config::step::user::data::zones%]" + }, + "description": "Set the options for account: {account}", + "title": "Options for the SIA Setup." + } } } } diff --git a/custom_components/sia/utils.py b/custom_components/sia/utils.py index ca51e02..9150099 100644 --- a/custom_components/sia/utils.py +++ b/custom_components/sia/utils.py @@ -1,66 +1,76 @@ +"""Helper functions for the SIA integration.""" +from __future__ import annotations + from datetime import timedelta -from typing import Tuple +from typing import Any -from homeassistant.const import DEVICE_CLASS_TIMESTAMP from pysiaalarm import SIAEvent -from .const import ( - HUB_SENSOR_NAME, - HUB_ZONE, - EVENT_ACCOUNT, - EVENT_CODE, - EVENT_ID, - EVENT_ZONE, - EVENT_MESSAGE, - EVENT_TIMESTAMP, -) +from homeassistant.util.dt import utcnow +from .const import ATTR_CODE, ATTR_ID, ATTR_MESSAGE, ATTR_TIMESTAMP, ATTR_ZONE -def get_entity_and_name( - port: int, account: str, zone: int = 0, entity_type: str = None -) -> Tuple[str, str]: - """Give back a entity_id and name according to the variables.""" - if zone == HUB_ZONE: - entity_type_name = ( - "Last Heartbeat" if entity_type == DEVICE_CLASS_TIMESTAMP else "Power" - ) - return ( - get_entity_id(port, account, zone, entity_type), - f"{port} - {account} - {entity_type_name}", - ) - if entity_type: - return ( - get_entity_id(port, account, zone, entity_type), - f"{port} - {account} - zone {zone} - {entity_type}", - ) - return None +PING_INTERVAL_MARGIN = 30 -def get_ping_interval(ping: int) -> timedelta: - """Return the ping interval as timedelta.""" - return timedelta(minutes=ping) +def get_unavailability_interval(ping: int) -> float: + """Return the interval to the next unavailability check.""" + return timedelta(minutes=ping, seconds=PING_INTERVAL_MARGIN).total_seconds() -def get_entity_id( - port: int, account: str, zone: int = 0, entity_type: str = None -) -> str: - """Give back a entity_id according to the variables, defaults to the hub sensor entity_id.""" - if zone == HUB_ZONE: - if entity_type == DEVICE_CLASS_TIMESTAMP: - return f"{port}_{account}_{HUB_SENSOR_NAME}" - return f"{port}_{account}_{entity_type}" - if entity_type: - return f"{port}_{account}_{zone}_{entity_type}" - return None +def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: + """Create the attributes dict from a SIAEvent.""" + return { + ATTR_ZONE: event.ri, + ATTR_CODE: event.code, + ATTR_MESSAGE: event.message, + ATTR_ID: event.id, + ATTR_TIMESTAMP: event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), + } -def sia_event_to_attr(event: SIAEvent) -> dict: - """Create the attributes dict from a SIAEvent.""" +def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: + """Create a dict from the SIA Event for the HA Event.""" return { - EVENT_ACCOUNT: event.account, - EVENT_ZONE: event.ri, - EVENT_CODE: event.code, - EVENT_MESSAGE: event.message, - EVENT_ID: event.id, - EVENT_TIMESTAMP: event.timestamp, + "message_type": event.message_type.value, + "receiver": event.receiver, + "line": event.line, + "account": event.account, + "sequence": event.sequence, + "content": event.content, + "ti": event.ti, + "id": event.id, + "ri": event.ri, + "code": event.code, + "message": event.message, + "x_data": event.x_data, + "timestamp": event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), + "event_qualifier": event.event_qualifier, + "event_type": event.event_type, + "partition": event.partition, + "extended_data": [ + { + "identifier": xd.identifier, + "name": xd.name, + "description": xd.description, + "length": xd.length, + "characters": xd.characters, + "value": xd.value, + } + for xd in event.extended_data + ] + if event.extended_data is not None + else None, + "sia_code": { + "code": event.sia_code.code, + "type": event.sia_code.type, + "description": event.sia_code.description, + "concerns": event.sia_code.concerns, + } + if event.sia_code is not None + else None, } From 2c68efad2b685e98199e7493d4acb196ac378a23 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 7 Jul 2022 09:45:01 +0200 Subject: [PATCH 63/63] prep for archive --- README.md | 86 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 12eb645..fa01b83 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,10 @@ -## OFFICIAL INTEGRATION IS NOW IN HA! +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) Make sure to delete the current integraiton, in your Integrations page, then delete the HACS custom component, reboot and then input your config in the official integration config. There are some settings, most importantly ignoring timestamps, in a options flow (press configure after installing the integration). -## OFFICIAL INTEGRATION IS NOW IN HA! ----------------- +## [OFFICIAL INTEGRATION IS NOW IN HA!](official) - -_Component to integrate with [SIA], based on [CheaterDev's version][ch_sia]._ - -This is the new stream which will be kept in sync with the [official integration][official]. - -In order to switch from an earlier version of the custom component to do this do not install this version over an existing version of the custom component, delete your integration in the integrations page, then update and reenter your config in the integrations page. - -If you are running the official integration, then you can install this one and things will work. - -The config used by earlier versions (before 1.0) is different and will cause errors, it is in sync with the official integration though! - - -**This component will set up the following platforms.** - -## WARNING -This integration may be unsecure. You can use it, but it's at your own risk. -This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. - -Platform | Description --- | -- -`binary_sensor` | A smoke and moisture sensor, one of each per account and zone. Power sensor for the hub. You can disable these sensors if you do not have those devices. -`alarm_control_panel` | Alarm panel with the state of the alarm, one per account and zone. You can disable this sensor if you have zones defined with just sensors and no alarm. -`sensor` | Sensor with the last heartbeat message from your system, one per account. Please do not disable this sensor as it will show you the status of the connection. - -## Features -- Alarm tracking with a alarm_control_panel component, but no alarm setting -- Fire/gas tracker -- Water leak tracker -- Hub Power status tracker -- AES-128 CBC encryption support - -## Hub Setup (Ajax Systems Hub example) - -1. Select "SIA Protocol". -2. Enable "Connect on demand". -3. Place Account Id/Object number - 3-16 ASCII hex characters. For example AAA. -4. Insert Home Assistant IP address. It must be a visible to hub. There is no cloud connection to it. -5. Insert Home Assistant listening port. This port must not be used with anything else. -6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. -7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. -8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. -9. Keep in mind that Monitoring Station will say "Connected" in the app if configured correctly. The sensors will have state "Unknown" until they get a new state. Arm/disarm to update the alarm sensor as an example. - -## Installation - -1. Click install. -1. The latest version is only available through a config flow. -1. After clicking the add button in the Integration pane, you full in the below fields. - -If you have multiple accounts that you want to monitor you can choose to have both communicating with the same port, in that case, use the additional accounts checkbox in the config so setup the second (and more) accounts. You can also choose to have both running on a different port, in that case setup the component twice. - -After setup you will see one entity per account for the heartbeat, and 3 entities for each zone per account, alarm, smoke sensor and moisture sensor. This means at least four entities are added, each will also have a device associated with it, so allow you to use the area feature. Unwanted sensors should be hidden in the interface. - -## Configuration options - -Key | Type | Required | Description --- | -- | -- | -- -`port` | `int` | `True` | Port that SIA will listen on. -`account` | `string` | `True` | Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. -`encryption_key` | `string` | `False` | Encoding key. 16 ASCII characters. Must be same, as in hub properties. -`ping_interval` | `int` | `True` | Ping interval in minutes that the alarm system uses to send "Automatic communication test report" messages, the HA component adds 30 seconds before marking a device unavailable. Must be between 1 and 1440 minutes, default is 1. -`zones` | `int` | `True` | The number of zones present for the account, default is 1. -`additional_account` | `bool` | `True` | Used to ask for additional accounts in multiple steps during setup, default is False. - -ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. -*** - -## Debugging -To turn on debugging go into your `configuration.yaml` and add these lines: -```yaml -logger: - default: error - logs: - custom_components.sia: debug - pysiaalarm: debug -``` - -[SIA]: https://github.com/eavanvalkenburg/sia-ha -[ch_sia]: https://github.com/Cheaterdev/sia-ha -[hacs]: https://github.com/custom-components/hacs -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge [official]: https://www.home-assistant.io/integrations/sia/