Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The motionEye integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import contextlib
7 from http import HTTPStatus
8 import json
9 import logging
10 import os
11 from typing import Any
12 from urllib.parse import urlencode, urljoin
13 
14 from aiohttp.web import Request, Response
15 from motioneye_client.client import (
16  MotionEyeClient,
17  MotionEyeClientError,
18  MotionEyeClientInvalidAuthError,
19  MotionEyeClientPathError,
20 )
21 from motioneye_client.const import (
22  KEY_CAMERAS,
23  KEY_HTTP_METHOD_POST_JSON,
24  KEY_ID,
25  KEY_NAME,
26  KEY_ROOT_DIRECTORY,
27  KEY_WEB_HOOK_CONVERSION_SPECIFIERS,
28  KEY_WEB_HOOK_CS_FILE_PATH,
29  KEY_WEB_HOOK_CS_FILE_TYPE,
30  KEY_WEB_HOOK_NOTIFICATIONS_ENABLED,
31  KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD,
32  KEY_WEB_HOOK_NOTIFICATIONS_URL,
33  KEY_WEB_HOOK_STORAGE_ENABLED,
34  KEY_WEB_HOOK_STORAGE_HTTP_METHOD,
35  KEY_WEB_HOOK_STORAGE_URL,
36 )
37 
38 from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
39 from homeassistant.components.media_source import URI_SCHEME
40 from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
41 from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
43  async_generate_id,
44  async_generate_path,
45  async_register as webhook_register,
46  async_unregister as webhook_unregister,
47 )
48 from homeassistant.config_entries import ConfigEntry
49 from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID
50 from homeassistant.core import HomeAssistant, callback
51 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
52 from homeassistant.helpers import device_registry as dr
53 from homeassistant.helpers.aiohttp_client import async_get_clientsession
55  async_dispatcher_connect,
56  async_dispatcher_send,
57 )
58 from homeassistant.helpers.network import NoURLAvailableError, get_url
59 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
60 
61 from .const import (
62  ATTR_EVENT_TYPE,
63  ATTR_WEBHOOK_ID,
64  CONF_ADMIN_PASSWORD,
65  CONF_ADMIN_USERNAME,
66  CONF_CLIENT,
67  CONF_COORDINATOR,
68  CONF_SURVEILLANCE_PASSWORD,
69  CONF_SURVEILLANCE_USERNAME,
70  CONF_WEBHOOK_SET,
71  CONF_WEBHOOK_SET_OVERWRITE,
72  DEFAULT_SCAN_INTERVAL,
73  DEFAULT_WEBHOOK_SET,
74  DEFAULT_WEBHOOK_SET_OVERWRITE,
75  DOMAIN,
76  EVENT_FILE_STORED,
77  EVENT_FILE_STORED_KEYS,
78  EVENT_FILE_URL,
79  EVENT_MEDIA_CONTENT_ID,
80  EVENT_MOTION_DETECTED,
81  EVENT_MOTION_DETECTED_KEYS,
82  MOTIONEYE_MANUFACTURER,
83  SIGNAL_CAMERA_ADD,
84  WEB_HOOK_SENTINEL_KEY,
85  WEB_HOOK_SENTINEL_VALUE,
86 )
87 
88 _LOGGER = logging.getLogger(__name__)
89 PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
90 
91 
93  *args: Any,
94  **kwargs: Any,
95 ) -> MotionEyeClient:
96  """Create a MotionEyeClient."""
97  return MotionEyeClient(*args, **kwargs)
98 
99 
101  config_entry_id: str, camera_id: int
102 ) -> tuple[str, str]:
103  """Get the identifiers for a motionEye device."""
104  return (DOMAIN, f"{config_entry_id}_{camera_id}")
105 
106 
108  identifier: tuple[str, str],
109 ) -> tuple[str, str, int] | None:
110  """Get the identifiers for a motionEye device."""
111  if len(identifier) != 2 or identifier[0] != DOMAIN or "_" not in identifier[1]:
112  return None
113  config_id, camera_id_str = identifier[1].split("_", 1)
114  try:
115  camera_id = int(camera_id_str)
116  except ValueError:
117  return None
118  return (DOMAIN, config_id, camera_id)
119 
120 
122  camera_id: int, data: dict[str, Any] | None
123 ) -> dict[str, Any] | None:
124  """Get an individual camera dict from a multiple cameras data response."""
125  for camera in data.get(KEY_CAMERAS, []) if data else []:
126  if camera.get(KEY_ID) == camera_id:
127  val: dict[str, Any] = camera
128  return val
129  return None
130 
131 
132 def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
133  """Determine if a camera dict is acceptable."""
134  return bool(camera and KEY_ID in camera and KEY_NAME in camera)
135 
136 
137 @callback
139  hass: HomeAssistant,
140  entry: ConfigEntry,
141  add_func: Callable,
142 ) -> None:
143  """Listen for new cameras."""
144 
145  entry.async_on_unload(
147  hass,
148  SIGNAL_CAMERA_ADD.format(entry.entry_id),
149  add_func,
150  )
151  )
152 
153 
154 @callback
156  hass: HomeAssistant, webhook_id: str
157 ) -> str | None:
158  """Generate the full local URL for a webhook_id."""
159  try:
160  return f"{get_url(hass, allow_cloud=False)}{async_generate_path(webhook_id)}"
161  except NoURLAvailableError:
162  _LOGGER.warning(
163  "Unable to get Home Assistant URL. Have you set the internal and/or "
164  "external URLs in Settings -> System -> Network?"
165  )
166  return None
167 
168 
169 @callback
171  hass: HomeAssistant,
172  device_registry: dr.DeviceRegistry,
173  client: MotionEyeClient,
174  entry: ConfigEntry,
175  camera_id: int,
176  camera: dict[str, Any],
177  device_identifier: tuple[str, str],
178 ) -> None:
179  """Add a motionEye camera to hass."""
180 
181  def _is_recognized_web_hook(url: str) -> bool:
182  """Determine whether this integration set a web hook."""
183  return f"{WEB_HOOK_SENTINEL_KEY}={WEB_HOOK_SENTINEL_VALUE}" in url
184 
185  def _set_webhook(
186  url: str,
187  key_url: str,
188  key_method: str,
189  key_enabled: str,
190  camera: dict[str, Any],
191  ) -> bool:
192  """Set a web hook."""
193  if (
194  entry.options.get(
195  CONF_WEBHOOK_SET_OVERWRITE,
196  DEFAULT_WEBHOOK_SET_OVERWRITE,
197  )
198  or not camera.get(key_url)
199  or _is_recognized_web_hook(camera[key_url])
200  ) and (
201  not camera.get(key_enabled, False)
202  or camera.get(key_method) != KEY_HTTP_METHOD_POST_JSON
203  or camera.get(key_url) != url
204  ):
205  camera[key_enabled] = True
206  camera[key_method] = KEY_HTTP_METHOD_POST_JSON
207  camera[key_url] = url
208  return True
209  return False
210 
211  def _build_url(
212  device: dr.DeviceEntry, base: str, event_type: str, keys: list[str]
213  ) -> str:
214  """Build a motionEye webhook URL."""
215 
216  # This URL-surgery cannot use YARL because the output must NOT be
217  # url-encoded. This is because motionEye will do further string
218  # manipulation/substitution on this value before ultimately fetching it,
219  # and it cannot deal with URL-encoded input to that string manipulation.
220  return urljoin(
221  base,
222  "?"
223  + urlencode(
224  {
225  **{k: KEY_WEB_HOOK_CONVERSION_SPECIFIERS[k] for k in sorted(keys)},
226  WEB_HOOK_SENTINEL_KEY: WEB_HOOK_SENTINEL_VALUE,
227  ATTR_EVENT_TYPE: event_type,
228  ATTR_DEVICE_ID: device.id,
229  },
230  safe="%{}",
231  ),
232  )
233 
234  device = device_registry.async_get_or_create(
235  config_entry_id=entry.entry_id,
236  identifiers={device_identifier},
237  manufacturer=MOTIONEYE_MANUFACTURER,
238  model=MOTIONEYE_MANUFACTURER,
239  name=camera[KEY_NAME],
240  )
241  if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET):
242  url = async_generate_motioneye_webhook(hass, entry.data[CONF_WEBHOOK_ID])
243 
244  if url:
245  set_motion_event = _set_webhook(
246  _build_url(
247  device,
248  url,
249  EVENT_MOTION_DETECTED,
250  EVENT_MOTION_DETECTED_KEYS,
251  ),
252  KEY_WEB_HOOK_NOTIFICATIONS_URL,
253  KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD,
254  KEY_WEB_HOOK_NOTIFICATIONS_ENABLED,
255  camera,
256  )
257 
258  set_storage_event = _set_webhook(
259  _build_url(
260  device,
261  url,
262  EVENT_FILE_STORED,
263  EVENT_FILE_STORED_KEYS,
264  ),
265  KEY_WEB_HOOK_STORAGE_URL,
266  KEY_WEB_HOOK_STORAGE_HTTP_METHOD,
267  KEY_WEB_HOOK_STORAGE_ENABLED,
268  camera,
269  )
270  if set_motion_event or set_storage_event:
271  hass.async_create_task(client.async_set_camera(camera_id, camera))
272 
274  hass,
275  SIGNAL_CAMERA_ADD.format(entry.entry_id),
276  camera,
277  )
278 
279 
280 async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
281  """Handle entry updates."""
282  await hass.config_entries.async_reload(config_entry.entry_id)
283 
284 
285 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
286  """Set up motionEye from a config entry."""
287  hass.data.setdefault(DOMAIN, {})
288 
289  client = create_motioneye_client(
290  entry.data[CONF_URL],
291  admin_username=entry.data.get(CONF_ADMIN_USERNAME),
292  admin_password=entry.data.get(CONF_ADMIN_PASSWORD),
293  surveillance_username=entry.data.get(CONF_SURVEILLANCE_USERNAME),
294  surveillance_password=entry.data.get(CONF_SURVEILLANCE_PASSWORD),
295  session=async_get_clientsession(hass),
296  )
297 
298  try:
299  await client.async_client_login()
300  except MotionEyeClientInvalidAuthError as exc:
301  await client.async_client_close()
302  raise ConfigEntryAuthFailed from exc
303  except MotionEyeClientError as exc:
304  await client.async_client_close()
305  raise ConfigEntryNotReady from exc
306 
307  # Ensure every loaded entry has a registered webhook id.
308  if CONF_WEBHOOK_ID not in entry.data:
309  hass.config_entries.async_update_entry(
310  entry, data={**entry.data, CONF_WEBHOOK_ID: async_generate_id()}
311  )
312  webhook_register(
313  hass, DOMAIN, "motionEye", entry.data[CONF_WEBHOOK_ID], handle_webhook
314  )
315 
316  async def async_update_data() -> dict[str, Any] | None:
317  try:
318  return await client.async_get_cameras()
319  except MotionEyeClientError as exc:
320  raise UpdateFailed("Error communicating with API") from exc
321 
322  coordinator = DataUpdateCoordinator(
323  hass,
324  _LOGGER,
325  config_entry=entry,
326  name=DOMAIN,
327  update_method=async_update_data,
328  update_interval=DEFAULT_SCAN_INTERVAL,
329  )
330  hass.data[DOMAIN][entry.entry_id] = {
331  CONF_CLIENT: client,
332  CONF_COORDINATOR: coordinator,
333  }
334 
335  current_cameras: set[tuple[str, str]] = set()
336  device_registry = dr.async_get(hass)
337 
338  @callback
339  def _async_process_motioneye_cameras() -> None:
340  """Process motionEye camera additions and removals."""
341  inbound_camera: set[tuple[str, str]] = set()
342  if coordinator.data is None or KEY_CAMERAS not in coordinator.data:
343  return
344 
345  for camera in coordinator.data[KEY_CAMERAS]:
346  if not is_acceptable_camera(camera):
347  return
348  camera_id = camera[KEY_ID]
349  device_identifier = get_motioneye_device_identifier(
350  entry.entry_id, camera_id
351  )
352  inbound_camera.add(device_identifier)
353 
354  if device_identifier in current_cameras:
355  continue
356  current_cameras.add(device_identifier)
357  _add_camera(
358  hass,
359  device_registry,
360  client,
361  entry,
362  camera_id,
363  camera,
364  device_identifier,
365  )
366 
367  # Ensure every device associated with this config entry is still in the
368  # list of motionEye cameras, otherwise remove the device (and thus
369  # entities).
370  for device_entry in dr.async_entries_for_config_entry(
371  device_registry, entry.entry_id
372  ):
373  for identifier in device_entry.identifiers:
374  if identifier in inbound_camera:
375  break
376  else:
377  device_registry.async_remove_device(device_entry.id)
378 
379  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
380 
381  entry.async_on_unload(
382  coordinator.async_add_listener(_async_process_motioneye_cameras)
383  )
384  await coordinator.async_refresh()
385  entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
386 
387  return True
388 
389 
390 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
391  """Unload a config entry."""
392  webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
393 
394  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
395  if unload_ok:
396  config_data = hass.data[DOMAIN].pop(entry.entry_id)
397  await config_data[CONF_CLIENT].async_client_close()
398 
399  return unload_ok
400 
401 
402 async def handle_webhook(
403  hass: HomeAssistant, webhook_id: str, request: Request
404 ) -> Response | None:
405  """Handle webhook callback."""
406 
407  try:
408  data = await request.json()
409  except (json.decoder.JSONDecodeError, UnicodeDecodeError):
410  return Response(
411  text="Could not decode request",
412  status=HTTPStatus.BAD_REQUEST,
413  )
414 
415  for key in (ATTR_DEVICE_ID, ATTR_EVENT_TYPE):
416  if key not in data:
417  return Response(
418  text=f"Missing webhook parameter: {key}",
419  status=HTTPStatus.BAD_REQUEST,
420  )
421 
422  event_type = data[ATTR_EVENT_TYPE]
423  device_registry = dr.async_get(hass)
424  device_id = data[ATTR_DEVICE_ID]
425 
426  if not (device := device_registry.async_get(device_id)):
427  return Response(
428  text=f"Device not found: {device_id}",
429  status=HTTPStatus.BAD_REQUEST,
430  )
431 
432  if KEY_WEB_HOOK_CS_FILE_PATH in data and KEY_WEB_HOOK_CS_FILE_TYPE in data:
433  try:
434  event_file_type = int(data[KEY_WEB_HOOK_CS_FILE_TYPE])
435  except ValueError:
436  pass
437  else:
438  data.update(
440  hass,
441  device,
442  data[KEY_WEB_HOOK_CS_FILE_PATH],
443  event_file_type,
444  )
445  )
446 
447  hass.bus.async_fire(
448  f"{DOMAIN}.{event_type}",
449  {
450  ATTR_DEVICE_ID: device.id,
451  ATTR_NAME: device.name,
452  ATTR_WEBHOOK_ID: webhook_id,
453  **data,
454  },
455  )
456  return None
457 
458 
460  hass: HomeAssistant,
461  device: dr.DeviceEntry,
462  event_file_path: str,
463  event_file_type: int,
464 ) -> dict[str, str]:
465  config_entry_id = next(iter(device.config_entries), None)
466  if not config_entry_id or config_entry_id not in hass.data[DOMAIN]:
467  return {}
468 
469  config_entry_data = hass.data[DOMAIN][config_entry_id]
470  client = config_entry_data[CONF_CLIENT]
471  coordinator = config_entry_data[CONF_COORDINATOR]
472 
473  for identifier in device.identifiers:
474  data = split_motioneye_device_identifier(identifier)
475  if data is not None:
476  camera_id = data[2]
477  camera = get_camera_from_cameras(camera_id, coordinator.data)
478  break
479  else:
480  return {}
481 
482  root_directory = camera.get(KEY_ROOT_DIRECTORY) if camera else None
483  if root_directory is None:
484  return {}
485 
486  kind = "images" if client.is_file_type_image(event_file_type) else "movies"
487 
488  # The file_path in the event is the full local filesystem path to the
489  # media. To convert that to the media path that motionEye will
490  # understand, we need to strip the root directory from the path.
491  if os.path.commonprefix([root_directory, event_file_path]) != root_directory:
492  return {}
493 
494  file_path = "/" + os.path.relpath(event_file_path, root_directory)
495  output = {
496  EVENT_MEDIA_CONTENT_ID: (
497  f"{URI_SCHEME}{DOMAIN}/{config_entry_id}#{device.id}#{kind}#{file_path}"
498  ),
499  }
500  url = get_media_url(
501  client,
502  camera_id,
503  file_path,
504  kind == "images",
505  )
506  if url:
507  output[EVENT_FILE_URL] = url
508  return output
509 
510 
512  client: MotionEyeClient, camera_id: int, path: str, image: bool
513 ) -> str | None:
514  """Get the URL for a motionEye media item."""
515  with contextlib.suppress(MotionEyeClientPathError):
516  if image:
517  return client.get_image_url(camera_id, path)
518  return client.get_movie_url(camera_id, path)
519  return None
tuple[str, str, int]|None split_motioneye_device_identifier(tuple[str, str] identifier)
Definition: __init__.py:109
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:390
bool is_acceptable_camera(dict[str, Any]|None camera)
Definition: __init__.py:132
MotionEyeClient create_motioneye_client(*Any args, **Any kwargs)
Definition: __init__.py:95
str|None async_generate_motioneye_webhook(HomeAssistant hass, str webhook_id)
Definition: __init__.py:157
dict[str, str] _get_media_event_data(HomeAssistant hass, dr.DeviceEntry device, str event_file_path, int event_file_type)
Definition: __init__.py:464
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:285
None listen_for_new_cameras(HomeAssistant hass, ConfigEntry entry, Callable add_func)
Definition: __init__.py:142
dict[str, Any]|None get_camera_from_cameras(int camera_id, dict[str, Any]|None data)
Definition: __init__.py:123
str|None get_media_url(MotionEyeClient client, int camera_id, str path, bool image)
Definition: __init__.py:513
None _add_camera(HomeAssistant hass, dr.DeviceRegistry device_registry, MotionEyeClient client, ConfigEntry entry, int camera_id, dict[str, Any] camera, tuple[str, str] device_identifier)
Definition: __init__.py:178
Response|None handle_webhook(HomeAssistant hass, str webhook_id, Request request)
Definition: __init__.py:404
tuple[str, str] get_motioneye_device_identifier(str config_entry_id, int camera_id)
Definition: __init__.py:102
None _async_entry_updated(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:280
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193