1 """The motionEye integration."""
3 from __future__
import annotations
5 from collections.abc
import Callable
7 from http
import HTTPStatus
11 from typing
import Any
12 from urllib.parse
import urlencode, urljoin
14 from aiohttp.web
import Request, Response
15 from motioneye_client.client
import (
18 MotionEyeClientInvalidAuthError,
19 MotionEyeClientPathError,
21 from motioneye_client.const
import (
23 KEY_HTTP_METHOD_POST_JSON,
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,
45 async_register
as webhook_register,
46 async_unregister
as webhook_unregister,
55 async_dispatcher_connect,
56 async_dispatcher_send,
68 CONF_SURVEILLANCE_PASSWORD,
69 CONF_SURVEILLANCE_USERNAME,
71 CONF_WEBHOOK_SET_OVERWRITE,
72 DEFAULT_SCAN_INTERVAL,
74 DEFAULT_WEBHOOK_SET_OVERWRITE,
77 EVENT_FILE_STORED_KEYS,
79 EVENT_MEDIA_CONTENT_ID,
80 EVENT_MOTION_DETECTED,
81 EVENT_MOTION_DETECTED_KEYS,
82 MOTIONEYE_MANUFACTURER,
84 WEB_HOOK_SENTINEL_KEY,
85 WEB_HOOK_SENTINEL_VALUE,
88 _LOGGER = logging.getLogger(__name__)
89 PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
96 """Create a MotionEyeClient."""
97 return MotionEyeClient(*args, **kwargs)
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}")
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]:
113 config_id, camera_id_str = identifier[1].split(
"_", 1)
115 camera_id =
int(camera_id_str)
118 return (DOMAIN, config_id, camera_id)
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
133 """Determine if a camera dict is acceptable."""
134 return bool(camera
and KEY_ID
in camera
and KEY_NAME
in camera)
143 """Listen for new cameras."""
145 entry.async_on_unload(
148 SIGNAL_CAMERA_ADD.format(entry.entry_id),
156 hass: HomeAssistant, webhook_id: str
158 """Generate the full local URL for a webhook_id."""
160 return f
"{get_url(hass, allow_cloud=False)}{async_generate_path(webhook_id)}"
161 except NoURLAvailableError:
163 "Unable to get Home Assistant URL. Have you set the internal and/or "
164 "external URLs in Settings -> System -> Network?"
172 device_registry: dr.DeviceRegistry,
173 client: MotionEyeClient,
176 camera: dict[str, Any],
177 device_identifier: tuple[str, str],
179 """Add a motionEye camera to hass."""
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
190 camera: dict[str, Any],
192 """Set a web hook."""
195 CONF_WEBHOOK_SET_OVERWRITE,
196 DEFAULT_WEBHOOK_SET_OVERWRITE,
198 or not camera.get(key_url)
199 or _is_recognized_web_hook(camera[key_url])
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
205 camera[key_enabled] =
True
206 camera[key_method] = KEY_HTTP_METHOD_POST_JSON
207 camera[key_url] = url
212 device: dr.DeviceEntry, base: str, event_type: str, keys: list[str]
214 """Build a motionEye webhook URL."""
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,
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],
241 if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET):
245 set_motion_event = _set_webhook(
249 EVENT_MOTION_DETECTED,
250 EVENT_MOTION_DETECTED_KEYS,
252 KEY_WEB_HOOK_NOTIFICATIONS_URL,
253 KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD,
254 KEY_WEB_HOOK_NOTIFICATIONS_ENABLED,
258 set_storage_event = _set_webhook(
263 EVENT_FILE_STORED_KEYS,
265 KEY_WEB_HOOK_STORAGE_URL,
266 KEY_WEB_HOOK_STORAGE_HTTP_METHOD,
267 KEY_WEB_HOOK_STORAGE_ENABLED,
270 if set_motion_event
or set_storage_event:
271 hass.async_create_task(client.async_set_camera(camera_id, camera))
275 SIGNAL_CAMERA_ADD.format(entry.entry_id),
281 """Handle entry updates."""
282 await hass.config_entries.async_reload(config_entry.entry_id)
286 """Set up motionEye from a config entry."""
287 hass.data.setdefault(DOMAIN, {})
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),
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
308 if CONF_WEBHOOK_ID
not in entry.data:
309 hass.config_entries.async_update_entry(
313 hass, DOMAIN,
"motionEye", entry.data[CONF_WEBHOOK_ID], handle_webhook
316 async
def async_update_data() -> dict[str, Any] | None:
318 return await client.async_get_cameras()
319 except MotionEyeClientError
as exc:
320 raise UpdateFailed(
"Error communicating with API")
from exc
327 update_method=async_update_data,
328 update_interval=DEFAULT_SCAN_INTERVAL,
330 hass.data[DOMAIN][entry.entry_id] = {
332 CONF_COORDINATOR: coordinator,
335 current_cameras: set[tuple[str, str]] = set()
336 device_registry = dr.async_get(hass)
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:
345 for camera
in coordinator.data[KEY_CAMERAS]:
348 camera_id = camera[KEY_ID]
350 entry.entry_id, camera_id
352 inbound_camera.add(device_identifier)
354 if device_identifier
in current_cameras:
356 current_cameras.add(device_identifier)
370 for device_entry
in dr.async_entries_for_config_entry(
371 device_registry, entry.entry_id
373 for identifier
in device_entry.identifiers:
374 if identifier
in inbound_camera:
377 device_registry.async_remove_device(device_entry.id)
379 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
381 entry.async_on_unload(
382 coordinator.async_add_listener(_async_process_motioneye_cameras)
384 await coordinator.async_refresh()
385 entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
391 """Unload a config entry."""
392 webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
394 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
396 config_data = hass.data[DOMAIN].pop(entry.entry_id)
397 await config_data[CONF_CLIENT].async_client_close()
403 hass: HomeAssistant, webhook_id: str, request: Request
404 ) -> Response |
None:
405 """Handle webhook callback."""
408 data = await request.json()
409 except (json.decoder.JSONDecodeError, UnicodeDecodeError):
411 text=
"Could not decode request",
412 status=HTTPStatus.BAD_REQUEST,
415 for key
in (ATTR_DEVICE_ID, ATTR_EVENT_TYPE):
418 text=f
"Missing webhook parameter: {key}",
419 status=HTTPStatus.BAD_REQUEST,
422 event_type = data[ATTR_EVENT_TYPE]
423 device_registry = dr.async_get(hass)
424 device_id = data[ATTR_DEVICE_ID]
426 if not (device := device_registry.async_get(device_id)):
428 text=f
"Device not found: {device_id}",
429 status=HTTPStatus.BAD_REQUEST,
432 if KEY_WEB_HOOK_CS_FILE_PATH
in data
and KEY_WEB_HOOK_CS_FILE_TYPE
in data:
434 event_file_type =
int(data[KEY_WEB_HOOK_CS_FILE_TYPE])
442 data[KEY_WEB_HOOK_CS_FILE_PATH],
448 f
"{DOMAIN}.{event_type}",
450 ATTR_DEVICE_ID: device.id,
451 ATTR_NAME: device.name,
452 ATTR_WEBHOOK_ID: webhook_id,
461 device: dr.DeviceEntry,
462 event_file_path: str,
463 event_file_type: int,
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]:
469 config_entry_data = hass.data[DOMAIN][config_entry_id]
470 client = config_entry_data[CONF_CLIENT]
471 coordinator = config_entry_data[CONF_COORDINATOR]
473 for identifier
in device.identifiers:
482 root_directory = camera.get(KEY_ROOT_DIRECTORY)
if camera
else None
483 if root_directory
is None:
486 kind =
"images" if client.is_file_type_image(event_file_type)
else "movies"
491 if os.path.commonprefix([root_directory, event_file_path]) != root_directory:
494 file_path =
"/" + os.path.relpath(event_file_path, root_directory)
496 EVENT_MEDIA_CONTENT_ID: (
497 f
"{URI_SCHEME}{DOMAIN}/{config_entry_id}#{device.id}#{kind}#{file_path}"
507 output[EVENT_FILE_URL] = url
512 client: MotionEyeClient, camera_id: int, path: str, image: bool
514 """Get the URL for a motionEye media item."""
515 with contextlib.suppress(MotionEyeClientPathError):
517 return client.get_image_url(camera_id, path)
518 return client.get_movie_url(camera_id, path)
tuple[str, str, int]|None split_motioneye_device_identifier(tuple[str, str] identifier)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
bool is_acceptable_camera(dict[str, Any]|None camera)
MotionEyeClient create_motioneye_client(*Any args, **Any kwargs)
str|None async_generate_motioneye_webhook(HomeAssistant hass, str webhook_id)
dict[str, str] _get_media_event_data(HomeAssistant hass, dr.DeviceEntry device, str event_file_path, int event_file_type)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
None listen_for_new_cameras(HomeAssistant hass, ConfigEntry entry, Callable add_func)
dict[str, Any]|None get_camera_from_cameras(int camera_id, dict[str, Any]|None data)
str|None get_media_url(MotionEyeClient client, int camera_id, str path, bool image)
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)
Response|None handle_webhook(HomeAssistant hass, str webhook_id, Request request)
tuple[str, str] get_motioneye_device_identifier(str config_entry_id, int camera_id)
None _async_entry_updated(HomeAssistant hass, ConfigEntry config_entry)
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)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)