hass-core/homeassistant/components/amcrest/binary_sensor.py
Sean Vig d49a223a02
Fix updating Amcrest binary sensors (#80365)
* Fix updating Amcrest binary sensors

As detailed in https://bugs.python.org/issue32113, a generator
expression cannot be used with asynchronous components, even that the
resulting elements of the generator are normal objects.  Manually
iterate over the event codes and check if the events have happened.
Escape early on the first event that is triggered such that this is
functionally equivalent to using `any`.

* Update homeassistant/components/amcrest/binary_sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2022-10-17 10:13:11 +02:00

284 lines
9.4 KiB
Python

"""Support for Amcrest IP camera binary sensors."""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from amcrest import AmcrestError
import voluptuous as vol
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from .const import (
BINARY_SENSOR_SCAN_INTERVAL_SECS,
DATA_AMCREST,
DEVICES,
SERVICE_EVENT,
SERVICE_UPDATE,
)
from .helpers import log_update_error, service_signal
if TYPE_CHECKING:
from . import AmcrestDevice
@dataclass
class AmcrestSensorEntityDescription(BinarySensorEntityDescription):
"""Describe Amcrest sensor entity."""
event_codes: set[str] | None = None
should_poll: bool = False
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
_ONLINE_SCAN_INTERVAL = timedelta(seconds=60 - BINARY_SENSOR_SCAN_INTERVAL_SECS)
_AUDIO_DETECTED_KEY = "audio_detected"
_AUDIO_DETECTED_POLLED_KEY = "audio_detected_polled"
_AUDIO_DETECTED_NAME = "Audio Detected"
_AUDIO_DETECTED_EVENT_CODES = {"AudioMutation", "AudioIntensity"}
_CROSSLINE_DETECTED_KEY = "crossline_detected"
_CROSSLINE_DETECTED_POLLED_KEY = "crossline_detected_polled"
_CROSSLINE_DETECTED_NAME = "CrossLine Detected"
_CROSSLINE_DETECTED_EVENT_CODE = "CrossLineDetection"
_MOTION_DETECTED_KEY = "motion_detected"
_MOTION_DETECTED_POLLED_KEY = "motion_detected_polled"
_MOTION_DETECTED_NAME = "Motion Detected"
_MOTION_DETECTED_EVENT_CODE = "VideoMotion"
_ONLINE_KEY = "online"
BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = (
AmcrestSensorEntityDescription(
key=_AUDIO_DETECTED_KEY,
name=_AUDIO_DETECTED_NAME,
device_class=BinarySensorDeviceClass.SOUND,
event_codes=_AUDIO_DETECTED_EVENT_CODES,
),
AmcrestSensorEntityDescription(
key=_AUDIO_DETECTED_POLLED_KEY,
name=_AUDIO_DETECTED_NAME,
device_class=BinarySensorDeviceClass.SOUND,
event_codes=_AUDIO_DETECTED_EVENT_CODES,
should_poll=True,
),
AmcrestSensorEntityDescription(
key=_CROSSLINE_DETECTED_KEY,
name=_CROSSLINE_DETECTED_NAME,
device_class=BinarySensorDeviceClass.MOTION,
event_codes={_CROSSLINE_DETECTED_EVENT_CODE},
),
AmcrestSensorEntityDescription(
key=_CROSSLINE_DETECTED_POLLED_KEY,
name=_CROSSLINE_DETECTED_NAME,
device_class=BinarySensorDeviceClass.MOTION,
event_codes={_CROSSLINE_DETECTED_EVENT_CODE},
should_poll=True,
),
AmcrestSensorEntityDescription(
key=_MOTION_DETECTED_KEY,
name=_MOTION_DETECTED_NAME,
device_class=BinarySensorDeviceClass.MOTION,
event_codes={_MOTION_DETECTED_EVENT_CODE},
),
AmcrestSensorEntityDescription(
key=_MOTION_DETECTED_POLLED_KEY,
name=_MOTION_DETECTED_NAME,
device_class=BinarySensorDeviceClass.MOTION,
event_codes={_MOTION_DETECTED_EVENT_CODE},
should_poll=True,
),
AmcrestSensorEntityDescription(
key=_ONLINE_KEY,
name="Online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
should_poll=True,
),
)
BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS]
_EXCLUSIVE_OPTIONS = [
{_AUDIO_DETECTED_KEY, _AUDIO_DETECTED_POLLED_KEY},
{_MOTION_DETECTED_KEY, _MOTION_DETECTED_POLLED_KEY},
{_CROSSLINE_DETECTED_KEY, _CROSSLINE_DETECTED_POLLED_KEY},
]
_UPDATE_MSG = "Updating %s binary sensor"
def check_binary_sensors(value: list[str]) -> list[str]:
"""Validate binary sensor configurations."""
for exclusive_options in _EXCLUSIVE_OPTIONS:
if len(set(value) & exclusive_options) > 1:
raise vol.Invalid(
f"must contain at most one of {', '.join(exclusive_options)}."
)
return value
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up a binary sensor for an Amcrest IP Camera."""
if discovery_info is None:
return
name = discovery_info[CONF_NAME]
device = hass.data[DATA_AMCREST][DEVICES][name]
binary_sensors = discovery_info[CONF_BINARY_SENSORS]
async_add_entities(
[
AmcrestBinarySensor(name, device, entity_description)
for entity_description in BINARY_SENSORS
if entity_description.key in binary_sensors
],
True,
)
class AmcrestBinarySensor(BinarySensorEntity):
"""Binary sensor for Amcrest camera."""
def __init__(
self,
name: str,
device: AmcrestDevice,
entity_description: AmcrestSensorEntityDescription,
) -> None:
"""Initialize entity."""
self._signal_name = name
self._api = device.api
self._channel = device.channel
self.entity_description: AmcrestSensorEntityDescription = entity_description
self._attr_name = f"{name} {entity_description.name}"
self._attr_should_poll = entity_description.should_poll
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.entity_description.key == _ONLINE_KEY or self._api.available
async def async_update(self) -> None:
"""Update entity."""
if self.entity_description.key == _ONLINE_KEY:
await self._async_update_online()
else:
await self._async_update_others()
@Throttle(_ONLINE_SCAN_INTERVAL)
async def _async_update_online(self) -> None:
if not (self._api.available or self.is_on):
return
_LOGGER.debug(_UPDATE_MSG, self.name)
if self._api.available:
# Send a command to the camera to test if we can still communicate with it.
# Override of Http.async_command() in __init__.py will set self._api.available
# accordingly.
with suppress(AmcrestError):
await self._api.async_current_time
await self._async_update_unique_id()
self._attr_is_on = self._api.available
async def _async_update_others(self) -> None:
if not self.available:
return
_LOGGER.debug(_UPDATE_MSG, self.name)
try:
await self._async_update_unique_id()
except AmcrestError as error:
log_update_error(_LOGGER, "update", self.name, "binary sensor", error)
return
if not (event_codes := self.entity_description.event_codes):
raise ValueError(f"Binary sensor {self.name} event codes not set")
try:
for event_code in event_codes:
if await self._api.async_event_channels_happened(event_code):
self._attr_is_on = True
break
else:
self._attr_is_on = False
except AmcrestError as error:
log_update_error(_LOGGER, "update", self.name, "binary sensor", error)
return
async def _async_update_unique_id(self) -> None:
"""Set the unique id."""
if self._attr_unique_id is None and (
serial_number := await self._api.async_serial_number
):
self._attr_unique_id = (
f"{serial_number}-{self.entity_description.key}-{self._channel}"
)
@callback
def async_on_demand_update_online(self) -> None:
"""Update state."""
_LOGGER.debug(_UPDATE_MSG, self.name)
self._attr_is_on = self._api.available
self.async_write_ha_state()
@callback
def async_event_received(self, state: bool) -> None:
"""Update state from received event."""
_LOGGER.debug(_UPDATE_MSG, self.name)
self._attr_is_on = state
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to signals."""
if self.entity_description.key == _ONLINE_KEY:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
service_signal(SERVICE_UPDATE, self._signal_name),
self.async_on_demand_update_online,
)
)
else:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
service_signal(SERVICE_UPDATE, self._signal_name),
self.async_write_ha_state,
)
)
if (
event_codes := self.entity_description.event_codes
) and not self.entity_description.should_poll:
for event_code in event_codes:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
service_signal(
SERVICE_EVENT,
self._signal_name,
event_code,
),
self.async_event_received,
)
)