From e8762bdea1b92fe091000c5be68e1056c8a08242 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 3 Jun 2021 08:39:44 +0200 Subject: [PATCH] Add binary sensor platform to SIA integration (#51206) * add support for binary_sensor * added default enabled for binary sensors * fixed coverage and a import deleted * disable pylint for line * Apply suggestions from code review * split binary sensor and used more attr fields Co-authored-by: Erik Montnemery --- .coveragerc | 2 + .../components/sia/alarm_control_panel.py | 165 +++--------------- homeassistant/components/sia/binary_sensor.py | 163 +++++++++++++++++ homeassistant/components/sia/const.py | 7 +- .../components/sia/sia_entity_base.py | 131 ++++++++++++++ 5 files changed, 323 insertions(+), 145 deletions(-) create mode 100644 homeassistant/components/sia/binary_sensor.py create mode 100644 homeassistant/components/sia/sia_entity_base.py diff --git a/.coveragerc b/.coveragerc index f788c917e82..c704566c8d9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -920,9 +920,11 @@ omit = homeassistant/components/slack/notify.py homeassistant/components/sia/__init__.py homeassistant/components/sia/alarm_control_panel.py + homeassistant/components/sia/binary_sensor.py homeassistant/components/sia/const.py homeassistant/components/sia/hub.py homeassistant/components/sia/utils.py + homeassistant/components/sia/sia_entity_base.py homeassistant/components/sinch/* homeassistant/components/slide/* homeassistant/components/sma/__init__.py diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index fe5b95b639e..19ad4f4472e 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -9,7 +9,6 @@ from pysiaalarm import SIAEvent from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_PORT, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_NIGHT, @@ -17,25 +16,12 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_platform import AddEntitiesCallback -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_EVENT, - SIA_NAME_FORMAT, - SIA_UNIQUE_ID_FORMAT_ALARM, -) -from .utils import get_attr_from_sia_event, get_unavailability_interval +from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, SIA_UNIQUE_ID_FORMAT_ALARM +from .sia_entity_base import SIABaseEntity _LOGGER = logging.getLogger(__name__) @@ -86,7 +72,7 @@ async def async_setup_entry( ) -class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): +class SIAAlarmControlPanel(AlarmControlPanelEntity, SIABaseEntity): """Class for SIA Alarm Control Panels.""" def __init__( @@ -96,138 +82,31 @@ class SIAAlarmControlPanel(AlarmControlPanelEntity, RestoreEntity): zone: int, ) -> None: """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._attr: dict[str, Any] = {} - - self._available: bool = True - self._state: StateType = None + super().__init__(entry, account_data, zone, DEVICE_CLASS_ALARM) + self._attr_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. register the dispatcher 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( - async_dispatcher_connect( - self.hass, - SIA_EVENT.format(self._port, self._account), - 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, sia_event: SIAEvent) -> None: - """Listen to dispatcher events for this port and account and update state and attributes. - - If the port and account combo receives any message it means it is online and can therefore be set to available. - """ - _LOGGER.debug("Received event: %s", sia_event) - if int(sia_event.ri) == self._zone: - self._attr.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._attr_unique_id = SIA_UNIQUE_ID_FORMAT_ALARM.format( self._entry.entry_id, self._account, self._zone ) - @property - def available(self) -> bool: - """Get availability.""" - return self._available + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the alarm control panel.""" + new_state = CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + if new_state == PREVIOUS_STATE: + new_state = self._old_state + self._attr_state, self._old_state = new_state, self._attr_state - @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 + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None: + self._attr_state = last_state.state + if self.state == STATE_UNAVAILABLE: + self._attr_available = False @property def supported_features(self) -> int: - """Flag supported features.""" + """Return the list of 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/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py new file mode 100644 index 00000000000..f9cfca778e3 --- /dev/null +++ b/homeassistant/components/sia/binary_sensor.py @@ -0,0 +1,163 @@ +"""Module for SIA Binary Sensors.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + CONF_ZONES, + SIA_HUB_ZONE, + SIA_UNIQUE_ID_FORMAT_BINARY, +) +from .sia_entity_base import SIABaseEntity + +_LOGGER = logging.getLogger(__name__) + + +POWER_CODE_CONSEQUENCES: dict[str, bool] = { + "AT": False, + "AR": True, +} + +SMOKE_CODE_CONSEQUENCES: dict[str, bool] = { + "GA": True, + "GH": False, + "FA": True, + "FH": False, + "KA": True, + "KH": False, +} + +MOISTURE_CODE_CONSEQUENCES: dict[str, bool] = { + "WA": True, + "WH": False, +} + + +def generate_binary_sensors(entry) -> Iterable[SIABinarySensorBase]: + """Generate binary sensors. + + For each Account there is one power sensor with zone == 0. + For each Zone in each Account there is one smoke and one moisture sensor. + """ + for account in entry.data[CONF_ACCOUNTS]: + yield SIABinarySensorPower(entry, account) + zones = entry.options[CONF_ACCOUNTS][account[CONF_ACCOUNT]][CONF_ZONES] + for zone in range(1, zones + 1): + yield SIABinarySensorSmoke(entry, account, zone) + yield SIABinarySensorMoisture(entry, account, zone) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SIA binary sensors from a config entry.""" + async_add_entities(generate_binary_sensors(entry)) + + +class SIABinarySensorBase(BinarySensorEntity, SIABaseEntity): + """Class for SIA Binary Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + device_class: str, + ) -> None: + """Initialize a base binary sensor.""" + super().__init__(entry, account_data, zone, device_class) + + self._attr_unique_id = SIA_UNIQUE_ID_FORMAT_BINARY.format( + self._entry.entry_id, self._account, self._zone, self._attr_device_class + ) + + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + if last_state is not None and last_state.state is not None: + if last_state.state == STATE_ON: + self._attr_is_on = True + elif last_state.state == STATE_OFF: + self._attr_is_on = False + elif last_state.state == STATE_UNAVAILABLE: + self._attr_available = False + + +class SIABinarySensorMoisture(SIABinarySensorBase): + """Class for Moisture Binary Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Moisture binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_MOISTURE) + self._attr_entity_registry_enabled_default = False + + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = MOISTURE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state + + +class SIABinarySensorSmoke(SIABinarySensorBase): + """Class for Smoke Binary Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + ) -> None: + """Initialize a Smoke binary sensor.""" + super().__init__(entry, account_data, zone, DEVICE_CLASS_SMOKE) + self._attr_entity_registry_enabled_default = False + + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = SMOKE_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state + + +class SIABinarySensorPower(SIABinarySensorBase): + """Class for Power Binary Sensors.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + ) -> None: + """Initialize a Power binary sensor.""" + super().__init__(entry, account_data, SIA_HUB_ZONE, DEVICE_CLASS_POWER) + self._attr_entity_registry_enabled_default = True + + def update_state(self, sia_event: SIAEvent) -> None: + """Update the state of the binary sensor.""" + new_state = POWER_CODE_CONSEQUENCES.get(sia_event.code, None) + if new_state is not None: + _LOGGER.debug("New state will be %s", new_state) + self._attr_is_on = new_state diff --git a/homeassistant/components/sia/const.py b/homeassistant/components/sia/const.py index 916cdb9621c..711c070b1ee 100644 --- a/homeassistant/components/sia/const.py +++ b/homeassistant/components/sia/const.py @@ -2,13 +2,14 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN] +PLATFORMS = [ALARM_CONTROL_PANEL_DOMAIN, BINARY_SENSOR_DOMAIN] DOMAIN = "sia" ATTR_CODE = "last_code" -ATTR_ZONE = "zone" +ATTR_ZONE = "last_zone" ATTR_MESSAGE = "last_message" ATTR_ID = "last_id" ATTR_TIMESTAMP = "last_timestamp" @@ -24,5 +25,7 @@ CONF_ZONES = "zones" SIA_NAME_FORMAT = "{} - {} - zone {} - {}" SIA_UNIQUE_ID_FORMAT_ALARM = "{}_{}_{}" +SIA_UNIQUE_ID_FORMAT_BINARY = "{}_{}_{}_{}" +SIA_HUB_ZONE = 0 SIA_EVENT = "sia_event_{}_{}" diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py new file mode 100644 index 00000000000..9d81b749afe --- /dev/null +++ b/homeassistant/components/sia/sia_entity_base.py @@ -0,0 +1,131 @@ +"""Module for SIA Base Entity.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +from typing import Any + +from pysiaalarm import SIAEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import CALLBACK_TYPE, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_ACCOUNT, CONF_PING_INTERVAL, DOMAIN, SIA_EVENT, SIA_NAME_FORMAT +from .utils import get_attr_from_sia_event, get_unavailability_interval + +_LOGGER = logging.getLogger(__name__) + + +class SIABaseEntity(RestoreEntity): + """Base class for SIA entities.""" + + def __init__( + self, + entry: ConfigEntry, + account_data: dict[str, Any], + zone: int, + device_class: str, + ) -> None: + """Create SIABaseEntity object.""" + self._entry: ConfigEntry = entry + self._account_data: dict[str, Any] = account_data + self._zone: int = zone + self._attr_device_class: str = device_class + + self._port: int = self._entry.data[CONF_PORT] + self._account: str = self._account_data[CONF_ACCOUNT] + self._ping_interval: int = self._account_data[CONF_PING_INTERVAL] + + self._cancel_availability_cb: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes: dict[str, Any] = {} + self._attr_should_poll = False + self._attr_name = SIA_NAME_FORMAT.format( + self._port, self._account, self._zone, self._attr_device_class + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass. + + Overridden from Entity. + + 1. register the dispatcher and add the callback to on_remove + 2. get previous state from storage and pass to entity specific function + 3. if available: create availability cb + """ + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIA_EVENT.format(self._port, self._account), + self.async_handle_event, + ) + ) + self.handle_last_state(await self.async_get_last_state()) + if self._attr_available: + self.async_create_availability_cb() + + @abstractmethod + def handle_last_state(self, last_state: State | None) -> None: + """Handle the last state.""" + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + Overridden from Entity. + """ + if self._cancel_availability_cb: + self._cancel_availability_cb() + + async def async_handle_event(self, sia_event: SIAEvent) -> None: + """Listen to dispatcher events for this port and account and update state and attributes. + + If the port and account combo receives any message it means it is online and can therefore be set to available. + """ + _LOGGER.debug("Received event: %s", sia_event) + if int(sia_event.ri) == self._zone: + self._attr_extra_state_attributes.update(get_attr_from_sia_event(sia_event)) + self.update_state(sia_event) + self.async_reset_availability_cb() + self.async_write_ha_state() + + @abstractmethod + def update_state(self, sia_event: SIAEvent) -> None: + """Do the entity specific state updates.""" + + @callback + def async_reset_availability_cb(self) -> None: + """Reset availability cb by cancelling the current and creating a new one.""" + self._attr_available = True + if self._cancel_availability_cb: + self._cancel_availability_cb() + self.async_create_availability_cb() + + def async_create_availability_cb(self) -> None: + """Create a availability cb and return the callback.""" + self._cancel_availability_cb = async_call_later( + self.hass, + get_unavailability_interval(self._ping_interval), + self.async_set_unavailable, + ) + + @callback + def async_set_unavailable(self, _) -> None: + """Set unavailable.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info.""" + assert self._attr_name is not None + assert self.unique_id is not None + return { + "name": self._attr_name, + "identifiers": {(DOMAIN, self.unique_id)}, + "via_device": (DOMAIN, f"{self._port}_{self._account}"), + }