diff --git a/.coveragerc b/.coveragerc index c24b190f660..5fc54f1dbea 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66e10b18767..9ead1fd09bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CODEOWNERS b/CODEOWNERS index a4eb7a07987..e7f20d27e18 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py new file mode 100644 index 00000000000..9bca9a5f5b2 --- /dev/null +++ b/homeassistant/components/sia/__init__.py @@ -0,0 +1,34 @@ +"""The sia integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .hub import SIAHub + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up sia from a config entry.""" + hub: SIAHub = SIAHub(hass, entry) + await hub.async_setup_hub() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = hub + try: + await hub.sia_client.start(reuse_port=True) + except OSError as exc: + raise ConfigEntryNotReady( + f"SIA Server at port {entry.data[CONF_PORT]} could not start." + ) from exc + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) + await hub.async_shutdown() + return unload_ok diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py new file mode 100644 index 00000000000..74bb48be940 --- /dev/null +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -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}"), + } diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py new file mode 100644 index 00000000000..fe49ec65777 --- /dev/null +++ b/homeassistant/components/sia/config_flow.py @@ -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 diff --git a/homeassistant/components/sia/const.py b/homeassistant/components/sia/const.py new file mode 100644 index 00000000000..ceeaac75923 --- /dev/null +++ b/homeassistant/components/sia/const.py @@ -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" diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py new file mode 100644 index 00000000000..f5d48a3282c --- /dev/null +++ b/homeassistant/components/sia/hub.py @@ -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) diff --git a/homeassistant/components/sia/manifest.json b/homeassistant/components/sia/manifest.json new file mode 100644 index 00000000000..67c2a0e91a1 --- /dev/null +++ b/homeassistant/components/sia/manifest.json @@ -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" +} diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json new file mode 100644 index 00000000000..b091fdd341d --- /dev/null +++ b/homeassistant/components/sia/strings.json @@ -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." + } + } + } +} diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py new file mode 100644 index 00000000000..9b02025aa8d --- /dev/null +++ b/homeassistant/components/sia/utils.py @@ -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, + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a7d0153d8a1..3a28a24315b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -219,6 +219,7 @@ FLOWS = [ "sharkiq", "shelly", "shopping_list", + "sia", "simplisafe", "sma", "smappee", diff --git a/requirements_all.txt b/requirements_all.txt index 6b72c9f6c6e..156b75bb8a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a06d15465e..ac6bcea193f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/sia/__init__.py b/tests/components/sia/__init__.py new file mode 100644 index 00000000000..198b6bc4bb8 --- /dev/null +++ b/tests/components/sia/__init__.py @@ -0,0 +1 @@ +"""Tests for the sia integration.""" diff --git a/tests/components/sia/test_config_flow.py b/tests/components/sia/test_config_flow.py new file mode 100644 index 00000000000..204518c1e5a --- /dev/null +++ b/tests/components/sia/test_config_flow.py @@ -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"]