diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6bcce42 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..da55566 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component and HA setup (version, OS, etc) + + +## Configuration + +```yaml + +Add your configuration here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/unhandled_event_type.md b/.github/ISSUE_TEMPLATE/unhandled_event_type.md new file mode 100644 index 0000000..9ca0bb1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/unhandled_event_type.md @@ -0,0 +1,44 @@ +--- +name: Unhandled event type +about: Create a report to help us support more event types + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your configuration here. + +``` + +## Fill in the below info about the unhandled event +SIA codes from your logs or from [SIA](SIA_code.pdf) and [supported alarm states](https://developers.home-assistant.io/docs/en/entity_alarm_control_panel.html) or [supported states for binary_sensors](https://developers.home-assistant.io/docs/en/entity_binary_sensor.html) + +SIA Code | sensor_type (alarm, smoke, moisture) | expected state +-- | -- | -- + +## Debug logs with the requested codes + + + +```text + +Add your logs here. + +``` \ No newline at end of file diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..6b75ccc --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,23 @@ +repository: + private: false + has_issues: true + has_projects: false + has_wiki: false + has_downloads: false + default_branch: master + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: false +labels: + - name: "Feature Request" + color: "fbca04" + - name: "Bug" + color: "b60205" + - name: "Wont Fix" + color: "ffffff" + - name: "Enhancement" + color: a2eeef + - name: "Documentation" + color: "008672" + - name: "Stale" + color: "930191" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe9c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/ \ 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/README.md b/README.md index 3b17c6e..2aeca33 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,63 @@ -# 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]._ + +_Latest beta will be suggested for inclusion as a official integration._ + +**This component will set up the following platforms.** ## WARNING This integration may be unsecure. You can use it, but it's at your own risk. This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. +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. + ## Features +- Alarm tracking with a alarm_control_panel component - Fire/gas tracker - Water leak tracker -- Alarm tracking -- Armed state tracking -- Partial armed state tracking - 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. 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. - - -## Home Assistant Setup - -Place "sia" folder in **/custom_components** folder - -```yaml -# configuration.yaml - -sia: - port: **port** - hubs: - - name: **name** - account: **account** - password: *password* - -``` - -Configuration variables: -- **port** (*Required*): Listeting port -- **hubs** (*Required*): List of hubs -- **name** (*Required*): Used to generate sensor ids. -- **account** (*Required*): Hub account to track. 3-16 ASCII hex characters. Must be same, as in hub properties. -- **password** (*Optional*): Encoding key. 16 ASCII characters. Must be same, as in hub properties. - -## Disclaimer -This software is supplied "AS IS" without any warranties and support. +## 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. +*** + +[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 0000000..6e8d2ba Binary files /dev/null and b/SIA Codes.xlsx differ diff --git a/SIA_code.pdf b/SIA_code.pdf new file mode 100644 index 0000000..37b9a5c Binary files /dev/null and b/SIA_code.pdf differ diff --git a/codes.csv b/codes.csv new file mode 100644 index 0000000..f8216d0 --- /dev/null +++ b/codes.csv @@ -0,0 +1,281 @@ +CODE,DESCRIPTION +AN,ANALOG RESTORAL +AR,AC RESTORAL +AS,ANALOG SERVICE +AT,AC TROUBLE +BA,BURGLARY ALARM +BB,BURGLARY BYPASS +BC,BURGLARY CANCEL +BD,SWINGER TROUBLE +BE,SWINGER TRBL RESTORE +BH,BURG ALARM RESTORE +BJ,BURG TROUBLE RESTORE +BM,BURG ALARM CROSS PNT +BR,BURGLARY RESTORAL +BS,BURGLARY SUPERVISORY +BT,BURGLARY TROUBLE +BU,BURGLARY UNBYPASS +BV,BURGLARY VERIFIED +BX,BURGLARY TEST +BZ,MISSING SUPERVISION +CA,AUTOMATIC CLOSING +CD,CLOSING DELINQUENT +CE,CLOSING EXTEND +CF,FORCED CLOSING +CG,CLOSE AREA +CI,FAIL TO CLOSE +CJ,LATE CLOSE +CK,EARLY CLOSE +CL,CLOSING REPORT +CM,MISSING AL-RECNT CLS +CP,AUTOMATIC CLOSING +CR,RECENT CLOSING +CS,CLOSE KEY SWITCH +CT,LATE TO OPEN +CW,WAS FORCE ARMED +CZ,POINT CLOSING +DA,CARD ASSIGNED +DB,CARD DELETED +DC,ACCESS CLOSED +DD,ACCESS DENIED +DE,REQUEST TO ENTER +DF,DOOR FORCED +DG,ACCESS GRANTED +DH,DOOR LEFT OPEN-RSTRL +DJ,DOOR FORCED-TROUBLE +DK,ACCESS LOCKOUT +DL,DOOR LEFT OPEN-ALARM +DM,DOOR LEFT OPEN-TRBL +DN,DOOR LEFT OPEN +DO,ACCESS OPEN +DP,ACCESS DENIED-BAD TM +DQ,ACCESS DENIED-UN ARM +DR,DOOR RESTORAL +DS,DOOR STATION +DT,ACCESS TROUBLE +DU,DEALER ID# +DV,ACCESS DENIED-UN ENT +DW,ACCESS DENIED-INTRLK +DX,REQUEST TO EXIT +DY,DOOR LOCKED +DZ,ACCESS CLOSED STATE +EA,EXIT ALARM +EE,EXIT_ERROR +ER,EXPANSION RESTORAL +ET,EXPANSION TROUBLE +EX,EXTRNL DEVICE STATE +EZ,MISSING ALARM-EXT ER +FA,FIRE ALARM +FB,FIRE BYPASS +FC,FIRE CANCEL +FH,FIRE ALARM RESTORE +FI,FIRE TEST BEGIN +FJ,FIRE TROUBLE RESTORE +FK,FIRE TEST END +FM,FIRE ALARM CROSS PNT +FR,FIRE RESTORAL +FS,FIRE SUPERVISORY +FT,FIRE TROUBLE +FU,FIRE UNBYPASS +FX,FIRE TEST +FY,MISSING FIRE TROUBLE +FZ,MISSING FIRE SPRV +GA,GAS ALARM +GB,GAS BYPASS +GH,GAS ALARM RESTORE +GJ,GAS TROUBLE RESTORE +GR,GAS RESTORAL +GS,GAS SUPERVISORY +GT,GAS TROUBLE +GU,GAS UNBYPASS +GX,GAS TEST +HA,HOLDUP ALARM +HB,HOLDUP BYPASS +HH,HOLDUP ALARM RESTORE +HJ,HOLDUP TRBL RESTORE +HR,HOLDUP RESTORAL +HS,HOLDUP SUPERVISORY +HT,HOLDUP TROUBLE +HU,HOLDUP UNBYPASS +IA,EQPMT FAIL CONDITION +IR,EQPMT FAIL RESTORE +JA,USER CODE TAMPER +JD,DATE CHANGED +JH,HOLIDAY CHANGED +JK,LATCHKEY ALERT +JL,LOG THRESHOLD +JO,LOG OVERFLOW +JP,USER ON PREMISES +JR,SCHEDULE EXECUTED +JS,SCHEDULE CHANGED +JT,TIME CHANGED +JV,USER CODE CHANGED +JX,USER CODE DELETED +JY,USER CODE ADDED +JZ,USER LEVEL SET +KA,HEAT ALARM +KB,HEAT BYPASS +KH,HEAT ALARM RESTORE +KJ,HEAT TROUBLE RESTORE +KR,HEAT RESTORAL +KS,HEAT SUPERVISORY +KT,HEAT TROUBLE +KU,HEAT UNBYPASS +L_,LISTEN IN + SECONDS +LB,LOCAL PROG. BEGIN +LD,LOCAL PROG. DENIED +LE,LISTEN IN ENDED +LF,LISTEN IN BEGIN +LR,PHONE LINE RESTORAL +LS,LOCAL PROG. SUCCESS +LT,PHONE LINE TROUBLE +LU,LOCAL PROG. FAIL +LX,LOCAL PROG. ENDED +MA,MEDICAL ALARM +MB,MEDICAL BYPASS +MH,MEDIC ALARM RESTORE +MJ,MEDICAL TRBL RESTORE +MR,MEDICAL RESTORAL +MS,MEDICAL SUPERVISORY +MT,MEDICAL TROUBLE +MU,MEDICAL UNBYPASS +NA,NO ACTIVITY +NC,NETWORK CONDITION +NF,FORCED PERIMETER ARM +NL,PERIMETER ARMED +NR,NETWORK RESTORAL +NS,ACTIVITY RESUMED +NT,NETWORK FAILURE +OA,AUTOMATIC OPENING +OC,CANCEL REPORT +OG,OPEN AREA +OH,EARLY TO OPN FROM AL +OI,FAIL TO OPEN +OJ,LATE OPEN +OK,EARLY OPEN +OL,LATE TO OPEN FROM AL +OP,OPENING REPORT +OR,DISARM FROM ALARM +OS,OPEN KEY SWITCH +OT,LATE TO CLOSE +OZ,POINT OPENING +PA,PANIC ALARM +PB,PANIC BYPASS +PH,PANIC ALARM RESTORE +PJ,PANIC TRBL RESTORE +PR,PANIC RESTORAL +PS,PANIC SUPERVISORY +PT,PANIC TROUBLE +PU,PANIC UNBYPASS +QA,EMERGENCY ALARM +QB,EMERGENCY BYPASS +QH,EMRGCY ALARM RESTORE +QJ,EMRGCY TRBL RESTORE +QR,EMERGENCY RESTORAL +QS,EMRGCY SUPERVISORY +QT,EMERGENCY TROUBLE +QU,EMERGENCY UNBYPASS +RA,RMOTE PROG CALL FAIL +RB,REMOTE PROG. BEGIN +RC,RELAY CLOSE +RD,REMOTE PROG. DENIED +RN,REMOTE RESET +RO,RELAY OPEN +RP,AUTOMATIC TEST +RR,RESTORE POWER +RS,REMOTE PROG. SUCCESS +RT,DATA LOST +RU,REMOTE PROG. FAIL +RX,MANUAL TEST +RY,TEST OFF NORMAL +SA,SPRINKLER ALARM +SB,SPRINKLER BYPASS +SH,SPRKLR ALARM RESTORE +SJ,SPRKLR TRBL RESTORE +SR,SPRINKLER RESTORAL +SS,SPRINKLER SUPERVISRY +ST,SPRINKLER TROUBLE +SU,SPRINKLER UNBYPASS +TA,TAMPER ALARM +TB,TAMPER BYPASS +TC,ALL POINTS TESTED +TE,TEST END +TH,TAMPER ALRM RESTORE +TJ,TAMPER TRBL RESTORE +TP,WALK TEST POINT +TR,TAMPER RESTORAL +TS,TEST START +TT,TAMPER TROUBLE +TU,TAMPER UNBYPASS +TX,TEST REPORT +UA,UNTYPED ZONE ALARM +UB,UNTYPED ZONE BYPASS +UH,UNTYPD ALARM RESTORE +UJ,UNTYPED TRBL RESTORE +UR,UNTYPED ZONE RESTORE +US,UNTYPED ZONE SUPRVRY +UT,UNTYPED ZONE TROUBLE +UU,UNTYPED ZONE UNBYPSS +UX,UNDEFINED ALARM +UY,UNTYPED MISSING TRBL +UZ,UNTYPED MISSING ALRM +VI,PRINTER PAPER IN +VO,PRINTER PAPER OUT +VR,PRINTER RESTORE +VT,PRINTER TROUBLE +VX,PRINTER TEST +VY,PRINTER ONLINE +VZ,PRINTER OFFLINE +WA,WATER ALARM +WB,WATER BYPASS +WH,WATER ALARM RESTORE +WJ,WATER TRBL RESTORE +WR,WATER RESTORAL +WS,WATER SUPERVISORY +WT,WATER TROUBLE +WU,WATER UNBYPASS +XA,EXTRA ACCNT REPORT +XE,EXTRA POINT +XF,EXTRA RF POINT +XH,RF INTERFERENCE RST +XI,SENSOR RESET +XJ,RF RCVR TAMPER RST +XL,LOW RF SIGNAL +XM,MISSING ALRM-X POINT +XQ,RF INTERFERENCE +XR,TRANS. BAT. RESTORAL +XS,RF RECEIVER TAMPER +XT,TRANS. BAT. TROUBLE +XW,FORCED POINT +XX,FAIL TO TEST +YA,BELL FAULT +YB,BUSY SECONDS +YC,COMMUNICATIONS FAIL +YD,RCV LINECARD TROUBLE +YE,RCV LINECARD RESTORE +YF,PARA CHECKSUM FAIL +YG,PARAMETER CHANGED +YH,BELL RESTORED +YI,OVERCURRENT TROUBLE +YJ,OVERCURRENT RESTORE +YK,COMM. RESTORAL +YM,SYSTEM BATT MISSING +YN,INVALID REPORT +YO,UNKNOWN MESSAGE +YP,PWR SUPPLY TROUBLE +YQ,PWR SUPPLY RESTORE +YR,SYSTEM BAT. RESTORAL +YS,COMMUNICATIONS TRBL +YT,SYSTEM BAT. TROUBLE +YW,WATCHDOG RESET +YX,SERVICE REQUIRED +YY,STATUS REPORT +YZ,SERVICE COMPLETED +ZA,FREEZE ALARM +ZB,FREEZE BYPASS +ZH,FREEZE ALARM RESTORE +ZJ,FREEZE TRBL RESTORE +ZR,FREEZE RESTORAL +ZS,FREEZE SUPERVISORY +ZT,FREEZE TROUBLE +ZU,FREEZE UNBYPASS diff --git a/codes.pdf b/codes.pdf new file mode 100644 index 0000000..b763ad8 Binary files /dev/null and b/codes.pdf differ diff --git a/custom_components/sia/__init__.py b/custom_components/sia/__init__.py new file mode 100644 index 0000000..bd98da8 --- /dev/null +++ b/custom_components/sia/__init__.py @@ -0,0 +1,269 @@ +"""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 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the sia component.""" + hass.data[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() + 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 + + +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( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + 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 + ) + + 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 + ) + + 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_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 + ) + 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_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.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"] + ) + + 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}" + } + ) + + 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 new file mode 100644 index 0000000..fac07f2 --- /dev/null +++ b/custom_components/sia/alarm_control_panel.py @@ -0,0 +1,195 @@ +"""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, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util.dt import utcnow + +from .const import ( + CONF_ACCOUNT, + CONF_PING_INTERVAL, + DATA_UPDATED, + DOMAIN, + PING_INTERVAL_MARGIN, + PREVIOUS_STATE, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass, entry: ConfigEntry, async_add_devices: Callable[[], None] +) -> bool: + """Set up sia_alarm_control_panel from a config entry.""" + async_add_devices( + [ + device + for device in hass.data[DOMAIN][entry.entry_id].states.values() + if isinstance(device, SIAAlarmControlPanel) + ] + ) + + return True + + +class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): + """Class for SIA Alarm Control Panels.""" + + 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 + self._name = name + self._port = port + self._account = account + self._zone = zone + self._ping_interval = ping_interval + 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.""" + 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 = None + await self._async_track_unavailable() + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) + + @property + def name(self) -> str: + """Get Name.""" + return self._name + + @property + def ping_interval(self) -> int: + """Get ping_interval.""" + return str(self._ping_interval) + + @property + def state(self) -> str: + """Get state.""" + return self._state + + @property + def account(self) -> str: + """Return device account.""" + return self._account + + @property + def unique_id(self) -> str: + """Get unique_id.""" + return self._unique_id + + @property + def available(self) -> bool: + """Get availability.""" + return self._is_available + + @property + def device_state_attributes(self) -> dict: + """Return device attributes.""" + return self._attr + + @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() + + async def assume_available(self): + """Reset unavalability tracker.""" + await self._async_track_unavailable() + + @callback + async def _async_track_unavailable(self) -> bool: + """Reset unavailability.""" + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + self._remove_unavailability_tracker = async_track_point_in_utc_time( + self.hass, + self._async_set_unavailable, + utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, + ) + if not self._is_available: + self._is_available = True + return True + return False + + @callback + def _async_set_unavailable(self, _): + """Set availability.""" + self._remove_unavailability_tracker = None + self._is_available = False + self.async_schedule_update_ha_state() + + @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), + } diff --git a/custom_components/sia/binary_sensor.py b/custom_components/sia/binary_sensor.py new file mode 100644 index 0000000..ed4bd37 --- /dev/null +++ b/custom_components/sia/binary_sensor.py @@ -0,0 +1,185 @@ +"""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 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 .const import ( + CONF_ACCOUNT, + CONF_PING_INTERVAL, + DATA_UPDATED, + DOMAIN, + PING_INTERVAL_MARGIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass, entry: ConfigEntry, async_add_devices: Callable[[], None] +) -> bool: + """Set up sia_binary_sensor from a config entry.""" + async_add_devices( + [ + device + for device in hass.data[DOMAIN][entry.entry_id].states.values() + if isinstance(device, SIABinarySensor) + ] + ) + + return True + + +class SIABinarySensor(BinarySensorEntity, RestoreEntity): + """Class for SIA Binary Sensors.""" + + def __init__( + self, + entity_id: str, + name: str, + device_class: str, + port: int, + account: str, + zone: int, + ping_interval: int, + hass: HomeAssistant, + ): + """Create SIABinarySensor object.""" + + self.entity_id = BINARY_SENSOR_FORMAT.format(entity_id) + self._unique_id = entity_id + self._name = name + self._device_class = device_class + self._port = port + self._account = account + self._zone = zone + self._ping_interval = ping_interval + self.hass = hass + + self._should_poll = False + self._is_on = None + self._is_available = True + self._remove_unavailability_tracker = None + self._attr = { + CONF_ACCOUNT: self._account, + CONF_PING_INTERVAL: str(self._ping_interval), + CONF_ZONE: self._zone, + } + + async def async_added_to_hass(self): + """Add sensor to HASS.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state is not None: + if state.state == STATE_ON: + self._is_on = True + elif state.state == STATE_OFF: + self._is_on = False + await self._async_track_unavailable() + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + """Schedule update.""" + self.async_schedule_update_ha_state(True) + + @property + def name(self) -> str: + """Return name.""" + return self._name + + @property + def ping_interval(self) -> int: + """Get ping_interval.""" + return str(self._ping_interval) + + @property + def unique_id(self) -> str: + """Return unique id.""" + return self._unique_id + + @property + def account(self) -> str: + """Return device account.""" + return self._account + + @property + def available(self) -> bool: + """Return avalability.""" + return self._is_available + + @property + def device_state_attributes(self) -> dict: + """Return attributes.""" + return self._attr + + @property + def device_class(self) -> str: + """Return device class.""" + return self._device_class + + @property + def state(self) -> str: + """Return the state of the binary sensor.""" + if self.is_on is None: + return STATE_UNKNOWN + return STATE_ON if self.is_on else STATE_OFF + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._is_on + + @state.setter + def state(self, new_on: bool): + """Set state.""" + self._is_on = new_on + self.async_schedule_update_ha_state() + + async def assume_available(self): + """Reset unavalability tracker.""" + await self._async_track_unavailable() + + @callback + async def _async_track_unavailable(self) -> bool: + """Track availability.""" + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + self._remove_unavailability_tracker = async_track_point_in_utc_time( + self.hass, + self._async_set_unavailable, + utcnow() + self._ping_interval + PING_INTERVAL_MARGIN, + ) + if not self._is_available: + self._is_available = True + return True + return False + + @callback + def _async_set_unavailable(self, now): + """Set unavailable.""" + self._remove_unavailability_tracker = None + self._is_available = False + self.async_schedule_update_ha_state() + + @property + def device_info(self) -> dict: + """Return the device_info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self._port, self._account), + } diff --git a/custom_components/sia/config_flow.py b/custom_components/sia/config_flow.py new file mode 100644 index 0000000..4398c01 --- /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: dict) -> bool: + """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: dict = 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: dict = 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, + ) + 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..6a1d701 --- /dev/null +++ b/custom_components/sia/const.py @@ -0,0 +1,52 @@ +"""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 + +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 = "last_message" + +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"}, +} diff --git a/custom_components/sia/manifest.json b/custom_components/sia/manifest.json new file mode 100644 index 0000000..090d1a0 --- /dev/null +++ b/custom_components/sia/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "sia", + "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/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 new file mode 100644 index 0000000..a56877a --- /dev/null +++ b/custom_components/sia/sensor.py @@ -0,0 +1,142 @@ +"""Module for SIA Sensors.""" + +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 HomeAssistant, callback +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 + +_LOGGER = logging.getLogger(__name__) + + +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( + [ + 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, + 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) + 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: + 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 + ) + + @callback + def _schedule_immediate_update(self): + """Schedule update.""" + self.async_schedule_update_ha_state(True) + + @property + def name(self) -> str: + """Return name.""" + return self._name + + @property + def unique_id(self) -> str: + """Get unique_id.""" + return self._unique_id + + @property + def state(self) -> str: + """Return state.""" + return self._state.isoformat() + + @property + def account(self) -> str: + """Return device account.""" + return self._account + + @property + def device_state_attributes(self) -> dict: + """Return attributes.""" + return self._attr + + def add_attribute(self, attr: dict): + """Update attributes.""" + self._attr.update(attr) + + @property + def device_class(self) -> str: + """Return device class.""" + return self._device_class + + @state.setter + def state(self, state: dt.datetime): + """Set state.""" + self._state = state + self.async_schedule_update_ha_state() + + @property + def icon(self) -> str: + """Return the icon to use in the frontend, if any.""" + return "mdi:alarm-light-outline" + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return "ISO8601" + + @property + def device_info(self) -> dict: + """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/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 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 diff --git a/info.md b/info.md new file mode 100644 index 0000000..d2f4bc6 --- /dev/null +++ b/info.md @@ -0,0 +1,91 @@ +[![hacs][hacs_badge]](hacs) + +_Component to integrate with [SIA][sia], based on [CheaterDev's version][ch_sia]._ + +**This component will set up the following platforms.** + +## WARNING +This integration may be unsecure. You can use it, but it's at your own risk. +This integration was tested with Ajax Systems security hub only. Other SIA hubs may not work. + +Platform | Description +-- | -- +`binary_sensor` | A smoke or moisture sensor. +`alarm_control_panel` | Alarm panel with the state of the alarm. +`sensor` | Sensor with the last heartbeat message from your system. + +## Features +- Alarm tracking with a alarm_control_panel component +- Optional Fire/gas tracker +- Optional Water leak tracker +- AES-128 CBC encryption support + +## Hub Setup(Ajax Systems Hub example) + +1. Select "SIA Protocol". +2. Enable "Connect on demand". +3. Place Account Id - 3-16 ASCII hex characters. For example AAA. +4. Insert Home Assistant IP adress. It must be visible to hub. There is no cloud connection to it. +5. Insert Home Assistant listening port. This port must not be used with anything else. +6. Select Preferred Network. Ethernet is preferred if hub and HA in same network. Multiple networks are not tested. +7. Enable Periodic Reports. The interval with which the alarm systems reports to the monitoring station, default is 1 minute. This component adds 30 seconds before setting the alarm unavailable to deal with slights latencies between ajax and HA and the async nature of HA. +8. Encryption is on your risk. There is no CPU or network hit, so it's preferred. Password is 16 ASCII characters. +{% if not installed %} +## Installation + +1. Click install. +1. Add at least the minimum configuration to your HA configuration, see below. + +### 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` + +ASCII characters are 0-9 and ABCDEF, so a account is something like `346EB` and the encryption key is the same but 16 characters. +*** + +[sia]: https://github.com/eavanvalkenburg/sia-ha +[ch_sia]: https://github.com/Cheaterdev/sia-ha +[hacs]: https://github.com/custom-components/hacs +[hacs_badge]: https://img.shields.io/badge/HACS-Default-orange.svg) \ No newline at end of file diff --git a/sia/__init__.py b/sia/__init__.py deleted file mode 100644 index 68108b5..0000000 --- a/sia/__init__.py +++ /dev/null @@ -1,338 +0,0 @@ -import asyncio -import logging -import json -import voluptuous as vol -import sseclient -import requests -import time -from collections import defaultdict -from requests_toolbelt.utils import dump -from homeassistant.core import callback -import voluptuous as vol -from datetime import timedelta -from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change - -from threading import Thread -from homeassistant.helpers import discovery -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.helpers.restore_state import RestoreEntity -_LOGGER = logging.getLogger(__name__) -from homeassistant.const import (STATE_ON, STATE_OFF) - -from homeassistant.const import ( - CONF_NAME, CONF_PORT, CONF_PASSWORD) -import socketserver -from datetime import datetime -import time -import logging -import threading -import sys -import re - -from Crypto.Cipher import AES -from binascii import unhexlify,hexlify -from Crypto import Random -import random, string, base64 -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow - -DOMAIN = 'sia' -CONF_HUBS = 'hubs' -CONF_ACCOUNT = 'account' - -HUB_CONFIG = vol.Schema({ - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ACCOUNT): cv.string, - vol.Optional(CONF_PASSWORD):cv.string, -}) - - - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_PORT): cv.string, - vol.Required(CONF_HUBS, default={}): - vol.All(cv.ensure_list, [HUB_CONFIG]), - }), -}, extra=vol.ALLOW_EXTRA) - -ID_STRING = '"SIA-DCS"'.encode() -ID_STRING_ENCODED = '"*SIA-DCS"'.encode() - -TIME_TILL_UNAVAILABLE = timedelta(minutes=3) - -ID_R='\r'.encode() - -hass_platform = None - - -def setup(hass, config): - global hass_platform - socketserver.TCPServer.allow_reuse_address = True - hass_platform = hass - - hass_platform.data[DOMAIN] = {} - - port = int(config[DOMAIN][CONF_PORT]) - - for hub_config in config[DOMAIN][CONF_HUBS]: - if CONF_PASSWORD in hub_config: - hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = EncryptedHub(hass, hub_config) - else: - hass.data[DOMAIN][hub_config[CONF_ACCOUNT]] = Hub(hass, hub_config) - - for component in ['binary_sensor']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - server = socketserver.TCPServer(("", port), AlarmTCPHandler) - - t = threading.Thread(target=server.serve_forever) - t.start() - - return True - -class Hub: - reactions = { - "BA" : [{"state":"ALARM","value":True}], - "TA" : [{"state":"ALARM" ,"value":True}], - "CL" : [{"state":"STATUS" ,"value":False},{"state":"STATUS_TEMP" ,"value":False}], - "NL" : [{"state":"STATUS" ,"value":True},{"state":"STATUS_TEMP" ,"value":False}], - "WA": [{"state":"LEAK","value":True}], - "WH": [{"state":"LEAK" ,"value":False}], - "GA": [{"state":"GAS","value":True}], - "GH": [{"state":"GAS" ,"value":False}], - "BR" : [{"state":"ALARM","value":False}], - "OP" : [{"state":"STATUS","value":True},{"state":"STATUS_TEMP","value":True}], - "RP" : [] - } - - def __init__(self, hass, hub_config): - self._name = hub_config[CONF_NAME] - self._accountId = hub_config[CONF_ACCOUNT] - self._hass = hass - self._states = {} - self._states["LEAK"] = SIABinarySensor("sia_leak_" + self._name,"moisture" , hass) - self._states["GAS"] = SIABinarySensor("sia_gas_" + self._name,"smoke", hass) - self._states["ALARM"] = SIABinarySensor("sia_alarm_" + self._name,"safety", hass) - self._states["STATUS"] = SIABinarySensor("sia_status_" + self._name, "lock", hass) - self._states["STATUS_TEMP"] = SIABinarySensor("sia_status_temporal_" + self._name, "lock", hass) - - def manage_string(self, msg): - _LOGGER.debug("manage_string: " + msg) - - pos = msg.find('/') - assert pos>=0, "Can't find '/', message is possibly encrypted" - tipo = msg[pos+1:pos+3] - - if tipo in self.reactions: - reactions = self.reactions[tipo] - for reaction in reactions: - state = reaction["state"] - value = reaction["value"] - - self._states[state].new_state(value) - else: - _LOGGER.error("unknown event: " + tipo ) - - for device in self._states: - self._states[device].assume_available() - - - - def process_line(self, line): - _LOGGER.debug("Hub.process_line" + line.decode()) - pos = line.find(ID_STRING) - assert pos>=0, "Can't find ID_STRING, check encryption configs" - seq = line[pos+len(ID_STRING) : pos+len(ID_STRING)+4] - data = line[line.index(b'[') :] - _LOGGER.debug("Hub.process_line found data: " + data.decode()) - self.manage_string(data.decode()) - return '"ACK"' + (seq.decode()) + 'L0#' + (self._accountId) + '[]' - - -class EncryptedHub(Hub): - def __init__(self, hass, hub_config): - self._key = hub_config[CONF_PASSWORD].encode("utf8") - iv = Random.new().read(AES.block_size) - _cipher = AES.new(self._key, AES.MODE_CBC, iv) - self.iv2 = None - self._ending = hexlify(_cipher.encrypt( "00000000000000|]".encode("utf8") )).decode(encoding='UTF-8').upper() - Hub.__init__(self, hass, hub_config) - - def manage_string(self, msg): - iv = unhexlify("00000000000000000000000000000000") #where i need to find proper IV ? Only this works good. - _cipher = AES.new(self._key, AES.MODE_CBC, iv) - data = _cipher.decrypt(unhexlify(msg[1:])) - _LOGGER.debug("EncryptedHub.manage_string data: " + data.decode(encoding='UTF-8',errors='replace')) - - data = data[data.index(b'|'):] - resmsg = data.decode(encoding='UTF-8',errors='replace') - - Hub.manage_string(self, resmsg) - - def process_line(self, line): - _LOGGER.debug("EncryptedHub.process_line" + line.decode()) - pos = line.find(ID_STRING_ENCODED) - assert pos>=0, "Can't find ID_STRING_ENCODED, is SIA encryption enabled?" - seq = line[pos+len(ID_STRING_ENCODED) : pos+len(ID_STRING_ENCODED)+4] - data = line[line.index(b'[') :] - _LOGGER.debug("EncryptedHub.process_line found data: " + data.decode()) - self.manage_string(data.decode()) - return '"*ACK"' + (seq.decode()) + 'L0#' + (self._accountId) + '[' + self._ending - - - -class SIABinarySensor( RestoreEntity): - def __init__(self, name, device_class, hass): - self._device_class = device_class - self._should_poll = False - self._name = name - self.hass = hass - self._is_available = True - self._remove_unavailability_tracker = None - - async def async_added_to_hass(self): - await super().async_added_to_hass() - state = await self.async_get_last_state() - if state is not None and state.state is not None: - self._state = state.state == STATE_ON - else: - self._state = None - self._async_track_unavailable() - - @property - def name(self): - return self._name - - @property - def state(self): - return STATE_ON if self.is_on else STATE_OFF - - @property - def unique_id(self) -> str: - return self._name - - @property - def available(self): - return self._is_available - - @property - def device_state_attributes(self): - attrs = {} - return attrs - - @property - def device_class(self): - return self._device_class - - @property - def is_on(self): - return self._state - - def new_state(self, state): - self._state = state - self.async_schedule_update_ha_state() - - def assume_available(self): - self._async_track_unavailable() - - @callback - def _async_track_unavailable(self): - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, self._async_set_unavailable, - utcnow() + TIME_TILL_UNAVAILABLE) - if not self._is_available: - self._is_available = True - return True - return False - - @callback - def _async_set_unavailable(self, now): - self._remove_unavailability_tracker = None - self._is_available = False - self.async_schedule_update_ha_state() - -class AlarmTCPHandler(socketserver.BaseRequestHandler): - _received_data = "".encode() - - def handle_line(self, line): - _LOGGER.debug("Income raw string: " + line.decode()) - accountId = line[line.index(b'#') +1: line.index(b'[')].decode() - - pos = line.find(b'"') - assert pos>=0, "Can't find message beginning" - inputMessage=line[pos:] - msgcrc = line[0:4] - codecrc = str.encode(AlarmTCPHandler.CRCCalc(inputMessage)) - try: - if msgcrc != codecrc: - raise Exception('CRC mismatch') - if(accountId not in hass_platform.data[DOMAIN]): - raise Exception('Not supported account ' + accountId) - response = hass_platform.data[DOMAIN][accountId].process_line(line) - except Exception as e: - _LOGGER.error(str(e)) - timestamp = datetime.fromtimestamp(time.time()).strftime('_%H:%M:%S,%m-%d-%Y') - response = '"NAK"0000L0R0A0[]' + timestamp - - header = ('%04x' % len(response)).upper() - CRC = AlarmTCPHandler.CRCCalc2(response) - response="\n" + CRC + header + response + "\r" - - byte_response = str.encode(response) - self.request.sendall(byte_response) - - - def handle(self): - line = b'' - try: - while True: - raw = self.request.recv(1024) - if (not raw) or (len(raw) == 0): - return - raw = bytearray(raw) - while True: - splitter = raw.find(b'\r') - if splitter> -1: - line = raw[1:splitter] - raw = raw[splitter+1:] - else: - break - - self.handle_line(line) - except Exception as e: - _LOGGER.error(str(e)+" last line: " + line.decode()) - return - - @staticmethod - def CRCCalc(msg): - CRC=0 - for letter in msg: - temp=(letter) - for j in range(0,8): # @UnusedVariable - temp ^= CRC & 1 - CRC >>= 1 - if (temp & 1) != 0: - CRC ^= 0xA001 - temp >>= 1 - - return ('%x' % CRC).upper().zfill(4) - - @staticmethod - def CRCCalc2(msg): - CRC=0 - for letter in msg: - temp=ord(letter) - for j in range(0,8): # @UnusedVariable - temp ^= CRC & 1 - CRC >>= 1 - if (temp & 1) != 0: - CRC ^= 0xA001 - temp >>= 1 - - return ('%x' % CRC).upper().zfill(4) \ No newline at end of file diff --git a/sia/binary_sensor.py b/sia/binary_sensor.py deleted file mode 100644 index 9c99b97..0000000 --- a/sia/binary_sensor.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging -import json - -DOMAIN = 'sia' -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): - devices = [] - for account in hass.data[DOMAIN]: - for device in hass.data[DOMAIN][account]._states: - devices.append(hass.data[DOMAIN][account]._states[device]) - add_entities(devices) - diff --git a/sia/manifest.json b/sia/manifest.json deleted file mode 100644 index 0919449..0000000 --- a/sia/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "sia", - "name": "Sia", - "documentation": "", - "dependencies": [], - "codeowners": ["@cheater.dev"], - "requirements": [] -} \ No newline at end of file