* 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>
284 lines
9.4 KiB
Python
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,
|
|
)
|
|
)
|