mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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:
committed by
GitHub
parent
d7da32cbb9
commit
0bba0f07ac
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
34
homeassistant/components/sia/__init__.py
Normal file
34
homeassistant/components/sia/__init__.py
Normal 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
|
253
homeassistant/components/sia/alarm_control_panel.py
Normal file
253
homeassistant/components/sia/alarm_control_panel.py
Normal 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}"),
|
||||
}
|
232
homeassistant/components/sia/config_flow.py
Normal file
232
homeassistant/components/sia/config_flow.py
Normal 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
|
38
homeassistant/components/sia/const.py
Normal file
38
homeassistant/components/sia/const.py
Normal 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"
|
138
homeassistant/components/sia/hub.py
Normal file
138
homeassistant/components/sia/hub.py
Normal 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)
|
9
homeassistant/components/sia/manifest.json
Normal file
9
homeassistant/components/sia/manifest.json
Normal 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"
|
||||
}
|
50
homeassistant/components/sia/strings.json
Normal file
50
homeassistant/components/sia/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
57
homeassistant/components/sia/utils.py
Normal file
57
homeassistant/components/sia/utils.py
Normal 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,
|
||||
}
|
@ -219,6 +219,7 @@ FLOWS = [
|
||||
"sharkiq",
|
||||
"shelly",
|
||||
"shopping_list",
|
||||
"sia",
|
||||
"simplisafe",
|
||||
"sma",
|
||||
"smappee",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/sia/__init__.py
Normal file
1
tests/components/sia/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the sia integration."""
|
314
tests/components/sia/test_config_flow.py
Normal file
314
tests/components/sia/test_config_flow.py
Normal 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"]
|
Reference in New Issue
Block a user