Home Assistant Git Exporter
This commit is contained in:
465
config/custom_components/frigate/camera.py
Normal file
465
config/custom_components/frigate/camera.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Support for Frigate cameras."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from jinja2 import Template
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from custom_components.frigate.api import FrigateApiClient
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType
|
||||
from homeassistant.components.mqtt import async_publish
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import (
|
||||
FrigateDataUpdateCoordinator,
|
||||
FrigateEntity,
|
||||
FrigateMQTTEntity,
|
||||
ReceiveMessage,
|
||||
decode_if_necessary,
|
||||
get_friendly_name,
|
||||
get_frigate_device_identifier,
|
||||
get_frigate_entity_unique_id,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_CLIENT,
|
||||
ATTR_CONFIG,
|
||||
ATTR_COORDINATOR,
|
||||
ATTR_END_TIME,
|
||||
ATTR_EVENT_ID,
|
||||
ATTR_FAVORITE,
|
||||
ATTR_PLAYBACK_FACTOR,
|
||||
ATTR_PTZ_ACTION,
|
||||
ATTR_PTZ_ARGUMENT,
|
||||
ATTR_START_TIME,
|
||||
CONF_ENABLE_WEBRTC,
|
||||
CONF_RTMP_URL_TEMPLATE,
|
||||
CONF_RTSP_URL_TEMPLATE,
|
||||
DEVICE_CLASS_CAMERA,
|
||||
DOMAIN,
|
||||
NAME,
|
||||
SERVICE_EXPORT_RECORDING,
|
||||
SERVICE_FAVORITE_EVENT,
|
||||
SERVICE_PTZ,
|
||||
)
|
||||
from .views import get_frigate_instance_id_for_config_entry
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Camera entry setup."""
|
||||
|
||||
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
|
||||
frigate_client = hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT]
|
||||
client_id = get_frigate_instance_id_for_config_entry(hass, entry)
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
FrigateCamera(
|
||||
entry,
|
||||
cam_name,
|
||||
frigate_client,
|
||||
client_id,
|
||||
coordinator,
|
||||
frigate_config,
|
||||
camera_config,
|
||||
)
|
||||
for cam_name, camera_config in frigate_config["cameras"].items()
|
||||
]
|
||||
+ (
|
||||
[BirdseyeCamera(entry, frigate_client)]
|
||||
if frigate_config.get("birdseye", {}).get("restream", False)
|
||||
else []
|
||||
)
|
||||
)
|
||||
|
||||
# setup services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_EXPORT_RECORDING,
|
||||
{
|
||||
vol.Required(ATTR_PLAYBACK_FACTOR, default="realtime"): str,
|
||||
vol.Required(ATTR_START_TIME): str,
|
||||
vol.Required(ATTR_END_TIME): str,
|
||||
},
|
||||
SERVICE_EXPORT_RECORDING,
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_FAVORITE_EVENT,
|
||||
{
|
||||
vol.Required(ATTR_EVENT_ID): str,
|
||||
vol.Optional(ATTR_FAVORITE, default=True): bool,
|
||||
},
|
||||
SERVICE_FAVORITE_EVENT,
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_PTZ,
|
||||
{
|
||||
vol.Required(ATTR_PTZ_ACTION): str,
|
||||
vol.Optional(ATTR_PTZ_ARGUMENT, default=""): str,
|
||||
},
|
||||
SERVICE_PTZ,
|
||||
)
|
||||
|
||||
|
||||
class FrigateCamera(FrigateMQTTEntity, CoordinatorEntity, Camera): # type: ignore[misc]
|
||||
"""Representation of a Frigate camera."""
|
||||
|
||||
# sets the entity name to same as device name ex: camera.front_doorbell
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
cam_name: str,
|
||||
frigate_client: FrigateApiClient,
|
||||
frigate_client_id: Any | None,
|
||||
coordinator: FrigateDataUpdateCoordinator,
|
||||
frigate_config: dict[str, Any],
|
||||
camera_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize a Frigate camera."""
|
||||
self._client = frigate_client
|
||||
self._client_id = frigate_client_id
|
||||
self._frigate_config = frigate_config
|
||||
self._camera_config = camera_config
|
||||
self._cam_name = cam_name
|
||||
super().__init__(
|
||||
config_entry,
|
||||
frigate_config,
|
||||
{
|
||||
"state_topic": {
|
||||
"msg_callback": self._state_message_received,
|
||||
"qos": 0,
|
||||
"topic": (
|
||||
f"{self._frigate_config['mqtt']['topic_prefix']}"
|
||||
f"/{self._cam_name}/recordings/state"
|
||||
),
|
||||
"encoding": None,
|
||||
},
|
||||
"motion_topic": {
|
||||
"msg_callback": self._motion_message_received,
|
||||
"qos": 0,
|
||||
"topic": (
|
||||
f"{self._frigate_config['mqtt']['topic_prefix']}"
|
||||
f"/{self._cam_name}/motion/state"
|
||||
),
|
||||
"encoding": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
FrigateEntity.__init__(self, config_entry)
|
||||
CoordinatorEntity.__init__(self, coordinator)
|
||||
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._stream_source = None
|
||||
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"
|
||||
)
|
||||
self._ptz_topic = (
|
||||
f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/ptz"
|
||||
)
|
||||
self._set_motion_topic = (
|
||||
f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/motion/set"
|
||||
)
|
||||
|
||||
if (
|
||||
self._cam_name
|
||||
in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys()
|
||||
):
|
||||
if config_entry.options.get(CONF_ENABLE_WEBRTC, False):
|
||||
self._restream_type = "webrtc"
|
||||
self._attr_frontend_stream_type = StreamType.WEB_RTC
|
||||
else:
|
||||
self._restream_type = "rtsp"
|
||||
self._attr_frontend_stream_type = StreamType.HLS
|
||||
|
||||
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._restream_type = "none"
|
||||
|
||||
@callback # type: ignore[misc]
|
||||
def _state_message_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle a new received MQTT state message."""
|
||||
self._attr_is_recording = decode_if_necessary(msg.payload) == "ON"
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback # type: ignore[misc]
|
||||
def _motion_message_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle a new received MQTT extra message."""
|
||||
self._attr_motion_detection_enabled = decode_if_necessary(msg.payload) == "ON"
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Signal when frigate loses connection to camera."""
|
||||
if self.coordinator.data:
|
||||
if (
|
||||
self.coordinator.data.get("cameras", {})
|
||||
.get(self._cam_name, {})
|
||||
.get("camera_fps", 0)
|
||||
== 0
|
||||
):
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@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",
|
||||
self._cam_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return the 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._url}/cameras/{self._cam_name}",
|
||||
"manufacturer": NAME,
|
||||
}
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str]:
|
||||
"""Return entity specific state attributes."""
|
||||
return {
|
||||
"client_id": str(self._client_id),
|
||||
"camera_name": self._cam_name,
|
||||
"restream_type": self._restream_type,
|
||||
}
|
||||
|
||||
@property
|
||||
def supported_features(self) -> CameraEntityFeature:
|
||||
"""Return supported features of this camera."""
|
||||
if not self._attr_is_streaming:
|
||||
return CameraEntityFeature(0)
|
||||
|
||||
return 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)
|
||||
/ f"api/{self._cam_name}/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."""
|
||||
if not self._attr_is_streaming:
|
||||
return None
|
||||
return self._stream_source
|
||||
|
||||
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||
"""Handle the WebRTC offer and return an answer."""
|
||||
websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass))
|
||||
url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}"
|
||||
payload = {"type": "offer", "sdp": offer_sdp}
|
||||
async with websession.post(url, json=payload) as resp:
|
||||
answer = await resp.json()
|
||||
return cast(str, answer["sdp"])
|
||||
|
||||
async def async_enable_motion_detection(self) -> None:
|
||||
"""Enable motion detection for this camera."""
|
||||
await async_publish(
|
||||
self.hass,
|
||||
self._set_motion_topic,
|
||||
"ON",
|
||||
0,
|
||||
False,
|
||||
)
|
||||
|
||||
async def async_disable_motion_detection(self) -> None:
|
||||
"""Disable motion detection for this camera."""
|
||||
await async_publish(
|
||||
self.hass,
|
||||
self._set_motion_topic,
|
||||
"OFF",
|
||||
0,
|
||||
False,
|
||||
)
|
||||
|
||||
async def export_recording(
|
||||
self, playback_factor: str, start_time: str, end_time: str
|
||||
) -> None:
|
||||
"""Export recording."""
|
||||
await self._client.async_export_recording(
|
||||
self._cam_name,
|
||||
playback_factor,
|
||||
datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S").timestamp(),
|
||||
datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S").timestamp(),
|
||||
)
|
||||
|
||||
async def favorite_event(self, event_id: str, favorite: bool) -> None:
|
||||
"""Favorite an event."""
|
||||
await self._client.async_retain(event_id, favorite)
|
||||
|
||||
async def ptz(self, action: str, argument: str) -> None:
|
||||
"""Run PTZ command."""
|
||||
await async_publish(
|
||||
self.hass,
|
||||
self._ptz_topic,
|
||||
f"{action}{f'_{argument}' if argument else ''}",
|
||||
0,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
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) -> CameraEntityFeature:
|
||||
"""Return supported features of this camera."""
|
||||
return 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
|
||||
Reference in New Issue
Block a user