Add SIA Alarm systems (#36625)

* initial commit of SIA integration

* translations

* moved reactions to file, typed everything

* fixed no-else-return 3 times

* refactored config and fix coverage of test

* fix requirements_test

* elimated another platform

* forgot some mentions of sensor

* updated config flow steps, fixed restore and small edits

* fixed pylint

* updated config_flow with better schema, small fixes from review

* final comment and small legibility enhancements

* small fix for pylint

* fixed init

* fixes for botched rebase

* fixed port string

* updated common strings

* rebuild component with eventbus

* fixed pylint and tests

* updates based on review by @bdraco

* updates based on new version of package and reviews

* small updates with latest package

* added raise from

* deleted async_setup from test

* fixed tests

* removed unused code from addititional account step

* fixed typo in strings

* clarification and update to update_data func

* added iot_class to manifest

* fixed entity and unique id setup

* small fix in tests

* improved unique_id semantics and load/unload functions

* added typing in order to fix mypy

* further fixes for typing

* final fixes for mypy

* adding None return types

* fix hub DR identifier

* rebased, added DeviceInfo

* rewrite to clean up and make it easier to read

* replaced functions with format for id and name

* renamed tracker remover small fix in state.setter

* improved readibility of state.setter

* no more state.setter and small updates

* mypy fix

* fixed and improved config flow

* added fixtures to test and other cleaner test code

* removed timeband from config, will reintro in a options flow

* removed timeband from tests

* added options flow for zones and timestamps

* removed type ignore

* replaced mapping with collections.abc
This commit is contained in:
Eduard van Valkenburg
2021-05-24 08:48:28 +02:00
committed by GitHub
parent d7da32cbb9
commit 0bba0f07ac
16 changed files with 1140 additions and 1 deletions

View File

@ -916,6 +916,11 @@ omit =
homeassistant/components/skybeacon/sensor.py
homeassistant/components/skybell/*
homeassistant/components/slack/notify.py
homeassistant/components/sia/__init__.py
homeassistant/components/sia/alarm_control_panel.py
homeassistant/components/sia/const.py
homeassistant/components/sia/hub.py
homeassistant/components/sia/utils.py
homeassistant/components/sinch/*
homeassistant/components/slide/*
homeassistant/components/sma/__init__.py

View File

@ -17,7 +17,7 @@ repos:
hooks:
- id: codespell
args:
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba
- --skip="./.*,*.csv,*.json"
- --quiet-level=2
exclude_types: [csv, json]

View File

@ -431,6 +431,7 @@ homeassistant/components/shell_command/* @home-assistant/core
homeassistant/components/shelly/* @balloob @bieniu @thecode @chemelli74
homeassistant/components/shiftr/* @fabaff
homeassistant/components/shodan/* @fabaff
homeassistant/components/sia/* @eavanvalkenburg
homeassistant/components/sighthound/* @robmarkcole
homeassistant/components/signal_messenger/* @bbernhard
homeassistant/components/simplisafe/* @bachya

View File

@ -0,0 +1,34 @@
"""The sia integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .hub import SIAHub
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up sia from a config entry."""
hub: SIAHub = SIAHub(hass, entry)
await hub.async_setup_hub()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = hub
try:
await hub.sia_client.start(reuse_port=True)
except OSError as exc:
raise ConfigEntryNotReady(
f"SIA Server at port {entry.data[CONF_PORT]} could not start."
) from exc
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id)
await hub.async_shutdown()
return unload_ok

View File

@ -0,0 +1,253 @@
"""Module for SIA Alarm Control Panels."""
from __future__ import annotations
import logging
from typing import Any, Callable
from pysiaalarm import SIAEvent
from homeassistant.components.alarm_control_panel import (
ENTITY_ID_FORMAT as ALARM_ENTITY_ID_FORMAT,
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_UNAVAILABLE,
)
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
from .const import (
CONF_ACCOUNT,
CONF_ACCOUNTS,
CONF_PING_INTERVAL,
CONF_ZONES,
DOMAIN,
SIA_ENTITY_ID_FORMAT,
SIA_EVENT,
SIA_NAME_FORMAT,
SIA_UNIQUE_ID_FORMAT_ALARM,
)
from .utils import get_attr_from_sia_event, get_unavailability_interval
_LOGGER = logging.getLogger(__name__)
DEVICE_CLASS_ALARM = "alarm"
PREVIOUS_STATE = "previous_state"
CODE_CONSEQUENCES: dict[str, StateType] = {
"PA": STATE_ALARM_TRIGGERED,
"JA": STATE_ALARM_TRIGGERED,
"TA": STATE_ALARM_TRIGGERED,
"BA": STATE_ALARM_TRIGGERED,
"CA": STATE_ALARM_ARMED_AWAY,
"CB": STATE_ALARM_ARMED_AWAY,
"CG": STATE_ALARM_ARMED_AWAY,
"CL": STATE_ALARM_ARMED_AWAY,
"CP": STATE_ALARM_ARMED_AWAY,
"CQ": STATE_ALARM_ARMED_AWAY,
"CS": STATE_ALARM_ARMED_AWAY,
"CF": STATE_ALARM_ARMED_CUSTOM_BYPASS,
"OA": STATE_ALARM_DISARMED,
"OB": STATE_ALARM_DISARMED,
"OG": STATE_ALARM_DISARMED,
"OP": STATE_ALARM_DISARMED,
"OQ": STATE_ALARM_DISARMED,
"OR": STATE_ALARM_DISARMED,
"OS": STATE_ALARM_DISARMED,
"NC": STATE_ALARM_ARMED_NIGHT,
"NL": STATE_ALARM_ARMED_NIGHT,
"BR": PREVIOUS_STATE,
"NP": PREVIOUS_STATE,
"NO": PREVIOUS_STATE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[..., None],
) -> bool:
"""Set up SIA alarm_control_panel(s) from a config entry."""
async_add_entities(
[
SIAAlarmControlPanel(entry, account_data, zone)
for account_data in entry.data[CONF_ACCOUNTS]
for zone in range(
1,
entry.options[CONF_ACCOUNTS][account_data[CONF_ACCOUNT]][CONF_ZONES]
+ 1,
)
]
)
return True
class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity):
"""Class for SIA Alarm Control Panels."""
def __init__(
self,
entry: ConfigEntry,
account_data: dict[str, Any],
zone: int,
):
"""Create SIAAlarmControlPanel object."""
self._entry: ConfigEntry = entry
self._account_data: dict[str, Any] = account_data
self._zone: int = zone
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.entity_id: str = ALARM_ENTITY_ID_FORMAT.format(
SIA_ENTITY_ID_FORMAT.format(
self._port, self._account, self._zone, DEVICE_CLASS_ALARM
)
)
self._attr: dict[str, Any] = {
CONF_PORT: self._port,
CONF_ACCOUNT: self._account,
CONF_ZONE: self._zone,
CONF_PING_INTERVAL: f"{self._ping_interval} minute(s)",
}
self._available: bool = True
self._state: StateType = None
self._old_state: StateType = None
self._cancel_availability_cb: CALLBACK_TYPE | None = None
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.
Overridden from Entity.
1. start the event listener and add the callback to on_remove
2. get previous state from storage
3. if previous state: restore
4. if previous state is unavailable: set _available to False and return
5. if available: create availability cb
"""
self.async_on_remove(
self.hass.bus.async_listen(
event_type=SIA_EVENT.format(self._port, self._account),
listener=self.async_handle_event,
)
)
last_state = await self.async_get_last_state()
if last_state is not None:
self._state = last_state.state
if self.state == STATE_UNAVAILABLE:
self._available = False
return
self._cancel_availability_cb = self.async_create_availability_cb()
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()
async def async_handle_event(self, event: Event) -> None:
"""Listen to 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.
"""
sia_event: SIAEvent = SIAEvent.from_dict( # pylint: disable=no-member
event.data
)
_LOGGER.debug("Received event: %s", sia_event)
if int(sia_event.ri) == self._zone:
self._attr.update(get_attr_from_sia_event(sia_event))
new_state = CODE_CONSEQUENCES.get(sia_event.code, None)
if new_state is not None:
if new_state == PREVIOUS_STATE:
new_state = self._old_state
self._state, self._old_state = new_state, self._state
self._available = True
self.async_write_ha_state()
self.async_reset_availability_cb()
@callback
def async_reset_availability_cb(self) -> None:
"""Reset availability cb by cancelling the current and creating a new one."""
if self._cancel_availability_cb:
self._cancel_availability_cb()
self._cancel_availability_cb = self.async_create_availability_cb()
@callback
def async_create_availability_cb(self) -> CALLBACK_TYPE:
"""Create a availability cb and return the callback."""
return 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._available = False
self.async_write_ha_state()
@property
def state(self) -> StateType:
"""Get state."""
return self._state
@property
def name(self) -> str:
"""Get Name."""
return SIA_NAME_FORMAT.format(
self._port, self._account, self._zone, DEVICE_CLASS_ALARM
)
@property
def unique_id(self) -> str:
"""Get unique_id."""
return SIA_UNIQUE_ID_FORMAT_ALARM.format(
self._entry.entry_id, self._account, self._zone
)
@property
def available(self) -> bool:
"""Get availability."""
return self._available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device attributes."""
return self._attr
@property
def should_poll(self) -> bool:
"""Return False if entity pushes its state to HA."""
return False
@property
def supported_features(self) -> int:
"""Flag supported features."""
return 0
@property
def device_info(self) -> DeviceInfo:
"""Return the device_info."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.name,
"via_device": (DOMAIN, f"{self._port}_{self._account}"),
}

View File

@ -0,0 +1,232 @@
"""Config flow for sia integration."""
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
import logging
from typing import Any
from pysiaalarm import (
InvalidAccountFormatError,
InvalidAccountLengthError,
InvalidKeyFormatError,
InvalidKeyLengthError,
SIAAccount,
)
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import callback
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_ACCOUNT,
CONF_ACCOUNTS,
CONF_ADDITIONAL_ACCOUNTS,
CONF_ENCRYPTION_KEY,
CONF_IGNORE_TIMESTAMPS,
CONF_PING_INTERVAL,
CONF_ZONES,
DOMAIN,
TITLE,
)
from .hub import SIAHub
_LOGGER = logging.getLogger(__name__)
HUB_SCHEMA = vol.Schema(
{
vol.Required(CONF_PORT): int,
vol.Optional(CONF_PROTOCOL, default="TCP"): vol.In(["TCP", "UDP"]),
vol.Required(CONF_ACCOUNT): str,
vol.Optional(CONF_ENCRYPTION_KEY): str,
vol.Required(CONF_PING_INTERVAL, default=1): int,
vol.Required(CONF_ZONES, default=1): int,
vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool,
}
)
ACCOUNT_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACCOUNT): str,
vol.Optional(CONF_ENCRYPTION_KEY): str,
vol.Required(CONF_PING_INTERVAL, default=1): int,
vol.Required(CONF_ZONES, default=1): int,
vol.Optional(CONF_ADDITIONAL_ACCOUNTS, default=False): bool,
}
)
DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None}
def validate_input(data: ConfigType) -> dict[str, str] | None:
"""Validate the input by the user."""
try:
SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY))
except InvalidKeyFormatError:
return {"base": "invalid_key_format"}
except InvalidKeyLengthError:
return {"base": "invalid_key_length"}
except InvalidAccountFormatError:
return {"base": "invalid_account_format"}
except InvalidAccountLengthError:
return {"base": "invalid_account_length"}
except Exception as exc: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception from SIAAccount: %s", exc)
return {"base": "unknown"}
if not 1 <= data[CONF_PING_INTERVAL] <= 1440:
return {"base": "invalid_ping"}
return validate_zones(data)
def validate_zones(data: ConfigType) -> dict[str, str] | None:
"""Validate the zones field."""
if data[CONF_ZONES] == 0:
return {"base": "invalid_zones"}
return None
class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for sia."""
VERSION: int = 1
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return SIAOptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize the config flow."""
self._data: ConfigType = {}
self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}}
async def async_step_user(self, user_input: ConfigType = None):
"""Handle the initial user step."""
errors: dict[str, str] | None = None
if user_input is not None:
errors = validate_input(user_input)
if user_input is None or errors is not None:
return self.async_show_form(
step_id="user", data_schema=HUB_SCHEMA, errors=errors
)
return await self.async_handle_data_and_route(user_input)
async def async_step_add_account(self, user_input: ConfigType = None):
"""Handle the additional accounts steps."""
errors: dict[str, str] | None = None
if user_input is not None:
errors = validate_input(user_input)
if user_input is None or errors is not None:
return self.async_show_form(
step_id="add_account", data_schema=ACCOUNT_SCHEMA, errors=errors
)
return await self.async_handle_data_and_route(user_input)
async def async_handle_data_and_route(self, user_input: ConfigType):
"""Handle the user_input, check if configured and route to the right next step or create entry."""
self._update_data(user_input)
if self._data and self._port_already_configured():
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=TITLE.format(self._data[CONF_PORT]),
data=self._data,
options=self._options,
)
def _update_data(self, user_input: ConfigType) -> None:
"""Parse the user_input and store in data and options attributes.
If there is a port in the input or no data, assume it is fully new and overwrite.
Add the default options and overwrite the zones in options.
"""
if not self._data or user_input.get(CONF_PORT):
self._data = {
CONF_PORT: user_input[CONF_PORT],
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
CONF_ACCOUNTS: [],
}
account = user_input[CONF_ACCOUNT]
self._data[CONF_ACCOUNTS].append(
{
CONF_ACCOUNT: account,
CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY),
CONF_PING_INTERVAL: user_input[CONF_PING_INTERVAL],
}
)
self._options[CONF_ACCOUNTS].setdefault(account, deepcopy(DEFAULT_OPTIONS))
self._options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES]
def _port_already_configured(self):
"""See if we already have a SIA entry matching the port."""
for entry in self._async_current_entries(include_ignore=False):
if entry.data[CONF_PORT] == self._data[CONF_PORT]:
return True
return False
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: ConfigType = None):
"""Manage the SIA options."""
self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id]
if self.hub is not None and 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: ConfigType = None):
"""Create the options step for a account."""
errors: dict[str, str] | None = None
if user_input is not None:
errors = validate_zones(user_input)
if user_input is None or errors is not None:
account = self.accounts_todo[0]
return self.async_show_form(
step_id="options",
description_placeholders={"account": account},
data_schema=vol.Schema(
{
vol.Optional(
CONF_ZONES,
default=self.options[CONF_ACCOUNTS][account][CONF_ZONES],
): int,
vol.Optional(
CONF_IGNORE_TIMESTAMPS,
default=self.options[CONF_ACCOUNTS][account][
CONF_IGNORE_TIMESTAMPS
],
): bool,
}
),
errors=errors,
last_step=self.last_step,
)
account = self.accounts_todo.pop(0)
self.options[CONF_ACCOUNTS][account][CONF_IGNORE_TIMESTAMPS] = user_input[
CONF_IGNORE_TIMESTAMPS
]
self.options[CONF_ACCOUNTS][account][CONF_ZONES] = user_input[CONF_ZONES]
if self.accounts_todo:
return await self.async_step_options()
_LOGGER.warning("Updating SIA Options with %s", self.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

View File

@ -0,0 +1,38 @@
"""Constants for the sia integration."""
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
)
PLATFORMS = [ALARM_CONTROL_PANEL_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"
CONF_IGNORE_TIMESTAMPS = "ignore_timestamps"
DOMAIN = "sia"
TITLE = "SIA Alarm on port {}"
SIA_EVENT = "sia_event_{}_{}"
SIA_NAME_FORMAT = "{} - {} - zone {} - {}"
SIA_NAME_FORMAT_HUB = "{} - {} - {}"
SIA_ENTITY_ID_FORMAT = "{}_{}_{}_{}"
SIA_ENTITY_ID_FORMAT_HUB = "{}_{}_{}"
SIA_UNIQUE_ID_FORMAT_ALARM = "{}_{}_{}"
SIA_UNIQUE_ID_FORMAT = "{}_{}_{}_{}"
HUB_SENSOR_NAME = "last_heartbeat"
HUB_ZONE = 0
PING_INTERVAL_MARGIN = 30
DEFAULT_TIMEBAND = (80, 40)
IGNORED_TIMEBAND = (3600, 1800)
EVENT_CODE = "last_code"
EVENT_ACCOUNT = "account"
EVENT_ZONE = "zone"
EVENT_PORT = "port"
EVENT_MESSAGE = "last_message"
EVENT_ID = "last_id"
EVENT_TIMESTAMP = "last_timestamp"

View File

@ -0,0 +1,138 @@
"""The sia hub."""
from __future__ import annotations
from copy import deepcopy
import logging
from typing import Any
from pysiaalarm.aio import CommunicationsProtocol, SIAAccount, SIAClient, 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.helpers import device_registry as dr
from .const import (
CONF_ACCOUNT,
CONF_ACCOUNTS,
CONF_ENCRYPTION_KEY,
CONF_IGNORE_TIMESTAMPS,
CONF_ZONES,
DEFAULT_TIMEBAND,
DOMAIN,
IGNORED_TIMEBAND,
PLATFORMS,
SIA_EVENT,
)
_LOGGER = logging.getLogger(__name__)
class SIAHub:
"""Class for SIA Hubs."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
):
"""Create the SIAHub."""
self._hass: HomeAssistant = hass
self._entry: ConfigEntry = entry
self._port: int = int(entry.data[CONF_PORT])
self._title: str = entry.title
self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS])
self._protocol: str = entry.data[CONF_PROTOCOL]
self.sia_accounts: list[SIAAccount] | None = None
self.sia_client: SIAClient = None
async def async_setup_hub(self) -> None:
"""Add a device to the device_registry, register shutdown listener, load reactions."""
self.update_accounts()
device_registry = await dr.async_get_registry(self._hass)
for acc in self._accounts:
account = acc[CONF_ACCOUNT]
device_registry.async_get_or_create(
config_entry_id=self._entry.entry_id,
identifiers={(DOMAIN, f"{self._port}_{account}")},
name=f"{self._port} - {account}",
)
self._entry.async_on_unload(
self._entry.add_update_listener(self.async_config_entry_updated)
)
self._entry.async_on_unload(
self._hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.async_shutdown)
)
async def async_shutdown(self, _: Event = None) -> None:
"""Shutdown the SIA server."""
await self.sia_client.stop()
async def async_create_and_fire_event(self, event: SIAEvent) -> None:
"""Create a event on HA'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 bus for code %s for port %s and account %s",
event.code,
self._port,
event.account,
)
self._hass.bus.async_fire(
event_type=SIA_EVENT.format(self._port, event.account),
event_data=event.to_dict(encode_json=True),
origin=EventOrigin.remote,
)
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].keys():
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)

View File

@ -0,0 +1,9 @@
{
"domain": "sia",
"name": "SIA Alarm Systems",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sia",
"requirements": ["pysiaalarm==3.0.0b12"],
"codeowners": ["@eavanvalkenburg"],
"iot_class": "local_push"
}

View File

@ -0,0 +1,50 @@
{
"title": "SIA Alarm Systems",
"config": {
"step": {
"user": {
"data": {
"port": "[%key:common::config_flow::data::port%]",
"protocol": "Protocol",
"account": "Account ID",
"encryption_key": "Encryption Key",
"ping_interval": "Ping Interval (min)",
"zones": "Number of zones for the account",
"additional_account": "Additional accounts"
},
"title": "Create a connection for SIA based alarm systems."
},
"additional_account": {
"data": {
"account": "[%key:component::sia::config::step::user::data::account%]",
"encryption_key": "[%key:component::sia::config::step::user::data::encryption_key%]",
"ping_interval": "[%key:component::sia::config::step::user::data::ping_interval%]",
"zones": "[%key:component::sia::config::step::user::data::zones%]",
"additional_account": "[%key:component::sia::config::step::user::data::additional_account%]"
},
"title": "Add another account to the current port."
}
},
"error": {
"invalid_key_format": "The key is not a hex value, please use only 0-9 and A-F.",
"invalid_key_length": "The key is not the right length, it has to be 16, 24 or 32 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": "[%key:common::config_flow::error::unknown%]"
}
},
"options": {
"step": {
"options": {
"data": {
"ignore_timestamps": "Ignore the timestamp check of the SIA events",
"zones": "[%key:component::sia::config::step::user::data::zones%]"
},
"description": "Set the options for account: {account}",
"title": "Options for the SIA Setup."
}
}
}
}

View File

@ -0,0 +1,57 @@
"""Helper functions for the SIA integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from pysiaalarm import SIAEvent
from homeassistant.const import DEVICE_CLASS_TIMESTAMP
from .const import (
EVENT_ACCOUNT,
EVENT_CODE,
EVENT_ID,
EVENT_MESSAGE,
EVENT_TIMESTAMP,
EVENT_ZONE,
HUB_SENSOR_NAME,
HUB_ZONE,
PING_INTERVAL_MARGIN,
)
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_name(port: int, account: str, zone: int, entity_type: str) -> str:
"""Give back a entity_id and name according to the variables."""
if zone == HUB_ZONE:
return f"{port} - {account} - {'Last Heartbeat' if entity_type == DEVICE_CLASS_TIMESTAMP else 'Power'}"
return f"{port} - {account} - zone {zone} - {entity_type}"
def get_entity_id(port: int, account: str, zone: int, entity_type: str) -> str:
"""Give back a entity_id according to the variables."""
if zone == HUB_ZONE:
return f"{port}_{account}_{HUB_SENSOR_NAME if entity_type == DEVICE_CLASS_TIMESTAMP else entity_type}"
return f"{port}_{account}_{zone}_{entity_type}"
def get_unique_id(entry_id: str, account: str, zone: int, domain: str) -> str:
"""Return the unique id."""
return f"{entry_id}_{account}_{zone}_{domain}"
def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]:
"""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,
}

View File

@ -219,6 +219,7 @@ FLOWS = [
"sharkiq",
"shelly",
"shopping_list",
"sia",
"simplisafe",
"sma",
"smappee",

View File

@ -1722,6 +1722,9 @@ pysesame2==1.0.1
# homeassistant.components.goalfeed
pysher==1.0.1
# homeassistant.components.sia
pysiaalarm==3.0.0b12
# homeassistant.components.signal_messenger
pysignalclirestapi==0.3.4

View File

@ -961,6 +961,9 @@ pyserial-asyncio==0.5
# homeassistant.components.zha
pyserial==3.5
# homeassistant.components.sia
pysiaalarm==3.0.0b12
# homeassistant.components.signal_messenger
pysignalclirestapi==0.3.4

View File

@ -0,0 +1 @@
"""Tests for the sia integration."""

View File

@ -0,0 +1,314 @@
"""Test the sia config flow."""
import logging
from unittest.mock import patch
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.sia.config_flow import ACCOUNT_SCHEMA, HUB_SCHEMA
from homeassistant.components.sia.const import (
CONF_ACCOUNT,
CONF_ACCOUNTS,
CONF_ADDITIONAL_ACCOUNTS,
CONF_ENCRYPTION_KEY,
CONF_IGNORE_TIMESTAMPS,
CONF_PING_INTERVAL,
CONF_ZONES,
DOMAIN,
)
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
_LOGGER = logging.getLogger(__name__)
BASIS_CONFIG_ENTRY_ID = 1
BASIC_CONFIG = {
CONF_PORT: 7777,
CONF_PROTOCOL: "TCP",
CONF_ACCOUNT: "ABCDEF",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 10,
CONF_ZONES: 1,
CONF_ADDITIONAL_ACCOUNTS: False,
}
BASIC_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 2}
BASE_OUT = {
"data": {
CONF_PORT: 7777,
CONF_PROTOCOL: "TCP",
CONF_ACCOUNTS: [
{
CONF_ACCOUNT: "ABCDEF",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 10,
},
],
},
"options": {
CONF_ACCOUNTS: {"ABCDEF": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 1}}
},
}
ADDITIONAL_CONFIG_ENTRY_ID = 2
BASIC_CONFIG_ADDITIONAL = {
CONF_PORT: 7777,
CONF_PROTOCOL: "TCP",
CONF_ACCOUNT: "ABCDEF",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 10,
CONF_ZONES: 1,
CONF_ADDITIONAL_ACCOUNTS: True,
}
ADDITIONAL_ACCOUNT = {
CONF_ACCOUNT: "ACC2",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 2,
CONF_ZONES: 2,
CONF_ADDITIONAL_ACCOUNTS: False,
}
ADDITIONAL_OUT = {
"data": {
CONF_PORT: 7777,
CONF_PROTOCOL: "TCP",
CONF_ACCOUNTS: [
{
CONF_ACCOUNT: "ABCDEF",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 10,
},
{
CONF_ACCOUNT: "ACC2",
CONF_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAA",
CONF_PING_INTERVAL: 2,
},
],
},
"options": {
CONF_ACCOUNTS: {
"ABCDEF": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 1},
"ACC2": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 2},
}
},
}
ADDITIONAL_OPTIONS = {
CONF_ACCOUNTS: {
"ABCDEF": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 2},
"ACC2": {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: 2},
}
}
BASIC_CONFIG_ENTRY = MockConfigEntry(
domain=DOMAIN,
data=BASE_OUT["data"],
options=BASE_OUT["options"],
title="SIA Alarm on port 7777",
entry_id=BASIS_CONFIG_ENTRY_ID,
version=1,
)
ADDITIONAL_CONFIG_ENTRY = MockConfigEntry(
domain=DOMAIN,
data=ADDITIONAL_OUT["data"],
options=ADDITIONAL_OUT["options"],
title="SIA Alarm on port 7777",
entry_id=ADDITIONAL_CONFIG_ENTRY_ID,
version=1,
)
@pytest.fixture(params=[False, True], ids=["user", "add_account"])
def additional(request) -> bool:
"""Return True or False for the additional or base test."""
return request.param
@pytest.fixture
async def flow_at_user_step(hass):
"""Return a initialized flow."""
return await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
@pytest.fixture
async def entry_with_basic_config(hass, flow_at_user_step):
"""Return a entry with a basic config."""
with patch("pysiaalarm.aio.SIAClient.start", return_value=True):
return await hass.config_entries.flow.async_configure(
flow_at_user_step["flow_id"], BASIC_CONFIG
)
@pytest.fixture
async def flow_at_add_account_step(hass, flow_at_user_step):
"""Return a initialized flow at the additional account step."""
return await hass.config_entries.flow.async_configure(
flow_at_user_step["flow_id"], BASIC_CONFIG_ADDITIONAL
)
@pytest.fixture
async def entry_with_additional_account_config(hass, flow_at_add_account_step):
"""Return a entry with a two account config."""
with patch("pysiaalarm.aio.SIAClient.start", return_value=True):
return await hass.config_entries.flow.async_configure(
flow_at_add_account_step["flow_id"], ADDITIONAL_ACCOUNT
)
async def setup_sia(hass, config_entry: MockConfigEntry):
"""Add mock config to HASS."""
assert await async_setup_component(hass, DOMAIN, {})
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
async def test_form_start(
hass, flow_at_user_step, flow_at_add_account_step, additional
):
"""Start the form and check if you get the right id and schema."""
if additional:
assert flow_at_add_account_step["step_id"] == "add_account"
assert flow_at_add_account_step["errors"] is None
assert flow_at_add_account_step["data_schema"] == ACCOUNT_SCHEMA
return
assert flow_at_user_step["step_id"] == "user"
assert flow_at_user_step["errors"] is None
assert flow_at_user_step["data_schema"] == HUB_SCHEMA
async def test_create(hass, entry_with_basic_config):
"""Test we create a entry through the form."""
assert entry_with_basic_config["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
entry_with_basic_config["title"]
== f"SIA Alarm on port {BASIC_CONFIG[CONF_PORT]}"
)
assert entry_with_basic_config["data"] == BASE_OUT["data"]
assert entry_with_basic_config["options"] == BASE_OUT["options"]
async def test_create_additional_account(hass, entry_with_additional_account_config):
"""Test we create a config with two accounts."""
assert (
entry_with_additional_account_config["type"]
== data_entry_flow.RESULT_TYPE_CREATE_ENTRY
)
assert (
entry_with_additional_account_config["title"]
== f"SIA Alarm on port {BASIC_CONFIG[CONF_PORT]}"
)
assert entry_with_additional_account_config["data"] == ADDITIONAL_OUT["data"]
assert entry_with_additional_account_config["options"] == ADDITIONAL_OUT["options"]
async def test_abort_form(hass, entry_with_basic_config):
"""Test aborting a config that already exists."""
assert entry_with_basic_config["data"][CONF_PORT] == BASIC_CONFIG[CONF_PORT]
start_another_flow = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
get_abort = await hass.config_entries.flow.async_configure(
start_another_flow["flow_id"], BASIC_CONFIG
)
assert get_abort["type"] == "abort"
assert get_abort["reason"] == "already_configured"
@pytest.mark.parametrize(
"field, value, error",
[
("encryption_key", "AAAAAAAAAAAAAZZZ", "invalid_key_format"),
("encryption_key", "AAAAAAAAAAAAA", "invalid_key_length"),
("account", "ZZZ", "invalid_account_format"),
("account", "A", "invalid_account_length"),
("ping_interval", 1500, "invalid_ping"),
("zones", 0, "invalid_zones"),
],
)
async def test_validation_errors(
hass,
flow_at_user_step,
additional,
field,
value,
error,
):
"""Test we handle the different invalid inputs, both in the user and add_account flow."""
config = BASIC_CONFIG.copy()
flow_id = flow_at_user_step["flow_id"]
if additional:
flow_at_add_account_step = await hass.config_entries.flow.async_configure(
flow_at_user_step["flow_id"], BASIC_CONFIG_ADDITIONAL
)
config = ADDITIONAL_ACCOUNT.copy()
flow_id = flow_at_add_account_step["flow_id"]
config[field] = value
result_err = await hass.config_entries.flow.async_configure(flow_id, config)
assert result_err["type"] == "form"
assert result_err["errors"] == {"base": error}
async def test_unknown(hass, flow_at_user_step, additional):
"""Test unknown exceptions."""
flow_id = flow_at_user_step["flow_id"]
if additional:
flow_at_add_account_step = await hass.config_entries.flow.async_configure(
flow_at_user_step["flow_id"], BASIC_CONFIG_ADDITIONAL
)
flow_id = flow_at_add_account_step["flow_id"]
with patch(
"pysiaalarm.SIAAccount.validate_account",
side_effect=Exception,
):
config = ADDITIONAL_ACCOUNT if additional else BASIC_CONFIG
result_err = await hass.config_entries.flow.async_configure(flow_id, config)
assert result_err
assert result_err["step_id"] == "add_account" if additional else "user"
assert result_err["errors"] == {"base": "unknown"}
assert result_err["data_schema"] == ACCOUNT_SCHEMA if additional else HUB_SCHEMA
async def test_options_basic(hass):
"""Test options flow for single account."""
await setup_sia(hass, BASIC_CONFIG_ENTRY)
result = await hass.config_entries.options.async_init(BASIC_CONFIG_ENTRY.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "options"
assert result["last_step"]
updated = await hass.config_entries.options.async_configure(
result["flow_id"], BASIC_OPTIONS
)
assert updated["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert updated["data"] == {
CONF_ACCOUNTS: {BASIC_CONFIG[CONF_ACCOUNT]: BASIC_OPTIONS}
}
async def test_options_additional(hass):
"""Test options flow for single account."""
await setup_sia(hass, ADDITIONAL_CONFIG_ENTRY)
result = await hass.config_entries.options.async_init(
ADDITIONAL_CONFIG_ENTRY.entry_id
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "options"
assert not result["last_step"]
updated = await hass.config_entries.options.async_configure(
result["flow_id"], BASIC_OPTIONS
)
assert updated["type"] == data_entry_flow.RESULT_TYPE_FORM
assert updated["step_id"] == "options"
assert updated["last_step"]