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 <erik@montnemery.com>
pull/51400/head
Eduard van Valkenburg 2021-06-03 08:39:44 +02:00 committed by GitHub
parent 55f158cf78
commit e8762bdea1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 323 additions and 145 deletions

View File

@ -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

View File

@ -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}"),
}

View File

@ -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

View File

@ -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_{}_{}"

View File

@ -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}"),
}