Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """Support for Amcrest IP camera binary sensors."""
2 
3 from __future__ import annotations
4 
5 from contextlib import suppress
6 from dataclasses import dataclass
7 from datetime import timedelta
8 import logging
9 from typing import TYPE_CHECKING
10 
11 from amcrest import AmcrestError
12 import voluptuous as vol
13 
15  BinarySensorDeviceClass,
16  BinarySensorEntity,
17  BinarySensorEntityDescription,
18 )
19 from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME
20 from homeassistant.core import HomeAssistant, callback
21 from homeassistant.helpers.dispatcher import async_dispatcher_connect
22 from homeassistant.helpers.entity_platform import AddEntitiesCallback
23 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
24 from homeassistant.util import Throttle
25 
26 from .const import (
27  BINARY_SENSOR_SCAN_INTERVAL_SECS,
28  DATA_AMCREST,
29  DEVICES,
30  SERVICE_EVENT,
31  SERVICE_UPDATE,
32 )
33 from .helpers import log_update_error, service_signal
34 
35 if TYPE_CHECKING:
36  from . import AmcrestDevice
37 
38 
39 @dataclass(frozen=True)
41  """Describe Amcrest sensor entity."""
42 
43  event_codes: set[str] | None = None
44  should_poll: bool = False
45 
46 
47 _LOGGER = logging.getLogger(__name__)
48 
49 SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
50 _ONLINE_SCAN_INTERVAL = timedelta(seconds=60 - BINARY_SENSOR_SCAN_INTERVAL_SECS)
51 
52 _AUDIO_DETECTED_KEY = "audio_detected"
53 _AUDIO_DETECTED_POLLED_KEY = "audio_detected_polled"
54 _AUDIO_DETECTED_NAME = "Audio Detected"
55 _AUDIO_DETECTED_EVENT_CODES = {"AudioMutation", "AudioIntensity"}
56 
57 _CROSSLINE_DETECTED_KEY = "crossline_detected"
58 _CROSSLINE_DETECTED_POLLED_KEY = "crossline_detected_polled"
59 _CROSSLINE_DETECTED_NAME = "CrossLine Detected"
60 _CROSSLINE_DETECTED_EVENT_CODE = "CrossLineDetection"
61 
62 _MOTION_DETECTED_KEY = "motion_detected"
63 _MOTION_DETECTED_POLLED_KEY = "motion_detected_polled"
64 _MOTION_DETECTED_NAME = "Motion Detected"
65 _MOTION_DETECTED_EVENT_CODE = "VideoMotion"
66 
67 _ONLINE_KEY = "online"
68 
69 BINARY_SENSORS: tuple[AmcrestSensorEntityDescription, ...] = (
71  key=_AUDIO_DETECTED_KEY,
72  name=_AUDIO_DETECTED_NAME,
73  device_class=BinarySensorDeviceClass.SOUND,
74  event_codes=_AUDIO_DETECTED_EVENT_CODES,
75  ),
77  key=_AUDIO_DETECTED_POLLED_KEY,
78  name=_AUDIO_DETECTED_NAME,
79  device_class=BinarySensorDeviceClass.SOUND,
80  event_codes=_AUDIO_DETECTED_EVENT_CODES,
81  should_poll=True,
82  ),
84  key=_CROSSLINE_DETECTED_KEY,
85  name=_CROSSLINE_DETECTED_NAME,
86  device_class=BinarySensorDeviceClass.MOTION,
87  event_codes={_CROSSLINE_DETECTED_EVENT_CODE},
88  ),
90  key=_CROSSLINE_DETECTED_POLLED_KEY,
91  name=_CROSSLINE_DETECTED_NAME,
92  device_class=BinarySensorDeviceClass.MOTION,
93  event_codes={_CROSSLINE_DETECTED_EVENT_CODE},
94  should_poll=True,
95  ),
97  key=_MOTION_DETECTED_KEY,
98  name=_MOTION_DETECTED_NAME,
99  device_class=BinarySensorDeviceClass.MOTION,
100  event_codes={_MOTION_DETECTED_EVENT_CODE},
101  ),
103  key=_MOTION_DETECTED_POLLED_KEY,
104  name=_MOTION_DETECTED_NAME,
105  device_class=BinarySensorDeviceClass.MOTION,
106  event_codes={_MOTION_DETECTED_EVENT_CODE},
107  should_poll=True,
108  ),
110  key=_ONLINE_KEY,
111  name="Online",
112  device_class=BinarySensorDeviceClass.CONNECTIVITY,
113  should_poll=True,
114  ),
115 )
116 BINARY_SENSOR_KEYS = [description.key for description in BINARY_SENSORS]
117 _EXCLUSIVE_OPTIONS = [
118  {_AUDIO_DETECTED_KEY, _AUDIO_DETECTED_POLLED_KEY},
119  {_MOTION_DETECTED_KEY, _MOTION_DETECTED_POLLED_KEY},
120  {_CROSSLINE_DETECTED_KEY, _CROSSLINE_DETECTED_POLLED_KEY},
121 ]
122 
123 _UPDATE_MSG = "Updating %s binary sensor"
124 
125 
126 def check_binary_sensors(value: list[str]) -> list[str]:
127  """Validate binary sensor configurations."""
128  for exclusive_options in _EXCLUSIVE_OPTIONS:
129  if len(set(value) & exclusive_options) > 1:
130  raise vol.Invalid(
131  f"must contain at most one of {', '.join(exclusive_options)}."
132  )
133  return value
134 
135 
137  hass: HomeAssistant,
138  config: ConfigType,
139  async_add_entities: AddEntitiesCallback,
140  discovery_info: DiscoveryInfoType | None = None,
141 ) -> None:
142  """Set up a binary sensor for an Amcrest IP Camera."""
143  if discovery_info is None:
144  return
145 
146  name = discovery_info[CONF_NAME]
147  device = hass.data[DATA_AMCREST][DEVICES][name]
148  binary_sensors = discovery_info[CONF_BINARY_SENSORS]
150  [
151  AmcrestBinarySensor(name, device, entity_description)
152  for entity_description in BINARY_SENSORS
153  if entity_description.key in binary_sensors
154  ],
155  True,
156  )
157 
158 
160  """Binary sensor for Amcrest camera."""
161 
162  def __init__(
163  self,
164  name: str,
165  device: AmcrestDevice,
166  entity_description: AmcrestSensorEntityDescription,
167  ) -> None:
168  """Initialize entity."""
169  self._signal_name_signal_name = name
170  self._api_api = device.api
171  self._channel_channel = device.channel
172  self.entity_description: AmcrestSensorEntityDescription = entity_description
173 
174  self._attr_name_attr_name = f"{name} {entity_description.name}"
175  self._attr_should_poll_attr_should_poll = entity_description.should_poll
176 
177  @property
178  def available(self) -> bool:
179  """Return True if entity is available."""
180  return self.entity_description.key == _ONLINE_KEY or self._api_api.available
181 
182  async def async_update(self) -> None:
183  """Update entity."""
184  if self.entity_description.key == _ONLINE_KEY:
185  await self._async_update_online_async_update_online()
186  else:
187  await self._async_update_others_async_update_others()
188 
189  @Throttle(_ONLINE_SCAN_INTERVAL)
190  async def _async_update_online(self) -> None:
191  if not (self._api_api.available or self.is_on):
192  return
193  _LOGGER.debug(_UPDATE_MSG, self.namename)
194 
195  if self._api_api.available:
196  # Send a command to the camera to test if we can still communicate with it.
197  # Override of Http.async_command() in __init__.py will set self._api.available
198  # accordingly.
199  with suppress(AmcrestError):
200  await self._api_api.async_current_time
201  await self._async_update_unique_id_async_update_unique_id()
202  self._attr_is_on_attr_is_on = self._api_api.available
203 
204  async def _async_update_others(self) -> None:
205  if not self.availableavailableavailable:
206  return
207  _LOGGER.debug(_UPDATE_MSG, self.namename)
208 
209  try:
210  await self._async_update_unique_id_async_update_unique_id()
211  except AmcrestError as error:
212  log_update_error(_LOGGER, "update", self.namename, "binary sensor", error)
213  return
214 
215  if not (event_codes := self.entity_description.event_codes):
216  raise ValueError(f"Binary sensor {self.name} event codes not set")
217 
218  try:
219  for event_code in event_codes:
220  if await self._api_api.async_event_channels_happened(event_code):
221  self._attr_is_on_attr_is_on = True
222  break
223  else:
224  self._attr_is_on_attr_is_on = False
225  except AmcrestError as error:
226  log_update_error(_LOGGER, "update", self.namename, "binary sensor", error)
227  return
228 
229  async def _async_update_unique_id(self) -> None:
230  """Set the unique id."""
231  if self._attr_unique_id_attr_unique_id is None and (
232  serial_number := await self._api_api.async_serial_number
233  ):
234  self._attr_unique_id_attr_unique_id = (
235  f"{serial_number}-{self.entity_description.key}-{self._channel}"
236  )
237 
238  @callback
239  def async_on_demand_update_online(self) -> None:
240  """Update state."""
241  _LOGGER.debug(_UPDATE_MSG, self.namename)
242  self._attr_is_on_attr_is_on = self._api_api.available
243  self.async_write_ha_stateasync_write_ha_state()
244 
245  @callback
246  def async_event_received(self, state: bool) -> None:
247  """Update state from received event."""
248  _LOGGER.debug(_UPDATE_MSG, self.namename)
249  self._attr_is_on_attr_is_on = state
250  self.async_write_ha_stateasync_write_ha_state()
251 
252  async def async_added_to_hass(self) -> None:
253  """Subscribe to signals."""
254  if self.entity_description.key == _ONLINE_KEY:
255  self.async_on_removeasync_on_remove(
257  self.hasshass,
258  service_signal(SERVICE_UPDATE, self._signal_name_signal_name),
259  self.async_on_demand_update_onlineasync_on_demand_update_online,
260  )
261  )
262  else:
263  self.async_on_removeasync_on_remove(
265  self.hasshass,
266  service_signal(SERVICE_UPDATE, self._signal_name_signal_name),
267  self.async_write_ha_stateasync_write_ha_state,
268  )
269  )
270 
271  if (
272  event_codes := self.entity_description.event_codes
273  ) and not self.entity_description.should_poll:
274  for event_code in event_codes:
275  self.async_on_removeasync_on_remove(
277  self.hasshass,
279  SERVICE_EVENT,
280  self._signal_name_signal_name,
281  event_code,
282  ),
283  self.async_event_receivedasync_event_received,
284  )
285  )
None __init__(self, str name, AmcrestDevice device, AmcrestSensorEntityDescription entity_description)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
str|UndefinedType|None name(self)
Definition: entity.py:738
list[str] check_binary_sensors(list[str] value)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
None log_update_error(logging.Logger logger, str action, str|UndefinedType|None name, str entity_type, Exception error, int level=logging.ERROR)
Definition: helpers.py:24
str service_signal(str service, *str args)
Definition: helpers.py:12
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103