diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..eb26b93c Binary files /dev/null and b/.DS_Store differ diff --git a/custom_components/frigate/__init__.py b/custom_components/frigate/__init__.py index 030702f8..88011612 100644 --- a/custom_components/frigate/__init__.py +++ b/custom_components/frigate/__init__.py @@ -170,8 +170,10 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" - - client = FrigateApiClient(entry.data.get(CONF_URL), async_get_clientsession(hass)) + client = FrigateApiClient( + entry.data.get(CONF_URL), + async_get_clientsession(hass), + ) coordinator = FrigateDataUpdateCoordinator(hass, client=client) await coordinator.async_config_entry_first_refresh() @@ -208,6 +210,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for item in get_cameras_and_zones(config): current_devices.add(get_frigate_device_identifier(entry, item)) + if config.get("birdseye", {}).get("restream", False): + current_devices.add(get_frigate_device_identifier(entry, "birdseye")) + device_registry = dr.async_get(hass) for device_entry in dr.async_entries_for_config_entry( device_registry, entry.entry_id diff --git a/custom_components/frigate/api.py b/custom_components/frigate/api.py index c5c10ca3..e73d8f9f 100644 --- a/custom_components/frigate/api.py +++ b/custom_components/frigate/api.py @@ -53,27 +53,31 @@ async def async_get_stats(self) -> dict[str, Any]: async def async_get_events( self, - camera: str | None = None, - label: str | None = None, - zone: str | None = None, + cameras: list[str] | None = None, + labels: list[str] | None = None, + sub_labels: list[str] | None = None, + zones: list[str] | None = None, after: int | None = None, before: int | None = None, limit: int | None = None, has_clip: bool | None = None, has_snapshot: bool | None = None, + favorites: bool | None = None, decode_json: bool = True, ) -> list[dict[str, Any]]: """Get data from the API.""" params = { - "camera": camera, - "label": label, - "zone": zone, + "cameras": ",".join(cameras) if cameras else None, + "labels": ",".join(labels) if labels else None, + "sub_labels": ",".join(sub_labels) if sub_labels else None, + "zones": ",".join(zones) if zones else None, "after": after, "before": before, "limit": limit, "has_clip": int(has_clip) if has_clip is not None else None, "has_snapshot": int(has_snapshot) if has_snapshot is not None else None, "include_thumbnails": 0, + "favorites": int(favorites) if favorites is not None else None, } return cast( @@ -93,12 +97,14 @@ async def async_get_event_summary( self, has_clip: bool | None = None, has_snapshot: bool | None = None, + timezone: str | None = None, decode_json: bool = True, ) -> list[dict[str, Any]]: """Get data from the API.""" params = { "has_clip": int(has_clip) if has_clip is not None else None, "has_snapshot": int(has_snapshot) if has_snapshot is not None else None, + "timezone": str(timezone) if timezone is not None else None, } return cast( @@ -137,15 +143,21 @@ async def async_retain( return cast(dict[str, Any], result) if decode_json else result async def async_get_recordings_summary( - self, camera: str, decode_json: bool = True - ) -> dict[str, Any] | str: + self, camera: str, timezone: str, decode_json: bool = True + ) -> list[dict[str, Any]] | str: """Get recordings summary.""" + params = {"timezone": timezone} + result = await self.api_wrapper( "get", - str(URL(self._host) / f"api/{camera}/recordings/summary"), + str( + URL(self._host) + / f"api/{camera}/recordings/summary" + % {k: v for k, v in params.items() if v is not None} + ), decode_json=decode_json, ) - return cast(dict[str, Any], result) if decode_json else result + return cast(list[dict[str, Any]], result) if decode_json else result async def async_get_recordings( self, diff --git a/custom_components/frigate/binary_sensor.py b/custom_components/frigate/binary_sensor.py index eab67275..515a9610 100644 --- a/custom_components/frigate/binary_sensor.py +++ b/custom_components/frigate/binary_sensor.py @@ -5,8 +5,7 @@ from typing import Any, cast from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -134,7 +133,7 @@ def is_on(self) -> bool: @property def device_class(self) -> str: """Return the device class.""" - return cast(str, DEVICE_CLASS_OCCUPANCY) + return cast(str, BinarySensorDeviceClass.OCCUPANCY) @property def icon(self) -> str: @@ -210,4 +209,4 @@ def is_on(self) -> bool: @property def device_class(self) -> str: """Return the device class.""" - return cast(str, DEVICE_CLASS_MOTION) + return cast(str, BinarySensorDeviceClass.MOTION) diff --git a/custom_components/frigate/camera.py b/custom_components/frigate/camera.py index f8991250..ef05a6e0 100644 --- a/custom_components/frigate/camera.py +++ b/custom_components/frigate/camera.py @@ -36,6 +36,7 @@ ATTR_EVENT_ID, ATTR_FAVORITE, CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, DEVICE_CLASS_CAMERA, DOMAIN, NAME, @@ -66,6 +67,11 @@ async def async_setup_entry( FrigateMqttSnapshots(entry, frigate_config, cam_name, obj_name) for cam_name, obj_name in get_cameras_and_objects(frigate_config, False) ] + + ( + [BirdseyeCamera(entry, frigate_client)] + if frigate_config.get("birdseye", {}).get("restream", False) + else [] + ) ) # setup services @@ -81,7 +87,7 @@ async def async_setup_entry( class FrigateCamera(FrigateMQTTEntity, Camera): # type: ignore[misc] - """Representation a Frigate camera.""" + """Representation of a Frigate camera.""" # sets the entity name to same as device name ex: camera.front_doorbell _attr_name = None @@ -130,7 +136,11 @@ def __init__( # The device_class is used to filter out regular camera entities # from motion camera entities on selectors self._attr_device_class = DEVICE_CLASS_CAMERA - self._attr_is_streaming = self._camera_config.get("rtmp", {}).get("enabled") + self._attr_is_streaming = ( + self._camera_config.get("rtmp", {}).get("enabled") + or self._cam_name + in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys() + ) self._attr_is_recording = self._camera_config.get("record", {}).get("enabled") self._attr_motion_detection_enabled = self._camera_config.get("motion", {}).get( "enabled" @@ -139,20 +149,48 @@ def __init__( f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/motion/set" ) - streaming_template = config_entry.options.get( - CONF_RTMP_URL_TEMPLATE, "" - ).strip() - - if streaming_template: - # Can't use homeassistant.helpers.template as it requires hass which - # is not available in the constructor, so use direct jinja2 - # template instead. This means templates cannot access HomeAssistant - # state, but rather only the camera config. - self._stream_source = Template(streaming_template).render( - **self._camera_config - ) + if ( + self._cam_name + in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys() + ): + self._restream_type = "rtsp" + streaming_template = config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + **self._camera_config + ) + else: + self._stream_source = ( + f"rtsp://{URL(self._url).host}:8554/{self._cam_name}" + ) + + elif self._camera_config.get("rtmp", {}).get("enabled"): + self._restream_type = "rtmp" + streaming_template = config_entry.options.get( + CONF_RTMP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + **self._camera_config + ) + else: + self._stream_source = ( + f"rtmp://{URL(self._url).host}/live/{self._cam_name}" + ) else: - self._stream_source = f"rtmp://{URL(self._url).host}/live/{self._cam_name}" + self._restream_type = "none" @callback # type: ignore[misc] def _state_message_received(self, msg: ReceiveMessage) -> None: @@ -189,6 +227,13 @@ def device_info(self) -> dict[str, Any]: "manufacturer": NAME, } + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return entity specific state attributes.""" + return { + "restream_type": self._restream_type, + } + @property def supported_features(self) -> int: """Return supported features of this camera.""" @@ -244,6 +289,93 @@ async def favorite_event(self, event_id: str, favorite: bool) -> None: await self._client.async_retain(event_id, favorite) +class BirdseyeCamera(FrigateEntity, Camera): # type: ignore[misc] + """Representation of the Frigate birdseye camera.""" + + # sets the entity name to same as device name ex: camera.front_doorbell + _attr_name = None + + def __init__( + self, + config_entry: ConfigEntry, + frigate_client: FrigateApiClient, + ) -> None: + """Initialize the birdseye camera.""" + self._client = frigate_client + FrigateEntity.__init__(self, config_entry) + Camera.__init__(self) + self._url = config_entry.data[CONF_URL] + self._attr_is_on = True + # The device_class is used to filter out regular camera entities + # from motion camera entities on selectors + self._attr_device_class = DEVICE_CLASS_CAMERA + self._attr_is_streaming = True + self._attr_is_recording = False + + streaming_template = config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + {"name": "birdseye"} + ) + else: + self._stream_source = f"rtsp://{URL(self._url).host}:8554/birdseye" + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "camera", + "birdseye", + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return the device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, "birdseye") + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": "Birdseye", + "model": self._get_model(), + "configuration_url": f"{self._url}/cameras/birdseye", + "manufacturer": NAME, + } + + @property + def supported_features(self) -> int: + """Return supported features of this camera.""" + return cast(int, CameraEntityFeature.STREAM) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass)) + + image_url = str( + URL(self._url) + / "api/birdseye/latest.jpg" + % ({"h": height} if height is not None and height > 0 else {}) + ) + + async with async_timeout.timeout(10): + response = await websession.get(image_url) + return await response.read() + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + return self._stream_source + + class FrigateMqttSnapshots(FrigateMQTTEntity, Camera): # type: ignore[misc] """Frigate best camera class.""" diff --git a/custom_components/frigate/config_flow.py b/custom_components/frigate/config_flow.py index 712bb9c4..26c9c741 100644 --- a/custom_components/frigate/config_flow.py +++ b/custom_components/frigate/config_flow.py @@ -20,6 +20,7 @@ CONF_NOTIFICATION_PROXY_ENABLE, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, DEFAULT_HOST, DOMAIN, ) @@ -143,6 +144,16 @@ async def async_step_init( "", ), ): str, + # The input URL is not validated as being a URL to allow for the + # possibility the template input won't be a valid URL until after + # it's rendered. + vol.Optional( + CONF_RTSP_URL_TEMPLATE, + default=self._config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, + "", + ), + ): str, vol.Optional( CONF_NOTIFICATION_PROXY_ENABLE, default=self._config_entry.options.get( diff --git a/custom_components/frigate/const.py b/custom_components/frigate/const.py index a3512bac..568793d9 100644 --- a/custom_components/frigate/const.py +++ b/custom_components/frigate/const.py @@ -40,6 +40,7 @@ CONF_PASSWORD = "password" CONF_PATH = "path" CONF_RTMP_URL_TEMPLATE = "rtmp_url_template" +CONF_RTSP_URL_TEMPLATE = "rtsp_url_template" CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds" # Defaults diff --git a/custom_components/frigate/manifest.json b/custom_components/frigate/manifest.json index 36a84e99..22d4ebe6 100644 --- a/custom_components/frigate/manifest.json +++ b/custom_components/frigate/manifest.json @@ -1,18 +1,18 @@ { "domain": "frigate", - "documentation": "https://github.com/blakeblackshear/frigate", "name": "Frigate", - "version": "3.0.1", - "issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues", + "codeowners": [ + "@blakeblackshear" + ], + "config_flow": true, "dependencies": [ "http", "media_source", "mqtt" ], - "config_flow": true, - "codeowners": [ - "@blakeblackshear" - ], - "requirements": [], - "iot_class": "local_push" + "documentation": "https://github.com/blakeblackshear/frigate", + "iot_class": "local_push", + "issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues", + "requirements": ["pytz==2022.7"], + "version": "4.0.0" } diff --git a/custom_components/frigate/media_source.py b/custom_components/frigate/media_source.py index 365fed57..a73e4dc1 100644 --- a/custom_components/frigate/media_source.py +++ b/custom_components/frigate/media_source.py @@ -4,10 +4,11 @@ import datetime as dt import enum import logging -from typing import Any +from typing import Any, cast import attr from dateutil.relativedelta import relativedelta +import pytz from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, @@ -26,6 +27,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import system_info from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util.dt import DEFAULT_TIME_ZONE @@ -123,7 +125,7 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" raise NotImplementedError - def get_integration_proxy_path(self) -> str: + def get_integration_proxy_path(self, timezone: str) -> str: """Get the proxy (Home Assistant view) path for this identifier.""" raise NotImplementedError @@ -249,7 +251,7 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "event" - def get_integration_proxy_path(self) -> str: + def get_integration_proxy_path(self, timezone: str) -> str: """Get the equivalent Frigate server path.""" if self.frigate_media_type == FrigateMediaType.CLIPS: return f"vod/event/{self.id}/index.{self.frigate_media_type.extension}" @@ -365,22 +367,15 @@ def media_class(self) -> str: return self.frigate_media_type.media_class -def _validate_year_month( +def _validate_year_month_day( inst: RecordingIdentifier, attribute: attr.Attribute, data: str | None ) -> None: """Validate input.""" if data: - year, month = data.split("-") - if int(year) < 0 or int(month) <= 0 or int(month) > 12: - raise ValueError("Invalid year-month in identifier: %s" % data) - - -def _validate_day( - inst: RecordingIdentifier, attribute: attr.Attribute, value: int | None -) -> None: - """Determine if a value is a valid day.""" - if value is not None and (int(value) < 1 or int(value) > 31): - raise ValueError("Invalid day in identifier: %s" % value) + try: + dt.datetime.strptime(data, "%Y-%m-%d") + except ValueError as exc: + raise ValueError("Invalid date in identifier: %s" % data) from exc def _validate_hour( @@ -395,20 +390,15 @@ def _validate_hour( class RecordingIdentifier(Identifier): """Recording Identifier.""" - year_month: str | None = attr.ib( - default=None, - validator=[ - attr.validators.instance_of((str, type(None))), - _validate_year_month, - ], + camera: str | None = attr.ib( + default=None, validator=[attr.validators.instance_of((str, type(None)))] ) - day: int | None = attr.ib( + year_month_day: str | None = attr.ib( default=None, - converter=_to_int_or_none, validator=[ - attr.validators.instance_of((int, type(None))), - _validate_day, + attr.validators.instance_of((str, type(None))), + _validate_year_month_day, ], ) @@ -421,10 +411,6 @@ class RecordingIdentifier(Identifier): ], ) - camera: str | None = attr.ib( - default=None, validator=[attr.validators.instance_of((str, type(None)))] - ) - @classmethod def from_str( cls, data: str, default_frigate_instance_id: str | None = None @@ -440,10 +426,9 @@ def from_str( try: return cls( frigate_instance_id=parts[0], - year_month=cls._get_index(parts, 2), - day=cls._get_index(parts, 3), + camera=cls._get_index(parts, 2), + year_month_day=cls._get_index(parts, 3), hour=cls._get_index(parts, 4), - camera=cls._get_index(parts, 5), ) except ValueError: return None @@ -455,10 +440,11 @@ def __str__(self) -> str: + [ self._empty_if_none(val) for val in ( - self.year_month, - f"{self.day:02}" if self.day is not None else None, - f"{self.hour:02}" if self.hour is not None else None, self.camera, + f"{self.year_month_day}" + if self.year_month_day is not None + else None, + f"{self.hour:02}" if self.hour is not None else None, ) ] ) @@ -468,38 +454,40 @@ def get_identifier_type(cls) -> str: """Get the identifier type.""" return "recordings" - def get_integration_proxy_path(self) -> str: + def get_integration_proxy_path(self, timezone: str) -> str: """Get the integration path that will proxy this identifier.""" - # The attributes of this class represent a path that the recording can - # be retrieved from the Frigate server. If there are holes in the path - # (i.e. missing attributes) the path won't work on the Frigate server, - # so the path returned is either complete or up until the first "hole" / - # missing attribute. - - in_parts = [ - self.get_identifier_type() if not self.camera else "vod", - self.year_month, - f"{self.day:02}" if self.day is not None else None, - f"{self.hour:02}" if self.hour is not None else None, - self.camera, - "index.m3u8" if self.camera else None, - ] - - out_parts = [] - for val in in_parts: - if val is None: - break - out_parts.append(str(val)) - - return "/".join(out_parts) - - def get_changes_to_set_next_empty(self, data: str) -> dict[str, str]: - """Get the changes that would set the next attribute in the hierarchy.""" - for attribute in self.__attrs_attrs__: - if getattr(self, attribute.name) is None: # type: ignore[attr-defined] - return {attribute.name: data} # type: ignore[attr-defined] - raise ValueError("No empty attribute available") + if ( + self.camera is not None + and self.year_month_day is not None + and self.hour is not None + ): + year, month, day = self.year_month_day.split("-") + # Take the selected time in users local time and find the offset to + # UTC, convert to UTC then request the vod for that time. + start_date: dt.datetime = dt.datetime( + int(year), + int(month), + int(day), + int(self.hour), + tzinfo=dt.timezone.utc, + ) - (dt.datetime.now(pytz.timezone(timezone)).utcoffset() or dt.timedelta()) + + parts = [ + "vod", + f"{start_date.year}-{start_date.month:02}", + f"{start_date.day:02}", + f"{start_date.hour:02}", + self.camera, + "utc", + "index.m3u8", + ] + + return "/".join(parts) + + raise MediaSourceError( + "Can not get proxy-path without year_month_day and hour." + ) @property def mime_type(self) -> str: @@ -588,7 +576,10 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: if identifier and self._is_allowed_as_media_source( identifier.frigate_instance_id ): - server_path = identifier.get_integration_proxy_path() + info = await system_info.async_get_system_info(self.hass) + server_path = identifier.get_integration_proxy_path( + info.get("timezone", "utc") + ) return PlayMedia( f"/api/frigate/{identifier.frigate_instance_id}/{server_path}", identifier.mime_type, @@ -693,9 +684,10 @@ async def async_browse_media( events = await self._get_client(identifier).async_get_events( after=identifier.after, before=identifier.before, - camera=identifier.camera, - label=identifier.label, - zone=identifier.zone, + cameras=[identifier.camera] if identifier.camera else None, + labels=[identifier.label] if identifier.label else None, + sub_labels=None, + zones=[identifier.zone] if identifier.zone else None, limit=10000 if identifier.name.endswith(".all") else ITEM_LIMIT, **media_kwargs, ) @@ -707,18 +699,26 @@ async def async_browse_media( ) if isinstance(identifier, RecordingIdentifier): - path = identifier.get_integration_proxy_path() try: - recordings_folder = await self._get_client(identifier).async_get_path( - path + if not identifier.camera: + config = await self._get_client(identifier).async_get_config() + return self._get_camera_recording_folders(identifier, config) + + info = await system_info.async_get_system_info(self.hass) + recording_summary = cast( + list[dict[str, Any]], + await self._get_client(identifier).async_get_recordings_summary( + camera=identifier.camera, timezone=info.get("timezone", "utc") + ), ) + + if not identifier.year_month_day: + return self._get_recording_days(identifier, recording_summary) + + return self._get_recording_hours(identifier, recording_summary) except FrigateApiClientError as exc: raise MediaSourceError from exc - if identifier.hour is None: - return self._browse_recording_folders(identifier, recordings_folder) - return self._browse_recordings(identifier, recordings_folder) - raise MediaSourceError("Invalid media source identifier: %s" % item.identifier) async def _get_event_summary_data( @@ -727,12 +727,14 @@ async def _get_event_summary_data( """Get event summary data.""" try: + info = await system_info.async_get_system_info(self.hass) + if identifier.frigate_media_type == FrigateMediaType.CLIPS: kwargs = {"has_clip": True} else: kwargs = {"has_snapshot": True} summary_data = await self._get_client(identifier).async_get_event_summary( - **kwargs + timezone=info.get("timezone", "utc"), **kwargs ) except FrigateApiClientError as exc: raise MediaSourceError from exc @@ -1242,109 +1244,110 @@ def _count_by( ] ) - @classmethod - def _generate_recording_title( - cls, identifier: RecordingIdentifier, folder: dict[str, Any] | None = None - ) -> str | None: - """Generate recording title.""" - try: - if identifier.hour is not None: - if folder is None: - return dt.datetime.strptime( - f"{identifier.hour}.00.00", "%H.%M.%S" - ).strftime("%T") - return get_friendly_name(folder["name"]) - - if identifier.day is not None: - if folder is None: - return dt.datetime.strptime( - f"{identifier.year_month}-{identifier.day}", "%Y-%m-%d" - ).strftime("%B %d") - return dt.datetime.strptime( - f"{folder['name']}.00.00", "%H.%M.%S" - ).strftime("%T") - - if identifier.year_month is not None: - if folder is None: - return dt.datetime.strptime( - f"{identifier.year_month}", "%Y-%m" - ).strftime("%B %Y") - return dt.datetime.strptime( - f"{identifier.year_month}-{folder['name']}", "%Y-%m-%d" - ).strftime("%B %d") - - if folder is None: - return "Recordings" - return dt.datetime.strptime(f"{folder['name']}", "%Y-%m").strftime("%B %Y") - except ValueError: - return None - def _get_recording_base_media_source( self, identifier: RecordingIdentifier ) -> BrowseMediaSource: """Get the base BrowseMediaSource object for a recording identifier.""" - title = self._generate_recording_title(identifier) - - # Must be able to generate a title for the source folder. - if not title: - raise MediaSourceError - return BrowseMediaSource( domain=DOMAIN, identifier=identifier, media_class=MEDIA_CLASS_DIRECTORY, children_media_class=MEDIA_CLASS_DIRECTORY, media_content_type=identifier.media_type, - title=title, + title="Recordings", can_play=False, can_expand=True, thumbnail=None, children=[], ) - def _browse_recording_folders( - self, identifier: RecordingIdentifier, folders: list[dict[str, Any]] + def _get_camera_recording_folders( + self, identifier: RecordingIdentifier, config: dict[str, dict] ) -> BrowseMediaSource: - """Browse Frigate recording folders.""" + """List cameras for recordings.""" base = self._get_recording_base_media_source(identifier) - for folder in folders: - if folder["name"].endswith(".mp4"): - continue - title = self._generate_recording_title(identifier, folder) - if not title: - _LOGGER.warning("Skipping non-standard folder name: %s", folder["name"]) - continue + for camera in config["cameras"].keys(): base.children.append( BrowseMediaSource( domain=DOMAIN, identifier=attr.evolve( identifier, - **identifier.get_changes_to_set_next_empty(folder["name"]), + camera=camera, ), media_class=MEDIA_CLASS_DIRECTORY, children_media_class=MEDIA_CLASS_DIRECTORY, media_content_type=identifier.media_type, - title=title, + title=get_friendly_name(camera), can_play=False, can_expand=True, thumbnail=None, ) ) + return base - def _browse_recordings( - self, identifier: RecordingIdentifier, recordings: list[dict[str, Any]] + def _get_recording_days( + self, identifier: RecordingIdentifier, recording_days: list[dict[str, Any]] + ) -> BrowseMediaSource: + """List year-month-day options for camera.""" + base = self._get_recording_base_media_source(identifier) + + for day_item in recording_days: + try: + dt.datetime.strptime(day_item["day"], "%Y-%m-%d") + except ValueError as exc: + raise MediaSourceError( + "Media source is not valid for %s %s" + % (identifier, day_item["day"]) + ) from exc + + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + year_month_day=day_item["day"], + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=day_item["day"], + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + return base + + def _get_recording_hours( + self, identifier: RecordingIdentifier, recording_days: list[dict[str, Any]] ) -> BrowseMediaSource: """Browse Frigate recordings.""" base = self._get_recording_base_media_source(identifier) + hour_items: list[dict[str, Any]] = next( + ( + hours["hours"] + for hours in recording_days + if hours["day"] == identifier.year_month_day + ), + [], + ) + + for hour_data in hour_items: + try: + title = dt.datetime.strptime(hour_data["hour"], "%H").strftime("%H:00") + except ValueError as exc: + raise MediaSourceError( + "Media source is not valid for %s %s" + % (identifier, hour_data["hour"]) + ) from exc - for recording in recordings: - title = self._generate_recording_title(identifier, recording) base.children.append( BrowseMediaSource( domain=DOMAIN, - identifier=attr.evolve(identifier, camera=recording["name"]), + identifier=attr.evolve(identifier, hour=hour_data["hour"]), media_class=identifier.media_class, media_content_type=identifier.media_type, title=title, diff --git a/custom_components/frigate/sensor.py b/custom_components/frigate/sensor.py index 8c73886b..e38e57cb 100644 --- a/custom_components/frigate/sensor.py +++ b/custom_components/frigate/sensor.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, TEMP_CELSIUS +from homeassistant.const import CONF_URL, PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,6 +16,7 @@ FrigateEntity, FrigateMQTTEntity, ReceiveMessage, + get_cameras, get_cameras_zones_and_objects, get_friendly_name, get_frigate_device_identifier, @@ -34,6 +35,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Sensor entry setup.""" + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] entities = [] @@ -43,10 +45,24 @@ async def async_setup_entry( elif key == "detectors": for name in value.keys(): entities.append(DetectorSpeedSensor(coordinator, entry, name)) + elif key == "gpu_usages": + for name in value.keys(): + entities.append(GpuLoadSensor(coordinator, entry, name)) elif key == "service": # Temperature is only supported on PCIe Coral. for name in value.get("temperatures", {}): entities.append(DeviceTempSensor(coordinator, entry, name)) + elif key == "cpu_usages": + for camera in get_cameras(frigate_config): + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "capture") + ) + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "detect") + ) + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "ffmpeg") + ) else: entities.extend( [CameraFpsSensor(coordinator, entry, key, t) for t in CAMERA_FPS_TYPES] @@ -228,6 +244,73 @@ def icon(self) -> str: return ICON_SPEEDOMETER +class GpuLoadSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate GPU Load class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + gpu_name: str, + ) -> None: + """Construct a GpuLoadSensor.""" + self._gpu_name = gpu_name + self._attr_name = f"{get_friendly_name(self._gpu_name)} gpu load" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "gpu_load", self._gpu_name + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data: + data = ( + self.coordinator.data.get("gpu_usages", {}) + .get(self._gpu_name, {}) + .get("gpu") + ) + + if data is None or not isinstance(data, str): + return None + + try: + return float(data.replace("%", "").strip()) + except ValueError: + pass + + return None + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return "%" + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SPEEDOMETER + + class CameraFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] """Frigate Camera Fps class.""" @@ -451,3 +534,77 @@ def unit_of_measurement(self) -> Any: def icon(self) -> str: """Return the icon of the sensor.""" return ICON_CORAL + + +class CameraProcessCpuSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Cpu usage for camera processes class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + cam_name: str, + process_type: str, + ) -> None: + """Construct a CoralTempSensor.""" + self._cam_name = cam_name + self._process_type = process_type + self._attr_name = f"{self._process_type} cpu usage" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + f"{self._process_type}_cpu_usage", + self._cam_name, + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data: + pid_key = ( + "pid" if self._process_type == "detect" else f"{self._process_type}_pid" + ) + pid = str(self.coordinator.data.get(self._cam_name, {}).get(pid_key, "-1")) + data = ( + self.coordinator.data.get("cpu_usages", {}) + .get(pid, {}) + .get("cpu", None) + ) + + try: + return float(data) + except (TypeError, ValueError): + pass + return None + + @property + def unit_of_measurement(self) -> Any: + """Return the unit of measurement of the sensor.""" + return PERCENTAGE + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_CORAL diff --git a/custom_components/frigate/translations/en.json b/custom_components/frigate/translations/en.json index 6df23335..07867085 100644 --- a/custom_components/frigate/translations/en.json +++ b/custom_components/frigate/translations/en.json @@ -21,6 +21,7 @@ "init": { "data": { "rtmp_url_template": "RTMP URL template (see documentation)", + "rtsp_url_template": "RTSP URL template (see documentation)", "media_browser_enable": "Enable the media browser", "notification_proxy_enable": "Enable the unauthenticated notification event proxy", "notification_proxy_expire_after_seconds": "Disallow unauthenticated notification access after seconds (0=never)" diff --git a/custom_components/frigate/translations/pt-BR.json b/custom_components/frigate/translations/pt-BR.json index 0adf068e..92cb58a6 100644 --- a/custom_components/frigate/translations/pt-BR.json +++ b/custom_components/frigate/translations/pt-BR.json @@ -21,6 +21,7 @@ "init": { "data": { "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)", "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado" } } diff --git a/custom_components/frigate/translations/pt_br.json b/custom_components/frigate/translations/pt_br.json index 0adf068e..92cb58a6 100644 --- a/custom_components/frigate/translations/pt_br.json +++ b/custom_components/frigate/translations/pt_br.json @@ -21,6 +21,7 @@ "init": { "data": { "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)", "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado" } } diff --git a/custom_components/frigate/views.py b/custom_components/frigate/views.py index 60db1471..2a4d4c9e 100644 --- a/custom_components/frigate/views.py +++ b/custom_components/frigate/views.py @@ -101,6 +101,8 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the views.""" session = async_get_clientsession(hass) hass.http.register_view(JSMPEGProxyView(session)) + hass.http.register_view(MSEProxyView(session)) + hass.http.register_view(WebRTCProxyView(session)) hass.http.register_view(NotificationsProxyView(session)) hass.http.register_view(SnapshotsProxyView(session)) hass.http.register_view(RecordingProxyView(session)) @@ -479,7 +481,33 @@ class JSMPEGProxyView(WebsocketProxyView): def _create_path(self, **kwargs: Any) -> str | None: """Create path.""" - return f"live/{kwargs['path']}" + return f"live/jsmpeg/{kwargs['path']}" + + +class MSEProxyView(WebsocketProxyView): + """A proxy for MSE websocket.""" + + url = "/api/frigate/{frigate_instance_id:.+}/mse/{path:.+}" + extra_urls = ["/api/frigate/mse/{path:.+}"] + + name = "api:frigate:mse" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"live/mse/{kwargs['path']}" + + +class WebRTCProxyView(WebsocketProxyView): + """A proxy for WebRTC websocket.""" + + url = "/api/frigate/{frigate_instance_id:.+}/webrtc/{path:.+}" + extra_urls = ["/api/frigate/webrtc/{path:.+}"] + + name = "api:frigate:webrtc" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"live/webrtc/{kwargs['path']}" def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]: diff --git a/custom_components/frigate/ws_api.py b/custom_components/frigate/ws_api.py index a54160ac..f6efe333 100644 --- a/custom_components/frigate/ws_api.py +++ b/custom_components/frigate/ws_api.py @@ -114,6 +114,7 @@ async def ws_get_recordings( vol.Required("type"): "frigate/recordings/summary", vol.Required("instance_id"): str, vol.Required("camera"): str, + vol.Optional("timezone"): str, } ) # type: ignore[misc] @websocket_api.async_response # type: ignore[misc] @@ -129,7 +130,9 @@ async def ws_get_recordings_summary( try: connection.send_result( msg["id"], - await client.async_get_recordings_summary(msg["camera"], decode_json=False), + await client.async_get_recordings_summary( + msg["camera"], msg.get("timezone", "utc"), decode_json=False + ), ) except FrigateApiClientError: connection.send_error( @@ -144,14 +147,17 @@ async def ws_get_recordings_summary( { vol.Required("type"): "frigate/events/get", vol.Required("instance_id"): str, - vol.Optional("camera"): str, - vol.Optional("label"): str, - vol.Optional("zone"): str, + vol.Optional("cameras"): [str], + vol.Optional("labels"): [str], + vol.Optional("sub_labels"): [str], + vol.Optional("zones"): [str], vol.Optional("after"): int, vol.Optional("before"): int, vol.Optional("limit"): int, vol.Optional("has_clip"): bool, vol.Optional("has_snapshot"): bool, + vol.Optional("has_snapshot"): bool, + vol.Optional("favorites"): bool, } ) # type: ignore[misc] @websocket_api.async_response # type: ignore[misc] @@ -169,14 +175,16 @@ async def ws_get_events( connection.send_result( msg["id"], await client.async_get_events( - msg.get("camera"), - msg.get("label"), - msg.get("zone"), + msg.get("cameras"), + msg.get("labels"), + msg.get("sub_labels"), + msg.get("zones"), msg.get("after"), msg.get("before"), msg.get("limit"), msg.get("has_clip"), msg.get("has_snapshot"), + msg.get("favorites"), decode_json=False, ), ) @@ -184,8 +192,8 @@ async def ws_get_events( connection.send_error( msg["id"], "frigate_error", - f"API error whilst retrieving events for camera " - f"{msg['camera']} for Frigate instance {msg['instance_id']}", + f"API error whilst retrieving events for cameras " + f"{msg['cameras']} for Frigate instance {msg['instance_id']}", ) @@ -195,6 +203,7 @@ async def ws_get_events( vol.Required("instance_id"): str, vol.Optional("has_clip"): bool, vol.Optional("has_snapshot"): bool, + vol.Optional("timezone"): str, } ) # type: ignore[misc] @websocket_api.async_response # type: ignore[misc] @@ -214,6 +223,7 @@ async def ws_get_events_summary( await client.async_get_event_summary( msg.get("has_clip"), msg.get("has_snapshot"), + msg.get("timezone", "utc"), decode_json=False, ), ) diff --git a/docker-compose.yml b/docker-compose.yml index 2d434c31..72b73f16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - /var/run/docker.sock:/var/run/docker-host.sock - .:${LOCAL_WORKSPACE_FOLDER}:cached hass: - image: "homeassistant/home-assistant:${HA_VERSION:-stable}" + image: "homeassistant/home-assistant:${HA_VERSION:-latest}" restart: unless-stopped volumes: - /etc/localtime:/etc/localtime:ro @@ -24,7 +24,7 @@ services: - ./custom_components:/config/custom_components:ro frigate: privileged: true - image: "blakeblackshear/frigate:${FRIGATE_VERSION:-stable-amd64}" + image: "ghcr.io/blakeblackshear/frigate:${FRIGATE_VERSION:-0.12.0-rc1}" restart: unless-stopped devices: - /dev/bus/usb:/dev/bus/usb diff --git a/pyproject.toml b/pyproject.toml index e19f1217..ceab52a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,6 @@ good-names = [ # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* @@ -67,7 +66,6 @@ good-names = [ # wrong-import-order - isort guards this disable = [ "format", - "abstract-class-little-used", "abstract-method", "cyclic-import", "duplicate-code", diff --git a/requirements.txt b/requirements.txt index 7f2a02cb..1e6df62d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ homeassistant==2023.1.7 paho-mqtt python-dateutil yarl +pytz==2022.7 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index cde9bfc6..fa95629f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,7 +1,7 @@ -r requirements.txt black flake8 -mypy +mypy==1.1.1 pre-commit pytest pytest-homeassistant-custom-component==0.12.49 @@ -10,3 +10,4 @@ pylint pytest-aiohttp pytest-asyncio types-python-dateutil +types-pytz diff --git a/tests/__init__.py b/tests/__init__.py index 947cc0c7..2ea430c3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,7 @@ "binary_sensor.steps_person_occupancy" ) TEST_BINARY_SENSOR_STEPS_ALL_OCCUPANCY_ENTITY_ID = "binary_sensor.steps_all_occupancy" +TEST_CAMERA_BIRDSEYE_ENTITY_ID = "camera.birdseye" TEST_CAMERA_FRONT_DOOR_ENTITY_ID = "camera.front_door" TEST_CAMERA_FRONT_DOOR_PERSON_ENTITY_ID = "camera.front_door_person" @@ -42,6 +43,7 @@ TEST_SWITCH_FRONT_DOOR_IMPROVE_CONTRAST_ENTITY_ID = "switch.front_door_improve_contrast" TEST_SENSOR_CORAL_TEMPERATURE_ENTITY_ID = "sensor.frigate_apex_0_temperature" +TEST_SENSOR_GPU_LOAD_ENTITY_ID = "sensor.frigate_nvidia_geforce_rtx_3050_gpu_load" TEST_SENSOR_STEPS_ALL_ENTITY_ID = "sensor.steps_all_count" TEST_SENSOR_STEPS_PERSON_ENTITY_ID = "sensor.steps_person_count" TEST_SENSOR_FRONT_DOOR_ALL_ENTITY_ID = "sensor.front_door_all_count" @@ -50,7 +52,10 @@ TEST_SENSOR_CPU1_INTFERENCE_SPEED_ENTITY_ID = "sensor.frigate_cpu1_inference_speed" TEST_SENSOR_CPU2_INTFERENCE_SPEED_ENTITY_ID = "sensor.frigate_cpu2_inference_speed" TEST_SENSOR_FRONT_DOOR_CAMERA_FPS_ENTITY_ID = "sensor.front_door_camera_fps" +TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE = "sensor.front_door_capture_cpu_usage" +TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE = "sensor.front_door_detect_cpu_usage" TEST_SENSOR_FRONT_DOOR_DETECTION_FPS_ENTITY_ID = "sensor.front_door_detection_fps" +TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE = "sensor.front_door_ffmpeg_cpu_usage" TEST_SENSOR_FRONT_DOOR_PROCESS_FPS_ENTITY_ID = "sensor.front_door_process_fps" TEST_SENSOR_FRONT_DOOR_SKIPPED_FPS_ENTITY_ID = "sensor.front_door_skipped_fps" TEST_SENSOR_FRIGATE_STATUS_ENTITY_ID = "sensor.frigate_status" @@ -76,7 +81,7 @@ "ffmpeg_cmds": [ { "cmd": "ffmpeg -hide_banner -loglevel warning -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport tcp -stimeout 5000000 -use_wallclock_as_timestamps 1 -i rtsp://rtsp:password@cam-front-door/live -f segment -segment_time 10 -segment_format mp4 -reset_timestamps 1 -strftime 1 -c copy -an /tmp/cache/front_door-%Y%m%d%H%M%S.mp4 -c copy -f flv rtmp://127.0.0.1/live/front_door -r 4 -f rawvideo -pix_fmt yuv420p pipe:", - "roles": ["detect", "rtmp", "clips"], + "roles": ["detect", "rtmp", "restream", "clips"], } ], "fps": 4, @@ -148,6 +153,7 @@ "user": None, }, "snapshots": {"retain": {"default": 10, "objects": {}}}, + "go2rtc": {"streams": {"front_door": "rtsp://rtsp:password@cam-front-door/live"}}, } TEST_STATS = { "detection_fps": 13.7, @@ -160,6 +166,7 @@ "capture_pid": 53, "detection_fps": 6.0, "pid": 52, + "ffmpeg_pid": 54, "process_fps": 4.0, "skipped_fps": 0.0, }, @@ -195,6 +202,17 @@ "latest_version": "0.10.1", "temperatures": {"apex_0": 50.0}, }, + "cpu_usages": { + "52": {"cpu": 5.0, "mem": 1.0}, + "53": {"cpu": 3.0, "mem": 2.0}, + "54": {"cpu": 15.0, "mem": 4.0}, + }, + "gpu_usages": { + "Nvidia GeForce RTX 3050": { + "gpu": "19 %", + "mem": "57.0 %", + } + }, } TEST_EVENT_SUMMARY = [ # Today diff --git a/tests/test_api.py b/tests/test_api.py index 9f99edcf..58685bbd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -79,9 +79,10 @@ async def events_handler(request: web.Request) -> web.Response: _assert_request_params( request, { - "camera": "test_camera", - "label": "test_label", - "zone": "test_zone", + "cameras": "test_camera1,test_camera2", + "labels": "test_label1,test_label2", + "sub_labels": "test_sub_label1,test_sub_label2", + "zones": "test_zone1,test_zone2", "after": "1", "before": "2", "limit": "3", @@ -96,9 +97,10 @@ async def events_handler(request: web.Request) -> web.Response: frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session) assert events_in == await frigate_client.async_get_events( - camera="test_camera", - label="test_label", - zone="test_zone", + cameras=["test_camera1", "test_camera2"], + labels=["test_label1", "test_label2"], + sub_labels=["test_sub_label1", "test_sub_label2"], + zones=["test_zone1", "test_zone2"], after=1, before=2, limit=3, @@ -300,7 +302,7 @@ async def test_async_get_recordings_summary( ) -> None: """Test async_recordings_summary.""" - summary_success = {"summary": "goes_here"} + summary_success = [{"summary": "goes_here"}] summary_handler = AsyncMock(return_value=web.json_response(summary_success)) camera = "front_door" @@ -309,7 +311,10 @@ async def test_async_get_recordings_summary( ) frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session) - assert await frigate_client.async_get_recordings_summary(camera) == summary_success + assert ( + await frigate_client.async_get_recordings_summary(camera, "utc") + == summary_success + ) assert summary_handler.called diff --git a/tests/test_camera.py b/tests/test_camera.py index 774c945a..4d3a8f8e 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -13,6 +13,7 @@ ATTR_EVENT_ID, ATTR_FAVORITE, CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, DOMAIN, NAME, SERVICE_FAVORITE_EVENT, @@ -29,6 +30,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import ( + TEST_CAMERA_BIRDSEYE_ENTITY_ID, TEST_CAMERA_FRONT_DOOR_ENTITY_ID, TEST_CAMERA_FRONT_DOOR_PERSON_ENTITY_ID, TEST_CONFIG, @@ -43,7 +45,7 @@ _LOGGER = logging.getLogger(__name__) -async def test_frigate_camera_setup( +async def test_frigate_camera_setup_rtsp( hass: HomeAssistant, aioclient_mock: Any, ) -> None: @@ -55,6 +57,57 @@ async def test_frigate_camera_setup( assert entity_state assert entity_state.state == "streaming" assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["restream_type"] == "rtsp" + + source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert source + assert source == "rtsp://example.com:8554/front_door" + + aioclient_mock.get( + "http://example.com/api/front_door/latest.jpg?h=277", + content=b"data-277", + ) + + image = await async_get_image(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID, height=277) + assert image + assert image.content == b"data-277" + + +async def test_frigate_camera_setup_birdseye_rtsp(hass: HomeAssistant) -> None: + """Set up birdseye camera.""" + + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["birdseye"] = {"restream": True} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + await setup_mock_frigate_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert entity_state + assert entity_state.state == "streaming" + + source = await async_get_stream_source(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert source + assert source == "rtsp://example.com:8554/birdseye" + + +async def test_frigate_camera_setup_rtmp( + hass: HomeAssistant, + aioclient_mock: Any, +) -> None: + """Set up a camera.""" + + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["go2rtc"] = {} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + await setup_mock_frigate_config_entry(hass, client=client) + + entity_state = hass.states.get(TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert entity_state + assert entity_state.state == "streaming" + assert entity_state.attributes["supported_features"] == 2 + assert entity_state.attributes["restream_type"] == "rtmp" source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) assert source @@ -99,10 +152,43 @@ async def test_frigate_camera_image_height( assert image.content == b"data-no-height" +async def test_frigate_camera_birdseye_image_height( + hass: HomeAssistant, + aioclient_mock: Any, +) -> None: + """Ensure async_camera_image respects height parameter.""" + + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["birdseye"] = {"restream": True} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + await setup_mock_frigate_config_entry(hass, client=client) + + aioclient_mock.get( + "http://example.com/api/birdseye/latest.jpg?h=1000", + content=b"data-1000", + ) + + image = await async_get_image(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID, height=1000) + assert image + assert image.content == b"data-1000" + + # Don't specify the height (no argument should be passed). + aioclient_mock.get( + "http://example.com/api/birdseye/latest.jpg", + content=b"data-no-height", + ) + + image = await async_get_image(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert image + assert image.content == b"data-no-height" + + async def test_frigate_camera_setup_no_stream(hass: HomeAssistant) -> None: """Set up a camera without streaming.""" config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["go2rtc"] = {} config["cameras"]["front_door"]["rtmp"]["enabled"] = False client = create_mock_frigate_client() client.async_get_config = AsyncMock(return_value=config) @@ -112,6 +198,7 @@ async def test_frigate_camera_setup_no_stream(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == "idle" assert not entity_state.attributes["supported_features"] + assert entity_state.attributes["restream_type"] == "none" assert not await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) @@ -123,6 +210,7 @@ async def test_frigate_camera_recording_camera_state( """Set up an mqtt camera.""" config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["go2rtc"] = {} config["cameras"]["front_door"]["rtmp"]["enabled"] = False client = create_mock_frigate_client() client.async_get_config = AsyncMock(return_value=config) @@ -277,15 +365,62 @@ async def test_camera_unique_id( assert registry_entry.unique_id == unique_id -async def test_camera_option_stream_url_template( +async def test_camera_option_rtsp_stream_url_template( aiohttp_server: Any, hass: HomeAssistant ) -> None: - """Verify camera with the RTMP URL template option.""" + """Verify camera with the RTSP URL template option.""" + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + config_entry = create_mock_frigate_config_entry( + hass, options={CONF_RTSP_URL_TEMPLATE: ("rtsp://localhost/{{ name }}")} + ) + await setup_mock_frigate_config_entry( + hass, client=client, config_entry=config_entry + ) + + source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) + assert source + assert source == "rtsp://localhost/front_door" + + +async def test_birdseye_option_rtsp_stream_url_template( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Verify birdseye cam with the RTSP URL template option.""" + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["birdseye"] = {"restream": True} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) + config_entry = create_mock_frigate_config_entry( + hass, options={CONF_RTSP_URL_TEMPLATE: ("rtsp://localhost/{{ name }}")} + ) + + await setup_mock_frigate_config_entry( + hass, client=client, config_entry=config_entry + ) + + source = await async_get_stream_source(hass, TEST_CAMERA_BIRDSEYE_ENTITY_ID) + assert source + assert source == "rtsp://localhost/birdseye" + + +async def test_camera_option_rtmp_stream_url_template( + aiohttp_server: Any, hass: HomeAssistant +) -> None: + """Verify camera with the RTMP URL template option.""" + config: dict[str, Any] = copy.deepcopy(TEST_CONFIG) + config["go2rtc"] = {} + client = create_mock_frigate_client() + client.async_get_config = AsyncMock(return_value=config) config_entry = create_mock_frigate_config_entry( hass, options={CONF_RTMP_URL_TEMPLATE: ("rtmp://localhost/{{ name }}")} ) - await setup_mock_frigate_config_entry(hass, config_entry=config_entry) + + await setup_mock_frigate_config_entry( + hass, client=client, config_entry=config_entry + ) source = await async_get_stream_source(hass, TEST_CAMERA_FRONT_DOOR_ENTITY_ID) assert source diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d7fa1dca..9545cb35 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -12,6 +12,7 @@ CONF_NOTIFICATION_PROXY_ENABLE, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, DOMAIN, ) from homeassistant import config_entries, data_entry_flow @@ -175,6 +176,7 @@ async def test_options_advanced(hass: HomeAssistant) -> None: result["flow_id"], user_input={ CONF_RTMP_URL_TEMPLATE: "http://moo", + CONF_RTSP_URL_TEMPLATE: "http://moo", CONF_NOTIFICATION_PROXY_ENABLE: False, CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS: 60, CONF_MEDIA_BROWSER_ENABLE: False, @@ -183,6 +185,7 @@ async def test_options_advanced(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_RTMP_URL_TEMPLATE] == "http://moo" + assert result["data"][CONF_RTSP_URL_TEMPLATE] == "http://moo" assert result["data"][CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS] == 60 assert not result["data"][CONF_NOTIFICATION_PROXY_ENABLE] assert not result["data"][CONF_MEDIA_BROWSER_ENABLE] diff --git a/tests/test_media_source.py b/tests/test_media_source.py index d6ae7c38..98bb967b 100644 --- a/tests/test_media_source.py +++ b/tests/test_media_source.py @@ -10,8 +10,8 @@ from typing import Any from unittest.mock import AsyncMock, Mock, call, patch -import attr import pytest +import pytz from custom_components.frigate.api import FrigateApiClient, FrigateApiClientError from custom_components.frigate.const import ( @@ -34,6 +34,7 @@ from homeassistant.components.media_source.models import PlayMedia from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import system_info from . import ( TEST_CONFIG, @@ -169,7 +170,7 @@ async def test_async_browse_media_root(hass: HomeAssistant) -> None: "media_content_type": "video", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings////" + "/recordings///" ), "can_play": False, "can_expand": True, @@ -206,7 +207,7 @@ async def test_async_browse_media_root(hass: HomeAssistant) -> None: "media_class": "directory", "media_content_type": "video", "media_content_id": ( - "media-source://frigate/another_client_id/recordings////" + "media-source://frigate/another_client_id/recordings///" ), "can_play": False, "can_expand": True, @@ -640,12 +641,21 @@ async def test_async_resolve_media( hass, ( f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-05/30/15/front_door/46.08.mp4" + "/recordings/front_door/2021-05-30/15/46.08.mp4" ), ) + + # Convert from HA local timezone to UTC. + info = await system_info.async_get_system_info(hass) + date = datetime.datetime(2021, 5, 30, 15, 46, 8) + date = pytz.timezone(info.get("timezone", "utc")).localize(date) + date = date.astimezone(pytz.utc) + assert media == PlayMedia( url=( - f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/vod/2021-05/30/15/front_door/index.m3u8" + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/vod/" + + date.strftime("%Y-%m/%d/%H") + + "/front_door/utc/index.m3u8" ), mime_type="application/x-mpegURL", ) @@ -676,22 +686,6 @@ async def test_async_browse_media_recordings_root( await setup_mock_frigate_config_entry(hass, client=frigate_client) - frigate_client.async_get_path = AsyncMock( - return_value=[ - { - "name": "2021-06", - "type": "directory", - "mtime": "Sun, 30 June 2021 22:47:14 GMT", - }, - { - "name": "49.06.mp4", - "type": "file", - "mtime": "Sun, 30 June 2021 22:50:06 GMT", - "size": 5168517, - }, - ] - ) - media = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}/recordings", @@ -702,56 +696,7 @@ async def test_async_browse_media_recordings_root( "media_class": "directory", "media_content_type": "video", "media_content_id": ( - f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}/recordings////" - ), - "can_play": False, - "can_expand": True, - "children_media_class": "directory", - "thumbnail": None, - "not_shown": 0, - "children": [ - { - "can_expand": True, - "can_play": False, - "children_media_class": "directory", - "media_class": "directory", - "media_content_id": ( - f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06///" - ), - "media_content_type": "video", - "thumbnail": None, - "title": "June 2021", - } - ], - } - - frigate_client.async_get_path = AsyncMock( - return_value=[ - { - "name": "04", - "type": "directory", - "mtime": "Mon, 07 Jun 2021 02:33:16 GMT", - }, - { - "name": "NOT_AN_HOUR", - "type": "directory", - "mtime": "Mon, 07 Jun 2021 02:33:17 GMT", - }, - ] - ) - - media = await media_source.async_browse_media( - hass, - f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06///", - ) - - assert media.as_dict() == { - "title": "June 2021", - "media_class": "directory", - "media_content_type": "video", - "media_content_id": ( - f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06///" + f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}/recordings///" ), "can_play": False, "can_expand": True, @@ -766,39 +711,51 @@ async def test_async_browse_media_recordings_root( "media_class": "directory", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04//" + "/recordings/front_door//" ), "media_content_type": "video", "thumbnail": None, - "title": "June 04", + "title": "Front Door", } ], } - # There's a bogus value for an hour, that should be skipped. - assert "Skipping non-standard folder" in caplog.text - frigate_client.async_get_path = AsyncMock( + frigate_client.async_get_recordings_summary = AsyncMock( return_value=[ { - "name": "15", - "type": "directory", - "mtime": "Sun, 04 June 2021 22:47:14 GMT", + "day": "2022-12-31", + "events": 11, + "hours": [ + { + "duration": 3582, + "events": 2, + "hour": "01", + "motion": 133116366, + "objects": 832, + }, + { + "duration": 3537, + "events": 3, + "hour": "00", + "motion": 146836625, + "objects": 1118, + }, + ], }, ] ) media = await media_source.async_browse_media( hass, - f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04//", + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door//", ) assert media.as_dict() == { - "title": "June 04", + "title": "Recordings", "media_class": "directory", "media_content_type": "video", "media_content_id": ( - f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04//" + f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door//" ), "can_play": False, "can_expand": True, @@ -813,45 +770,30 @@ async def test_async_browse_media_recordings_root( "media_class": "directory", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/" + "/recordings/front_door/2022-12-31/" ), "media_content_type": "video", "thumbnail": None, - "title": "15:00:00", + "title": "2022-12-31", } ], } - frigate_client.async_get_path = AsyncMock( - return_value=[ - { - "name": "front_door", - "type": "directory", - "mtime": "Sun, 30 June 2021 23:00:50 GMT", - }, - { - "name": "sitting_room", - "type": "directory", - "mtime": "Sun, 04 June 2021 23:00:40 GMT", - }, - ] - ) - media = await media_source.async_browse_media( hass, ( f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/" + "/recordings/front_door/2022-12-31/00" ), ) assert media.as_dict() == { - "title": "15:00:00", + "title": "Recordings", "media_class": "directory", "media_content_type": "video", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/" + "/recordings/front_door/2022-12-31/00" ), "can_play": False, "can_expand": True, @@ -866,11 +808,11 @@ async def test_async_browse_media_recordings_root( "media_class": "movie", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/front_door" + "/recordings/front_door/2022-12-31/01" ), "media_content_type": "video", "thumbnail": None, - "title": "Front Door", + "title": "01:00", }, { "can_expand": False, @@ -879,11 +821,11 @@ async def test_async_browse_media_recordings_root( "media_class": "movie", "media_content_id": ( f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/sitting_room" + "/recordings/front_door/2022-12-31/00" ), "media_content_type": "video", "thumbnail": None, - "title": "Sitting Room", + "title": "00:00", }, ], } @@ -898,59 +840,74 @@ async def test_async_browse_media_recordings_root( ), ) + # Ensure API error results in MediaSourceError + frigate_client.async_get_recordings_summary = AsyncMock( + side_effect=FrigateApiClientError() + ) + with pytest.raises(MediaSourceError): + await media_source.async_browse_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" + "/recordings/front_door/2022-12-31/00" + ), + ) + # Ensure a syntactically correct, but semantically incorrect path will - # result in a MediaSourceError (there is no 29th February in 2021). + # result in a MediaSourceError (there is no 24th hour). with pytest.raises(MediaSourceError): + frigate_client.async_get_recordings_summary = AsyncMock( + return_value=[ + { + "day": "2022-12-31", + "events": 11, + "hours": [ + { + "duration": 3582, + "events": 2, + "hour": "24", + "motion": 133116366, + "objects": 832, + }, + ], + }, + ] + ) await media_source.async_browse_media( hass, - f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-02/29", + ( + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" + "/recordings/front_door/2022-12-31/" + ), ) - # Fetch a recording on the zeroth hour: - # https://github.com/blakeblackshear/frigate-hass-integration/issues/126 - frigate_client.async_get_path = AsyncMock( - return_value=[ - { - "name": "front_door", - "type": "directory", - "mtime": "Sun, 30 June 2021 23:00:50 GMT", - }, - ] - ) - media = await media_source.async_browse_media( - hass, - ( - f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/00/" - ), - ) - assert media.as_dict() == { - "title": "00:00:00", - "media_class": "directory", - "media_content_type": "video", - "media_content_id": "media-source://frigate/frigate_client_id/recordings/2021-06/04/00/", - "can_play": False, - "can_expand": True, - "children_media_class": "directory", - "thumbnail": None, - "not_shown": 0, - "children": [ - { - "can_expand": False, - "can_play": True, - "children_media_class": None, - "media_class": "movie", - "media_content_id": ( - f"media-source://frigate/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/00/front_door" - ), - "media_content_type": "video", - "thumbnail": None, - "title": "Front Door", - }, - ], - } + # Ensure a syntactically correct, but semantically incorrect path will + # result in a MediaSourceError (there is no 29th February in 2022). + with pytest.raises(MediaSourceError): + frigate_client.async_get_recordings_summary = AsyncMock( + return_value=[ + { + "day": "2022-2-29", + "events": 11, + "hours": [ + { + "duration": 3582, + "events": 2, + "hour": "01", + "motion": 133116366, + "objects": 832, + }, + ], + }, + ] + ) + await media_source.async_browse_media( + hass, + ( + f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" + "/recordings/front_door//" + ), + ) async def test_async_browse_media_async_get_event_summary_error( @@ -987,29 +944,6 @@ async def test_async_browse_media_async_get_events_error( ) -async def test_async_browse_media_async_get_path_error( - caplog: Any, frigate_client: AsyncMock, hass: HomeAssistant -) -> None: - """Test API error behavior.""" - frigate_client.async_get_path = AsyncMock(side_effect=FrigateApiClientError) - - await setup_mock_frigate_config_entry(hass, client=frigate_client) - - with pytest.raises(MediaSourceError): - await media_source.async_browse_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}/recordings" - ) - - with pytest.raises(MediaSourceError): - await media_source.async_browse_media( - hass, - ( - f"{const.URI_SCHEME}{DOMAIN}/{TEST_FRIGATE_INSTANCE_ID}" - "/recordings/2021-06/04/15/front_door" - ), - ) - - async def test_identifier() -> None: """Test base identifier.""" identifier = Identifier("foo") @@ -1056,7 +990,7 @@ async def test_event_search_identifier() -> None: # Event searches have no equivalent Frigate server path (searches result in # EventIdentifiers, that do have a Frigate server path). with pytest.raises(NotImplementedError): - identifier.get_integration_proxy_path() + identifier.get_integration_proxy_path("utc") # Invalid "after" time. assert ( @@ -1083,27 +1017,22 @@ async def test_event_search_identifier() -> None: async def test_recordings_identifier() -> None: """Test recordings identifier.""" - identifier_in = f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04/15/front_door" + identifier_in = f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/2021-06-04/15" identifier = Identifier.from_str(identifier_in) assert identifier assert isinstance(identifier, RecordingIdentifier) assert identifier.frigate_instance_id == TEST_FRIGATE_INSTANCE_ID - assert identifier.year_month == "2021-06" - assert identifier.day == 4 - assert identifier.hour == 15 assert identifier.camera == "front_door" + assert identifier.year_month_day == "2021-06-04" + assert identifier.hour == 15 assert str(identifier) == identifier_in - with pytest.raises(ValueError): - # The identifier is fully specified, there's no next available attribute. - identifier.get_changes_to_set_next_empty("value") - # Test acceptable boundary conditions. - for path in ("0-1/1/0/0", "9000-12/31/23/59"): + for path in ("0001-1-1/0", "9000-12-31/23"): assert ( Identifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/{path}/cam/media" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/cam/{path}/media" ) is not None ) @@ -1111,7 +1040,7 @@ async def test_recordings_identifier() -> None: # Year is not an int. assert ( RecordingIdentifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/NOT_AN_INT-06/04/15/front_door" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/NOT_AN_INT-06-04/15" ) is None ) @@ -1119,7 +1048,7 @@ async def test_recordings_identifier() -> None: # No 13th month. assert ( RecordingIdentifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-13/04/15/front_door" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/2021-13-04/15" ) is None ) @@ -1127,7 +1056,7 @@ async def test_recordings_identifier() -> None: # No 32nd day. assert ( RecordingIdentifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-12/32/15/front_door" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/2021-12-32/15" ) is None ) @@ -1135,7 +1064,7 @@ async def test_recordings_identifier() -> None: # No 25th hour. assert ( RecordingIdentifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-12/28/25/front_door" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/2021-12-28/25" ) is None ) @@ -1148,26 +1077,20 @@ async def test_recordings_identifier() -> None: is None ) - # A missing element (no hour) in the identifier, so no path will be possible + # A missing element (no year-month-day) in the identifier, so no path will be possible # beyond the path to the day. - identifier_in = f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04//front_door" - identifier = RecordingIdentifier.from_str(identifier_in) - assert identifier - assert identifier.get_integration_proxy_path() == "vod/2021-06/04" + with pytest.raises(MediaSourceError): + identifier_in = f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door//15" + identifier = RecordingIdentifier.from_str(identifier_in) + assert identifier is not None + identifier.get_integration_proxy_path("utc") # Verify a zero hour: # https://github.com/blakeblackshear/frigate-hass-integration/issues/126 identifier = RecordingIdentifier.from_str( - f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04/00//" + f"{TEST_FRIGATE_INSTANCE_ID}/recordings/front_door/2021-06-4/00" ) assert identifier - identifier_out = attr.evolve( - identifier, **identifier.get_changes_to_set_next_empty("front_door") - ) - assert ( - str(identifier_out) - == f"{TEST_FRIGATE_INSTANCE_ID}/recordings/2021-06/04/00/front_door" - ) async def test_event_identifier() -> None: @@ -1335,13 +1258,16 @@ async def test_snapshots(hass: HomeAssistant) -> None: ], } - assert client.async_get_event_summary.call_args == call(has_snapshot=True) + assert client.async_get_event_summary.call_args == call( + has_snapshot=True, timezone="US/Pacific" + ) assert client.async_get_events.call_args == call( after=1622764800, before=1622851200, - camera="front_door", - label="person", - zone=None, + cameras=["front_door"], + labels=["person"], + sub_labels=None, + zones=None, limit=50, has_snapshot=True, ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 8f4a5225..6baeacce 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -29,7 +29,7 @@ ICON_SERVER, ICON_SPEEDOMETER, ) -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util @@ -44,10 +44,14 @@ TEST_SENSOR_FRIGATE_STATUS_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_ALL_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_CAMERA_FPS_ENTITY_ID, + TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE, + TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE, TEST_SENSOR_FRONT_DOOR_DETECTION_FPS_ENTITY_ID, + TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE, TEST_SENSOR_FRONT_DOOR_PERSON_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_PROCESS_FPS_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_SKIPPED_FPS_ENTITY_ID, + TEST_SENSOR_GPU_LOAD_ENTITY_ID, TEST_SENSOR_STEPS_ALL_ENTITY_ID, TEST_SENSOR_STEPS_PERSON_ENTITY_ID, TEST_SERVER_VERSION, @@ -405,6 +409,91 @@ async def test_camera_fps_sensor(hass: HomeAssistant) -> None: assert entity_state.state == "unknown" +async def test_camera_cpu_usage_sensor(hass: HomeAssistant) -> None: + """Test CameraProcessCpuSensor state.""" + + client = create_mock_frigate_client() + await setup_mock_frigate_config_entry(hass, client=client) + await enable_and_load_entity(hass, client, TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE) + await enable_and_load_entity(hass, client, TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE) + await enable_and_load_entity(hass, client, TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE) + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE) + assert entity_state + assert entity_state.state == "3.0" + assert entity_state.attributes["icon"] == ICON_CORAL + assert entity_state.attributes["unit_of_measurement"] == PERCENTAGE + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE) + assert entity_state + assert entity_state.state == "5.0" + assert entity_state.attributes["icon"] == ICON_CORAL + assert entity_state.attributes["unit_of_measurement"] == PERCENTAGE + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE) + assert entity_state + assert entity_state.state == "15.0" + assert entity_state.attributes["icon"] == ICON_CORAL + assert entity_state.attributes["unit_of_measurement"] == PERCENTAGE + + stats: dict[str, Any] = copy.deepcopy(TEST_STATS) + client.async_get_stats = AsyncMock(return_value=stats) + + stats["cpu_usages"]["52"] = {"cpu": None, "mem": None} + stats["cpu_usages"]["53"] = {"cpu": None, "mem": None} + stats["cpu_usages"]["54"] = {"cpu": None, "mem": None} + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE) + assert entity_state + assert entity_state.state == "unknown" + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE) + assert entity_state + assert entity_state.state == "unknown" + + entity_state = hass.states.get(TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE) + assert entity_state + assert entity_state.state == "unknown" + + +async def test_gpu_usage_sensor(hass: HomeAssistant) -> None: + """Test CameraProcessCpuSensor state.""" + + client = create_mock_frigate_client() + await setup_mock_frigate_config_entry(hass, client=client) + await enable_and_load_entity(hass, client, TEST_SENSOR_GPU_LOAD_ENTITY_ID) + + entity_state = hass.states.get(TEST_SENSOR_GPU_LOAD_ENTITY_ID) + assert entity_state + assert entity_state.state == "19.0" + assert entity_state.attributes["icon"] == ICON_SPEEDOMETER + assert entity_state.attributes["unit_of_measurement"] == PERCENTAGE + + stats: dict[str, Any] = copy.deepcopy(TEST_STATS) + client.async_get_stats = AsyncMock(return_value=stats) + + stats["gpu_usages"]["Nvidia GeForce RTX 3050"] = {"gpu": -1, "mem": -1} + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_GPU_LOAD_ENTITY_ID) + assert entity_state + assert entity_state.state == "unknown" + + stats["gpu_usages"]["Nvidia GeForce RTX 3050"] = { + "gpu": "not a number", + "mem": "not a number", + } + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_SENSOR_GPU_LOAD_ENTITY_ID) + assert entity_state + assert entity_state.state == "unknown" + + @pytest.mark.parametrize( "entityid_to_uniqueid", [ @@ -468,7 +557,10 @@ async def test_sensors_setup_correctly_in_registry( entities_disabled={ TEST_SENSOR_DETECTION_FPS_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_CAMERA_FPS_ENTITY_ID, + TEST_SENSOR_FRONT_DOOR_CAPTURE_CPU_USAGE, + TEST_SENSOR_FRONT_DOOR_DETECT_CPU_USAGE, TEST_SENSOR_FRONT_DOOR_DETECTION_FPS_ENTITY_ID, + TEST_SENSOR_FRONT_DOOR_FFMPEG_CPU_USAGE, TEST_SENSOR_FRONT_DOOR_PROCESS_FPS_ENTITY_ID, TEST_SENSOR_FRONT_DOOR_SKIPPED_FPS_ENTITY_ID, TEST_SENSOR_CPU1_INTFERENCE_SPEED_ENTITY_ID, diff --git a/tests/test_views.py b/tests/test_views.py index 8af5b4eb..eafcae44 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -158,8 +158,12 @@ async def handler(request: web.Request) -> web.Response: web.get("/api/events/1577854800.123456-random/snapshot.jpg", handler), web.get("/api/events/1635807600.123456-random/snapshot.jpg", handler), web.get("/api/events/1635807359.123456-random/snapshot.jpg", handler), - web.get("/live/front_door", ws_echo_handler), - web.get("/live/querystring", ws_qs_echo_handler), + web.get("/live/jsmpeg/front_door", ws_echo_handler), + web.get("/live/jsmpeg/querystring", ws_qs_echo_handler), + web.get("/live/mse/front_door", ws_echo_handler), + web.get("/live/mse/querystring", ws_qs_echo_handler), + web.get("/live/webrtc/front_door", ws_echo_handler), + web.get("/live/webrtc/querystring", ws_qs_echo_handler), web.get( "/api/front_door/start/1664067600.02/end/1664068200.03/clip.mp4", handler, @@ -797,33 +801,52 @@ async def test_jsmpeg_frame_type_ping_pong( assert result[1].data == b"\x00\x01" -async def test_ws_proxy_specify_protocol( +async def test_jsmpeg_connection_reset( local_frigate: Any, hass_client: Any, ) -> None: - """Test websocket proxy handles the SEC_WEBSOCKET_PROTOCOL header.""" + """Test JSMPEG proxying handles connection resets.""" + + # Tricky: This test is intended to test a ConnectionResetError to the + # Frigate server, which is the _second_ call to send*. The first call (from + # this test) needs to succeed. + real_send_str = views.aiohttp.web.WebSocketResponse.send_str + + called_once = False + + async def send_str(*args: Any, **kwargs: Any) -> None: + nonlocal called_once + if called_once: + raise ConnectionResetError + else: + called_once = True + return await real_send_str(*args, **kwargs) authenticated_hass_client = await hass_client() - ws = await authenticated_hass_client.ws_connect( - f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/front_door", - headers={hdrs.SEC_WEBSOCKET_PROTOCOL: "foo,bar"}, - ) - assert ws - await ws.close() + with patch( + "custom_components.frigate.views.aiohttp.ClientWebSocketResponse.send_str", + new=send_str, + ): + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/front_door" + ) as ws: + await ws.send_str("data") -async def test_ws_proxy_query_string( +async def test_mse_text_binary( local_frigate: Any, + hass: Any, hass_client: Any, ) -> None: - """Test websocket proxy passes on the querystring.""" + """Test JSMPEG proxying text/binary data.""" authenticated_hass_client = await hass_client() async with authenticated_hass_client.ws_connect( - f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/querystring?key=value", + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/mse/front_door" ) as ws: + # Test sending text data. result = await asyncio.gather( ws.send_str("hello!"), ws.receive(), @@ -831,8 +854,39 @@ async def test_ws_proxy_query_string( assert result[1].type == aiohttp.WSMsgType.TEXT assert result[1].data == "hello!" + # Test sending binary data. + result = await asyncio.gather( + ws.send_bytes(b"\x00\x01"), + ws.receive(), + ) -async def test_jsmpeg_connection_reset( + assert result[1].type == aiohttp.WSMsgType.BINARY + assert result[1].data == b"\x00\x01" + + +async def test_mse_frame_type_ping_pong( + local_frigate: Any, + hass_client: Any, +) -> None: + """Test JSMPEG proxying handles ping-pong.""" + + authenticated_hass_client = await hass_client() + + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/mse/front_door" + ) as ws: + await ws.ping() + + # Push some data through after the ping. + result = await asyncio.gather( + ws.send_bytes(b"\x00\x01"), + ws.receive(), + ) + assert result[1].type == aiohttp.WSMsgType.BINARY + assert result[1].data == b"\x00\x01" + + +async def test_mse_connection_reset( local_frigate: Any, hass_client: Any, ) -> None: @@ -860,11 +914,131 @@ async def send_str(*args: Any, **kwargs: Any) -> None: new=send_str, ): async with authenticated_hass_client.ws_connect( - f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/front_door" + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/mse/front_door" + ) as ws: + await ws.send_str("data") + + +async def test_webrtc_text_binary( + local_frigate: Any, + hass: Any, + hass_client: Any, +) -> None: + """Test JSMPEG proxying text/binary data.""" + + authenticated_hass_client = await hass_client() + + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/webrtc/front_door" + ) as ws: + # Test sending text data. + result = await asyncio.gather( + ws.send_str("hello!"), + ws.receive(), + ) + assert result[1].type == aiohttp.WSMsgType.TEXT + assert result[1].data == "hello!" + + # Test sending binary data. + result = await asyncio.gather( + ws.send_bytes(b"\x00\x01"), + ws.receive(), + ) + + assert result[1].type == aiohttp.WSMsgType.BINARY + assert result[1].data == b"\x00\x01" + + +async def test_webrtc_frame_type_ping_pong( + local_frigate: Any, + hass_client: Any, +) -> None: + """Test JSMPEG proxying handles ping-pong.""" + + authenticated_hass_client = await hass_client() + + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/webrtc/front_door" + ) as ws: + await ws.ping() + + # Push some data through after the ping. + result = await asyncio.gather( + ws.send_bytes(b"\x00\x01"), + ws.receive(), + ) + assert result[1].type == aiohttp.WSMsgType.BINARY + assert result[1].data == b"\x00\x01" + + +async def test_webrtc_connection_reset( + local_frigate: Any, + hass_client: Any, +) -> None: + """Test JSMPEG proxying handles connection resets.""" + + # Tricky: This test is intended to test a ConnectionResetError to the + # Frigate server, which is the _second_ call to send*. The first call (from + # this test) needs to succeed. + real_send_str = views.aiohttp.web.WebSocketResponse.send_str + + called_once = False + + async def send_str(*args: Any, **kwargs: Any) -> None: + nonlocal called_once + if called_once: + raise ConnectionResetError + else: + called_once = True + return await real_send_str(*args, **kwargs) + + authenticated_hass_client = await hass_client() + + with patch( + "custom_components.frigate.views.aiohttp.ClientWebSocketResponse.send_str", + new=send_str, + ): + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/webrtc/front_door" ) as ws: await ws.send_str("data") +async def test_ws_proxy_specify_protocol( + local_frigate: Any, + hass_client: Any, +) -> None: + """Test websocket proxy handles the SEC_WEBSOCKET_PROTOCOL header.""" + + authenticated_hass_client = await hass_client() + + ws = await authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/front_door", + headers={hdrs.SEC_WEBSOCKET_PROTOCOL: "foo,bar"}, + ) + assert ws + await ws.close() + + +async def test_ws_proxy_query_string( + local_frigate: Any, + hass_client: Any, +) -> None: + """Test websocket proxy passes on the querystring.""" + + authenticated_hass_client = await hass_client() + + async with authenticated_hass_client.ws_connect( + f"/api/frigate/{TEST_FRIGATE_INSTANCE_ID}/jsmpeg/querystring?key=value", + ) as ws: + result = await asyncio.gather( + ws.send_str("hello!"), + ws.receive(), + ) + assert result[1].type == aiohttp.WSMsgType.TEXT + assert result[1].data == "hello!" + + async def test_ws_proxy_bad_instance_id( local_frigate: Any, hass_client: Any, diff --git a/tests/test_ws_api.py b/tests/test_ws_api.py index a452a3e2..474e2505 100644 --- a/tests/test_ws_api.py +++ b/tests/test_ws_api.py @@ -19,6 +19,7 @@ TEST_CAMERA = "front_door" TEST_EVENT_ID = "1656282822.206673-bovnfg" TEST_LABEL = "person" +TEST_SUB_LABEL = "mr-frigate" TEST_ZONE = "steps" @@ -131,7 +132,7 @@ async def test_get_recordings_success(hass: HomeAssistant, hass_ws_client: Any) await setup_mock_frigate_config_entry(hass, client=mock_client) ws_client = await hass_ws_client() - recording_json = { + recording_json: dict[str, Any] = { "id": 1, "type": "frigate/recordings/summary", "instance_id": TEST_FRIGATE_INSTANCE_ID, @@ -140,11 +141,11 @@ async def test_get_recordings_success(hass: HomeAssistant, hass_ws_client: Any) recording_success = {"recording": "summary"} mock_client.async_get_recordings_summary = AsyncMock(return_value=recording_success) - await ws_client.send_json(recording_json) + await ws_client.send_json({**recording_json, "timezone": "Europe/Dublin"}) response = await ws_client.receive_json() mock_client.async_get_recordings_summary.assert_called_with( - TEST_CAMERA, decode_json=False + TEST_CAMERA, "Europe/Dublin", decode_json=False ) assert response["success"] assert response["result"] == recording_success @@ -253,14 +254,16 @@ async def test_get_events_success(hass: HomeAssistant, hass_ws_client: Any) -> N "id": 1, "type": "frigate/events/get", "instance_id": TEST_FRIGATE_INSTANCE_ID, - "camera": TEST_CAMERA, - "label": TEST_LABEL, - "zone": TEST_ZONE, + "cameras": [TEST_CAMERA], + "labels": [TEST_LABEL], + "sub_labels": [TEST_SUB_LABEL], + "zones": [TEST_ZONE], "after": 1, "before": 2, "limit": 3, "has_clip": True, "has_snapshot": True, + "favorites": True, } events_success = {"events": "summary"} @@ -269,7 +272,17 @@ async def test_get_events_success(hass: HomeAssistant, hass_ws_client: Any) -> N response = await ws_client.receive_json() mock_client.async_get_events.assert_called_with( - TEST_CAMERA, TEST_LABEL, TEST_ZONE, 1, 2, 3, True, True, decode_json=False + [TEST_CAMERA], + [TEST_LABEL], + [TEST_SUB_LABEL], + [TEST_ZONE], + 1, + 2, + 3, + True, + True, + True, + decode_json=False, ) assert response["success"] assert response["result"] == events_success @@ -287,7 +300,7 @@ async def test_get_events_instance_not_found( "id": 1, "type": "frigate/events/get", "instance_id": "THIS-IS-NOT-A-REAL-INSTANCE-ID", - "camera": TEST_CAMERA, + "cameras": [TEST_CAMERA], } await ws_client.send_json(events_json) @@ -307,7 +320,7 @@ async def test_get_events_api_error(hass: HomeAssistant, hass_ws_client: Any) -> "id": 1, "type": "frigate/events/get", "instance_id": TEST_FRIGATE_INSTANCE_ID, - "camera": TEST_CAMERA, + "cameras": [TEST_CAMERA], } mock_client.async_get_events = AsyncMock(side_effect=FrigateApiClientError) @@ -333,6 +346,7 @@ async def test_get_events_summary_success( "instance_id": TEST_FRIGATE_INSTANCE_ID, "has_clip": True, "has_snapshot": True, + "timezone": "US/Pacific", } events_summary_success = {"events": "summary"} @@ -341,7 +355,7 @@ async def test_get_events_summary_success( response = await ws_client.receive_json() mock_client.async_get_event_summary.assert_called_with( - True, True, decode_json=False + True, True, "US/Pacific", decode_json=False ) assert response["success"] assert response["result"] == events_summary_success