Home Assistant Git Exporter

This commit is contained in:
root
2024-05-31 13:07:35 +02:00
parent 64a0536537
commit 60abdd866c
275 changed files with 71113 additions and 1 deletions
@@ -0,0 +1,483 @@
"""
Custom integration to integrate frigate with Home Assistant.
For more details about this integration, please refer to
https://github.com/blakeblackshear/frigate-hass-integration
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
import logging
import re
from typing import Any, Final
from awesomeversion import AwesomeVersion
from custom_components.frigate.config_flow import get_config_entry_title
from homeassistant.components.mqtt.models import ReceiveMessage
from homeassistant.components.mqtt.subscription import (
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_URL
from homeassistant.core import Config, HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
from .api import FrigateApiClient, FrigateApiClientError
from .const import (
ATTR_CLIENT,
ATTR_CONFIG,
ATTR_COORDINATOR,
ATTRIBUTE_LABELS,
CONF_CAMERA_STATIC_IMAGE_HEIGHT,
DOMAIN,
FRIGATE_RELEASES_URL,
FRIGATE_VERSION_ERROR_CUTOFF,
NAME,
PLATFORMS,
STARTUP_MESSAGE,
STATUS_ERROR,
STATUS_RUNNING,
STATUS_STARTING,
)
from .views import async_setup as views_async_setup
from .ws_api import async_setup as ws_api_async_setup
SCAN_INTERVAL = timedelta(seconds=5)
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Typing notes:
# - The HomeAssistant library does not provide usable type hints for custom
# components. Certain type checks (e.g. decorators and class inheritance) need
# to be marked as ignored or casted, when using the default Home Assistant
# mypy settings. Using the same settings is preferable, to smoothen a future
# migration to Home Assistant Core.
def get_frigate_device_identifier(
entry: ConfigEntry, camera_name: str | None = None
) -> tuple[str, str]:
"""Get a device identifier."""
if camera_name:
return (DOMAIN, f"{entry.entry_id}:{slugify(camera_name)}")
return (DOMAIN, entry.entry_id)
def get_frigate_entity_unique_id(
config_entry_id: str, type_name: str, name: str
) -> str:
"""Get the unique_id for a Frigate entity."""
return f"{config_entry_id}:{type_name}:{name}"
def get_friendly_name(name: str) -> str:
"""Get a friendly version of a name."""
return name.replace("_", " ").title()
def get_cameras(config: dict[str, Any]) -> set[str]:
"""Get cameras."""
cameras = set()
for cam_name, _ in config["cameras"].items():
cameras.add(cam_name)
return cameras
def get_cameras_and_objects(
config: dict[str, Any], include_all: bool = True
) -> set[tuple[str, str]]:
"""Get cameras and tracking object tuples."""
camera_objects = set()
for cam_name, cam_config in config["cameras"].items():
for obj in cam_config["objects"]["track"]:
if obj not in ATTRIBUTE_LABELS:
camera_objects.add((cam_name, obj))
# add an artificial all label to track
# all objects for this camera
if include_all:
camera_objects.add((cam_name, "all"))
return camera_objects
def get_cameras_and_audio(config: dict[str, Any]) -> set[tuple[str, str]]:
"""Get cameras and audio tuples."""
camera_audio = set()
for cam_name, cam_config in config["cameras"].items():
if cam_config.get("audio", {}).get("enabled_in_config", False):
for audio in cam_config.get("audio", {}).get("listen", []):
camera_audio.add((cam_name, audio))
return camera_audio
def get_cameras_zones_and_objects(config: dict[str, Any]) -> set[tuple[str, str]]:
"""Get cameras/zones and tracking object tuples."""
camera_objects = get_cameras_and_objects(config)
zone_objects = set()
for cam_name, obj in camera_objects:
for zone_name in config["cameras"][cam_name]["zones"]:
zone_name_objects = config["cameras"][cam_name]["zones"][zone_name].get(
"objects"
)
if not zone_name_objects or obj in zone_name_objects:
zone_objects.add((zone_name, obj))
# add an artificial all label to track
# all objects for this zone
zone_objects.add((zone_name, "all"))
return camera_objects.union(zone_objects)
def get_cameras_and_zones(config: dict[str, Any]) -> set[str]:
"""Get cameras and zones."""
cameras_zones = set()
for camera in config.get("cameras", {}).keys():
cameras_zones.add(camera)
for zone in config["cameras"][camera].get("zones", {}).keys():
cameras_zones.add(zone)
return cameras_zones
def get_zones(config: dict[str, Any]) -> set[str]:
"""Get zones."""
cameras_zones = set()
for camera in config.get("cameras", {}).keys():
for zone in config["cameras"][camera].get("zones", {}).keys():
cameras_zones.add(zone)
return cameras_zones
def decode_if_necessary(data: str | bytes) -> str:
"""Decode a string if necessary."""
return data.decode("utf-8") if isinstance(data, bytes) else data
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up this integration using YAML is not supported."""
integration = await async_get_integration(hass, DOMAIN)
_LOGGER.info(
STARTUP_MESSAGE,
NAME,
integration.version,
)
hass.data.setdefault(DOMAIN, {})
ws_api_async_setup(hass)
views_async_setup(hass)
return True
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),
)
coordinator = FrigateDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh()
try:
server_version = await client.async_get_version()
config = await client.async_get_config()
except FrigateApiClientError as exc:
raise ConfigEntryNotReady from exc
if AwesomeVersion(server_version.split("-")[0]) <= AwesomeVersion(
FRIGATE_VERSION_ERROR_CUTOFF
):
_LOGGER.error(
"Using a Frigate server (%s) with version %s <= %s which is not "
"compatible -- you must upgrade: %s",
entry.data[CONF_URL],
server_version,
FRIGATE_VERSION_ERROR_CUTOFF,
FRIGATE_RELEASES_URL,
)
return False
model = f"{(await async_get_integration(hass, DOMAIN)).version}/{server_version}"
hass.data[DOMAIN][entry.entry_id] = {
ATTR_COORDINATOR: coordinator,
ATTR_CLIENT: client,
ATTR_CONFIG: config,
ATTR_MODEL: model,
}
# Remove old devices associated with cameras that have since been removed
# from the Frigate server, keeping the 'master' device for this config
# entry.
current_devices: set[tuple[str, str]] = set({get_frigate_device_identifier(entry)})
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
):
for identifier in device_entry.identifiers:
if identifier in current_devices:
break
else:
device_registry.async_remove_device(device_entry.id)
# Cleanup old clips switch (<v0.9.0) if it exists.
entity_registry = er.async_get(hass)
for camera in config["cameras"].keys():
unique_id = get_frigate_entity_unique_id(
entry.entry_id, SWITCH_DOMAIN, f"{camera}_clips"
)
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, unique_id
)
if entity_id:
entity_registry.async_remove(entity_id)
# Remove old `camera_image_height` option.
if CONF_CAMERA_STATIC_IMAGE_HEIGHT in entry.options:
new_options = entry.options.copy()
new_options.pop(CONF_CAMERA_STATIC_IMAGE_HEIGHT)
hass.config_entries.async_update_entry(entry, options=new_options)
# Cleanup object_motion sensors (replaced with occupancy sensors).
for cam_name, obj_name in get_cameras_zones_and_objects(config):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"motion_sensor",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, unique_id
)
if entity_id:
entity_registry.async_remove(entity_id)
# Cleanup camera snapshot entities (replaced with image entities).
for cam_name, obj_name in get_cameras_and_objects(config, False):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"camera_snapshots",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id("camera", DOMAIN, unique_id)
if entity_id:
entity_registry.async_remove(entity_id)
# Rename / change ID of object count sensors.
for cam_name, obj_name in get_cameras_zones_and_objects(config):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"sensor_object_count",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
new_id = f"sensor.{slugify(cam_name)}_{slugify(obj_name)}_count"
if (
entity_id
and entity_id != new_id
and valid_entity_id(new_id)
and not entity_registry.async_get(new_id)
):
new_name = f"{get_friendly_name(cam_name)} {obj_name} Count".title()
entity_registry.async_update_entity(
entity_id=entity_id,
new_entity_id=new_id,
name=new_name,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
return True
class FrigateDataUpdateCoordinator(DataUpdateCoordinator): # type: ignore[misc]
"""Class to manage fetching data from the API."""
def __init__(self, hass: HomeAssistant, client: FrigateApiClient):
"""Initialize."""
self._api = client
self.server_status: str = STATUS_STARTING
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
try:
stats = await self._api.async_get_stats()
self.server_status = STATUS_RUNNING
return stats
except FrigateApiClientError as exc:
self.server_status = STATUS_ERROR
raise UpdateFailed from exc
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
unload_ok = bool(
await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle entry updates."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate from v1 entry."""
if config_entry.version == 1:
_LOGGER.debug("Migrating config entry from version '%s'", config_entry.version)
data = {**config_entry.data}
data[CONF_URL] = data.pop(CONF_HOST)
hass.config_entries.async_update_entry(
config_entry, data=data, title=get_config_entry_title(data[CONF_URL])
)
config_entry.version = 2
@callback # type: ignore[misc]
def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
converters: Final[dict[re.Pattern, Callable[[re.Match], list[str]]]] = {
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)_binary_sensor$"): lambda m: [
"occupancy_sensor",
m.group("cam_obj"),
],
re.compile(rf"^{DOMAIN}_(?P<cam>\S+)_camera$"): lambda m: [
"camera",
m.group("cam"),
],
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)_snapshot$"): lambda m: [
"camera_snapshots",
m.group("cam_obj"),
],
re.compile(rf"^{DOMAIN}_detection_fps$"): lambda m: [
"sensor_fps",
"detection",
],
re.compile(
rf"^{DOMAIN}_(?P<detector>\S+)_inference_speed$"
): lambda m: ["sensor_detector_speed", m.group("detector")],
re.compile(rf"^{DOMAIN}_(?P<cam_fps>\S+)_fps$"): lambda m: [
"sensor_fps",
m.group("cam_fps"),
],
re.compile(rf"^{DOMAIN}_(?P<cam_switch>\S+)_switch$"): lambda m: [
"switch",
m.group("cam_switch"),
],
# Caution: This is a broad but necessary match (keep until last).
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)$"): lambda m: [
"sensor_object_count",
m.group("cam_obj"),
],
}
for regexp, func in converters.items():
match = regexp.match(entity_entry.unique_id)
if match:
args = [config_entry.entry_id] + func(match)
return {"new_unique_id": get_frigate_entity_unique_id(*args)}
return None
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
_LOGGER.debug(
"Migrating config entry to version '%s' successful", config_entry.version
)
return True
class FrigateEntity(Entity): # type: ignore[misc]
"""Base class for Frigate entities."""
_attr_has_entity_name = True
def __init__(self, config_entry: ConfigEntry):
"""Construct a FrigateEntity."""
Entity.__init__(self)
self._config_entry = config_entry
self._available = True
@property
def available(self) -> bool:
"""Return the availability of the entity."""
return self._available and super().available
def _get_model(self) -> str:
"""Get the Frigate device model string."""
return str(self.hass.data[DOMAIN][self._config_entry.entry_id][ATTR_MODEL])
class FrigateMQTTEntity(FrigateEntity):
"""Base class for MQTT-based Frigate entities."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
topic_map: dict[str, Any],
) -> None:
"""Construct a FrigateMQTTEntity."""
super().__init__(config_entry)
self._frigate_config = frigate_config
self._sub_state = None
self._available = False
self._topic_map = topic_map
async def async_added_to_hass(self) -> None:
"""Subscribe mqtt events."""
self._topic_map["availability_topic"] = {
"topic": f"{self._frigate_config['mqtt']['topic_prefix']}/available",
"msg_callback": self._availability_message_received,
"qos": 0,
}
state = async_prepare_subscribe_topics(
self.hass,
self._sub_state,
self._topic_map,
)
self._sub_state = await async_subscribe_topics(self.hass, state)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self) -> None:
"""Cleanup prior to hass removal."""
async_unsubscribe_topics(self.hass, self._sub_state)
self._sub_state = None
await super().async_will_remove_from_hass()
@callback # type: ignore[misc]
def _availability_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT availability message."""
self._available = decode_if_necessary(msg.payload) == "online"
self.async_write_ha_state()
+264
View File
@@ -0,0 +1,264 @@
"""Frigate API client."""
from __future__ import annotations
import asyncio
import logging
import socket
from typing import Any, cast
import aiohttp
import async_timeout
from yarl import URL
TIMEOUT = 10
_LOGGER: logging.Logger = logging.getLogger(__name__)
HEADERS = {"Content-type": "application/json; charset=UTF-8"}
# ==============================================================================
# Please do not add HomeAssistant specific imports/functionality to this module,
# so that this library can be optionally moved to a different repo at a later
# date.
# ==============================================================================
class FrigateApiClientError(Exception):
"""General FrigateApiClient error."""
class FrigateApiClient:
"""Frigate API client."""
def __init__(self, host: str, session: aiohttp.ClientSession) -> None:
"""Construct API Client."""
self._host = host
self._session = session
async def async_get_version(self) -> str:
"""Get data from the API."""
return cast(
str,
await self.api_wrapper(
"get", str(URL(self._host) / "api/version"), decode_json=False
),
)
async def async_get_stats(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
dict[str, Any],
await self.api_wrapper("get", str(URL(self._host) / "api/stats")),
)
async def async_get_events(
self,
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 = {
"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(
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
URL(self._host)
/ "api/events"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
),
)
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(
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
URL(self._host)
/ "api/events/summary"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
),
)
async def async_get_config(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
dict[str, Any],
await self.api_wrapper("get", str(URL(self._host) / "api/config")),
)
async def async_get_ptz_info(
self,
camera: str,
decode_json: bool = True,
) -> Any:
"""Get PTZ info."""
return await self.api_wrapper(
"get",
str(URL(self._host) / "api" / camera / "ptz/info"),
decode_json=decode_json,
)
async def async_get_path(self, path: str) -> Any:
"""Get data from the API."""
return await self.api_wrapper("get", str(URL(self._host) / f"{path}/"))
async def async_retain(
self, event_id: str, retain: bool, decode_json: bool = True
) -> dict[str, Any] | str:
"""Un/Retain an event."""
result = await self.api_wrapper(
"post" if retain else "delete",
str(URL(self._host) / f"api/events/{event_id}/retain"),
decode_json=decode_json,
)
return cast(dict[str, Any], result) if decode_json else result
async def async_export_recording(
self,
camera: str,
playback_factor: str,
start_time: float,
end_time: float,
decode_json: bool = True,
) -> dict[str, Any] | str:
"""Export recording."""
result = await self.api_wrapper(
"post",
str(
URL(self._host)
/ f"api/export/{camera}/start/{start_time}/end/{end_time}"
),
data={"playback": playback_factor},
decode_json=decode_json,
)
return cast(dict[str, Any], result) if decode_json else result
async def async_get_recordings_summary(
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"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
)
return cast(list[dict[str, Any]], result) if decode_json else result
async def async_get_recordings(
self,
camera: str,
after: int | None = None,
before: int | None = None,
decode_json: bool = True,
) -> dict[str, Any] | str:
"""Get recordings."""
params = {
"after": after,
"before": before,
}
result = await self.api_wrapper(
"get",
str(
URL(self._host)
/ f"api/{camera}/recordings"
% {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
async def api_wrapper(
self,
method: str,
url: str,
data: dict | None = None,
headers: dict | None = None,
decode_json: bool = True,
) -> Any:
"""Get information from the API."""
if data is None:
data = {}
if headers is None:
headers = {}
try:
async with async_timeout.timeout(TIMEOUT):
func = getattr(self._session, method)
if func:
response = await func(
url, headers=headers, raise_for_status=True, json=data
)
if decode_json:
return await response.json()
return await response.text()
except asyncio.TimeoutError as exc:
_LOGGER.error(
"Timeout error fetching information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc
except (KeyError, TypeError) as exc:
_LOGGER.error(
"Error parsing information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc
except (aiohttp.ClientError, socket.gaierror) as exc:
_LOGGER.error(
"Error fetching information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc
@@ -0,0 +1,303 @@
"""Binary sensor platform for Frigate."""
from __future__ import annotations
import logging
from typing import Any, cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
decode_if_necessary,
get_cameras,
get_cameras_and_audio,
get_cameras_zones_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
get_zones,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
from .icons import get_dynamic_icon_from_type
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Binary sensor entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities = []
# add object sensors for cameras and zones
entities.extend(
[
FrigateObjectOccupancySensor(entry, frigate_config, cam_name, obj)
for cam_name, obj in get_cameras_zones_and_objects(frigate_config)
]
)
# add audio sensors for cameras
entities.extend(
[
FrigateAudioSensor(entry, frigate_config, cam_name, audio)
for cam_name, audio in get_cameras_and_audio(frigate_config)
]
)
# add generic motion sensors for cameras
entities.extend(
[
FrigateMotionSensor(entry, frigate_config, cam_name)
for cam_name in get_cameras(frigate_config)
]
)
async_add_entities(entities)
class FrigateObjectOccupancySensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Occupancy Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a new FrigateObjectOccupancySensor."""
self._cam_name = cam_name
self._obj_name = obj_name
self._is_on = False
self._frigate_config = frigate_config
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}/{self._obj_name}"
),
"encoding": None,
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._is_on = int(msg.payload) > 0
except ValueError:
self._is_on = False
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"occupancy_sensor",
f"{self._cam_name}_{self._obj_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return 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 if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._obj_name} occupancy"
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.OCCUPANCY)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return get_dynamic_icon_from_type(self._obj_name, self._is_on)
class FrigateAudioSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Audio Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
audio_name: str,
) -> None:
"""Construct a new FrigateAudioSensor."""
self._cam_name = cam_name
self._audio_name = audio_name
self._is_on = False
self._frigate_config = frigate_config
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}/audio/{self._audio_name}"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"audio_sensor",
f"{self._cam_name}_{self._audio_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return 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 name(self) -> str:
"""Return the name of the sensor."""
return f"{self._audio_name} sound"
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.SOUND)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return get_dynamic_icon_from_type("sound", self._is_on)
class FrigateMotionSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Motion Sensor class."""
_attr_name = "Motion"
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
) -> None:
"""Construct a new FrigateMotionSensor."""
self._cam_name = cam_name
self._is_on = False
self._frigate_config = frigate_config
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}/motion"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"motion_sensor",
f"{self._cam_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return 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 if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.MOTION)
+465
View 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
@@ -0,0 +1,192 @@
"""Adds config flow for Frigate."""
from __future__ import annotations
import logging
from typing import Any, Dict, cast
import voluptuous as vol
from voluptuous.validators import All, Range
from yarl import URL
from homeassistant import config_entries
from homeassistant.const import CONF_URL
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FrigateApiClient, FrigateApiClientError
from .const import (
CONF_ENABLE_WEBRTC,
CONF_MEDIA_BROWSER_ENABLE,
CONF_NOTIFICATION_PROXY_ENABLE,
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
CONF_RTMP_URL_TEMPLATE,
CONF_RTSP_URL_TEMPLATE,
DEFAULT_HOST,
DOMAIN,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
def get_config_entry_title(url_str: str) -> str:
"""Get the title of a config entry from the URL."""
# Strip the scheme from the URL as it's not that interesting in the title
# and space is limited on the integrations page.
url = URL(url_str)
return str(url)[len(url.scheme + "://") :]
class FrigateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
"""Config flow for Frigate."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Handle a flow initialized by the user."""
if user_input is None:
return self._show_config_form()
try:
# Cannot use cv.url validation in the schema itself, so
# apply extra validation here.
cv.url(user_input[CONF_URL])
except vol.Invalid:
return self._show_config_form(user_input, errors={"base": "invalid_url"})
try:
session = async_create_clientsession(self.hass)
client = FrigateApiClient(user_input[CONF_URL], session)
await client.async_get_stats()
except FrigateApiClientError:
return self._show_config_form(user_input, errors={"base": "cannot_connect"})
# Search for duplicates with the same Frigate CONF_HOST value.
for existing_entry in self._async_current_entries(include_ignore=False):
if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]:
return cast(
Dict[str, Any], self.async_abort(reason="already_configured")
)
return cast(
Dict[str, Any],
self.async_create_entry(
title=get_config_entry_title(user_input[CONF_URL]), data=user_input
),
)
def _show_config_form(
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Show the configuration form."""
if user_input is None:
user_input = {}
return cast(
Dict[str, Any],
self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_URL, default=user_input.get(CONF_URL, DEFAULT_HOST)
): str
}
),
errors=errors,
),
)
@staticmethod
@callback # type: ignore[misc]
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> FrigateOptionsFlowHandler:
"""Get the Frigate Options flow."""
return FrigateOptionsFlowHandler(config_entry)
class FrigateOptionsFlowHandler(config_entries.OptionsFlow): # type: ignore[misc]
"""Frigate options flow."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize a Frigate options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Manage the options."""
if user_input is not None:
return cast(
Dict[str, Any], self.async_create_entry(title="", data=user_input)
)
if not self.show_advanced_options:
return cast(
Dict[str, Any], self.async_abort(reason="only_advanced_options")
)
schema: dict[Any, Any] = {
# Whether to enable webrtc as the medium for camera streaming
vol.Optional(
CONF_ENABLE_WEBRTC,
default=self._config_entry.options.get(
CONF_ENABLE_WEBRTC,
False,
),
): bool,
# 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_RTMP_URL_TEMPLATE,
default=self._config_entry.options.get(
CONF_RTMP_URL_TEMPLATE,
"",
),
): 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(
CONF_NOTIFICATION_PROXY_ENABLE,
True,
),
): bool,
vol.Optional(
CONF_MEDIA_BROWSER_ENABLE,
default=self._config_entry.options.get(
CONF_MEDIA_BROWSER_ENABLE,
True,
),
): bool,
vol.Optional(
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
default=self._config_entry.options.get(
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
0,
),
): All(int, Range(min=0)),
}
return cast(
Dict[str, Any],
self.async_show_form(step_id="init", data_schema=vol.Schema(schema)),
)
+93
View File
@@ -0,0 +1,93 @@
"""Constants for frigate."""
# Base component constants
NAME = "Frigate"
DOMAIN = "frigate"
FRIGATE_VERSION_ERROR_CUTOFF = "0.12.1"
FRIGATE_RELEASES_URL = "https://github.com/blakeblackshear/frigate/releases"
FRIGATE_RELEASE_TAG_URL = f"{FRIGATE_RELEASES_URL}/tag"
# Platforms
BINARY_SENSOR = "binary_sensor"
NUMBER = "number"
SENSOR = "sensor"
SWITCH = "switch"
CAMERA = "camera"
IMAGE = "image"
UPDATE = "update"
PLATFORMS = [SENSOR, CAMERA, IMAGE, NUMBER, SWITCH, BINARY_SENSOR, UPDATE]
# Device Classes
# This device class does not exist in HA, but we use it to be able
# to filter cameras in selectors
DEVICE_CLASS_CAMERA = "camera"
# Unit of measurement
FPS = "fps"
MS = "ms"
# Attributes
ATTR_CLIENT = "client"
ATTR_CLIENT_ID = "client_id"
ATTR_CONFIG = "config"
ATTR_COORDINATOR = "coordinator"
ATTR_END_TIME = "end_time"
ATTR_EVENT_ID = "event_id"
ATTR_FAVORITE = "favorite"
ATTR_MQTT = "mqtt"
ATTR_PLAYBACK_FACTOR = "playback_factor"
ATTR_PTZ_ACTION = "action"
ATTR_PTZ_ARGUMENT = "argument"
ATTR_START_TIME = "start_time"
# Frigate Attribute Labels
# These are labels that are not individually tracked as they are
# attributes of another label. ex: face is an attribute of person
ATTRIBUTE_LABELS = ["amazon", "face", "fedex", "license_plate", "ups"]
# Configuration and options
CONF_CAMERA_STATIC_IMAGE_HEIGHT = "camera_image_height"
CONF_MEDIA_BROWSER_ENABLE = "media_browser_enable"
CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable"
CONF_PASSWORD = "password"
CONF_PATH = "path"
CONF_RTMP_URL_TEMPLATE = "rtmp_url_template"
CONF_RTSP_URL_TEMPLATE = "rtsp_url_template"
CONF_ENABLE_WEBRTC = "enable_webrtc"
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds"
# Defaults
DEFAULT_NAME = DOMAIN
DEFAULT_HOST = "http://ccab4aaf-frigate:5000"
STARTUP_MESSAGE = """
-------------------------------------------------------------------
%s
Integration Version: %s
This is a custom integration!
If you have any issues with this you need to open an issue here:
https://github.com/blakeblackshear/frigate-hass-integration/issues
-------------------------------------------------------------------
"""
# Min Values
MAX_CONTOUR_AREA = 50
MAX_THRESHOLD = 255
# Min Values
MIN_CONTOUR_AREA = 1
MIN_THRESHOLD = 1
# States
STATE_DETECTED = "active"
STATE_IDLE = "idle"
# Statuses
STATUS_ERROR = "error"
STATUS_RUNNING = "running"
STATUS_STARTING = "starting"
# Frigate Services
SERVICE_EXPORT_RECORDING = "export_recording"
SERVICE_FAVORITE_EVENT = "favorite_event"
SERVICE_PTZ = "ptz"
@@ -0,0 +1,37 @@
"""Diagnostics support for Frigate."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import ATTR_CLIENT, ATTR_CONFIG, CONF_PASSWORD, CONF_PATH, DOMAIN
REDACT_CONFIG = {CONF_PASSWORD, CONF_PATH}
def get_redacted_data(data: dict[str, Any]) -> Any:
"""Redact sensitive vales from data."""
return async_redact_data(data, REDACT_CONFIG)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
redacted_config = get_redacted_data(config)
stats = await hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT].async_get_stats()
redacted_stats = get_redacted_data(stats)
data = {
"frigate_config": redacted_config,
"frigate_stats": redacted_stats,
}
return data
+80
View File
@@ -0,0 +1,80 @@
"""Handles icons for different entity types."""
ICON_AUDIO = "mdi:ear-hearing"
ICON_AUDIO_OFF = "mdi:ear-hearing-off"
ICON_PTZ_AUTOTRACKER = "mdi:cctv"
ICON_BICYCLE = "mdi:bicycle"
ICON_CAR = "mdi:car"
ICON_CAT = "mdi:cat"
ICON_CONTRAST = "mdi:contrast-circle"
ICON_CORAL = "mdi:scoreboard-outline"
ICON_COW = "mdi:cow"
ICON_DOG = "mdi:dog-side"
ICON_FILM_MULTIPLE = "mdi:filmstrip-box-multiple"
ICON_HORSE = "mdi:horse"
ICON_IMAGE_MULTIPLE = "mdi:image-multiple"
ICON_MOTION_SENSOR = "mdi:motion-sensor"
ICON_MOTORCYCLE = "mdi:motorbike"
ICON_OTHER = "mdi:shield-alert"
ICON_PERSON = "mdi:human"
ICON_SERVER = "mdi:server"
ICON_SPEEDOMETER = "mdi:speedometer"
ICON_WAVEFORM = "mdi:waveform"
ICON_DEFAULT_ON = "mdi:home"
ICON_CAR_OFF = "mdi:car-off"
ICON_DEFAULT_OFF = "mdi:home-outline"
ICON_DOG_OFF = "mdi:dog-side-off"
def get_dynamic_icon_from_type(obj_type: str, is_on: bool) -> str:
"""Get icon for a specific object type and current state."""
if obj_type == "car":
return ICON_CAR if is_on else ICON_CAR_OFF
if obj_type == "dog":
return ICON_DOG if is_on else ICON_DOG_OFF
if obj_type == "sound":
return ICON_AUDIO if is_on else ICON_AUDIO_OFF
return ICON_DEFAULT_ON if is_on else ICON_DEFAULT_OFF
def get_icon_from_switch(switch_type: str) -> str:
"""Get icon for a specific switch type."""
if switch_type == "snapshots":
return ICON_IMAGE_MULTIPLE
if switch_type == "recordings":
return ICON_FILM_MULTIPLE
if switch_type == "improve_contrast":
return ICON_CONTRAST
if switch_type == "audio":
return ICON_AUDIO
if switch_type == "ptz_autotracker":
return ICON_PTZ_AUTOTRACKER
return ICON_MOTION_SENSOR
def get_icon_from_type(obj_type: str) -> str:
"""Get icon for a specific object type."""
if obj_type == "person":
return ICON_PERSON
if obj_type == "car":
return ICON_CAR
if obj_type == "dog":
return ICON_DOG
if obj_type == "cat":
return ICON_CAT
if obj_type == "motorcycle":
return ICON_MOTORCYCLE
if obj_type == "bicycle":
return ICON_BICYCLE
if obj_type == "cow":
return ICON_COW
if obj_type == "horse":
return ICON_HORSE
return ICON_OTHER
+123
View File
@@ -0,0 +1,123 @@
"""Support for Frigate images."""
from __future__ import annotations
import datetime
import logging
from typing import Any
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
get_cameras_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Image entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
async_add_entities(
[
FrigateMqttSnapshots(hass, entry, frigate_config, cam_name, obj_name)
for cam_name, obj_name in get_cameras_and_objects(frigate_config, False)
]
)
class FrigateMqttSnapshots(FrigateMQTTEntity, ImageEntity): # type: ignore[misc]
"""Frigate best image class."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a FrigateMqttSnapshots image."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._obj_name = obj_name
self._last_image_timestamp: datetime.datetime | None = None
self._last_image: bytes | None = None
FrigateMQTTEntity.__init__(
self,
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}/{self._obj_name}/snapshot"
),
"encoding": None,
},
},
)
ImageEntity.__init__(self, hass)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._last_image_timestamp = datetime.datetime.now()
self._last_image = msg.payload
self.async_write_ha_state()
@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,
"image_best_snapshot",
f"{self._cam_name}_{self._obj_name}",
)
@property
def device_info(self) -> DeviceInfo:
"""Get 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._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._obj_name.title()
@property
def image_last_updated(self) -> datetime.datetime | None:
"""Return timestamp of last image update."""
return self._last_image_timestamp
def image(
self,
) -> bytes | None: # pragma: no cover (HA currently does not support a direct way to test this)
"""Return bytes of image."""
return self._last_image
@@ -0,0 +1,18 @@
{
"domain": "frigate",
"name": "Frigate",
"codeowners": [
"@blakeblackshear"
],
"config_flow": true,
"dependencies": [
"http",
"media_source",
"mqtt"
],
"documentation": "https://github.com/blakeblackshear/frigate",
"iot_class": "local_push",
"issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues",
"requirements": ["pytz"],
"version": "5.2.0"
}
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
"""Number platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.mqtt import async_publish
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
get_cameras,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import (
ATTR_CONFIG,
DOMAIN,
MAX_CONTOUR_AREA,
MAX_THRESHOLD,
MIN_CONTOUR_AREA,
MIN_THRESHOLD,
NAME,
)
from .icons import ICON_SPEEDOMETER
_LOGGER: logging.Logger = logging.getLogger(__name__)
CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"]
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]
entities = []
# add motion configurations for cameras
for cam_name in get_cameras(frigate_config):
entities.extend(
[FrigateMotionContourArea(entry, frigate_config, cam_name, False)]
)
entities.extend(
[FrigateMotionThreshold(entry, frigate_config, cam_name, False)]
)
async_add_entities(entities)
class FrigateMotionContourArea(FrigateMQTTEntity, NumberEntity): # type: ignore[misc]
"""FrigateMotionContourArea class."""
_attr_entity_category = EntityCategory.CONFIG
_attr_name = "Contour area"
_attr_native_min_value = MIN_CONTOUR_AREA
_attr_native_max_value = MAX_CONTOUR_AREA
_attr_native_step = 1
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
default_enabled: bool,
) -> None:
"""Construct a FrigateNumber."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._attr_native_value = float(
self._frigate_config["cameras"][self._cam_name]["motion"]["contour_area"]
)
self._command_topic = (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_contour_area/set"
)
self._attr_entity_registry_enabled_default = default_enabled
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}/motion_contour_area/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._attr_native_value = float(msg.payload)
except (TypeError, ValueError):
pass
self.async_write_ha_state()
@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,
"number",
f"{self._cam_name}_contour_area",
)
@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,
}
async def async_set_native_value(self, value: float) -> None:
"""Update motion contour area."""
await async_publish(
self.hass,
self._command_topic,
int(value),
0,
False,
)
@property
def icon(self) -> str:
"""Return the icon of the number."""
return ICON_SPEEDOMETER
class FrigateMotionThreshold(FrigateMQTTEntity, NumberEntity): # type: ignore[misc]
"""FrigateMotionThreshold class."""
_attr_entity_category = EntityCategory.CONFIG
_attr_name = "Threshold"
_attr_native_min_value = MIN_THRESHOLD
_attr_native_max_value = MAX_THRESHOLD
_attr_native_step = 1
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
default_enabled: bool,
) -> None:
"""Construct a FrigateMotionThreshold."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._attr_native_value = float(
self._frigate_config["cameras"][self._cam_name]["motion"]["threshold"]
)
self._command_topic = (
f"{frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_threshold/set"
)
self._attr_entity_registry_enabled_default = default_enabled
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}/motion_threshold/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._attr_native_value = float(msg.payload)
except (TypeError, ValueError):
pass
self.async_write_ha_state()
@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,
"number",
f"{self._cam_name}_threshold",
)
@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,
}
async def async_set_native_value(self, value: float) -> None:
"""Update motion threshold."""
await async_publish(
self.hass,
self._command_topic,
int(value),
0,
False,
)
@property
def icon(self) -> str:
"""Return the icon of the number."""
return ICON_SPEEDOMETER
+712
View File
@@ -0,0 +1,712 @@
"""Sensor platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_URL,
PERCENTAGE,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import (
FrigateDataUpdateCoordinator,
FrigateEntity,
FrigateMQTTEntity,
ReceiveMessage,
get_cameras,
get_cameras_zones_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
get_zones,
)
from .const import ATTR_CONFIG, ATTR_COORDINATOR, DOMAIN, FPS, MS, NAME
from .icons import (
ICON_CORAL,
ICON_SERVER,
ICON_SPEEDOMETER,
ICON_WAVEFORM,
get_icon_from_type,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"]
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 = []
for key, value in coordinator.data.items():
if key == "detection_fps":
entities.append(FrigateFpsSensor(coordinator, 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 == "processes":
# don't create sensor for other processes
continue
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")
)
elif key == "cameras":
for name in value.keys():
entities.extend(
[
CameraFpsSensor(coordinator, entry, name, t)
for t in CAMERA_FPS_TYPES
]
)
if frigate_config["cameras"][name]["audio"]["enabled_in_config"]:
entities.append(CameraSoundSensor(coordinator, entry, name))
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities.extend(
[
FrigateObjectCountSensor(entry, frigate_config, cam_name, obj)
for cam_name, obj in get_cameras_zones_and_objects(frigate_config)
]
)
entities.append(FrigateStatusSensor(coordinator, entry))
async_add_entities(entities)
class FrigateFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Detection fps"
def __init__(
self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry
) -> None:
"""Construct a FrigateFpsSensor."""
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, "sensor_fps", "detection"
)
@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) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = self.coordinator.data.get("detection_fps")
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return FPS
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class FrigateStatusSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Status Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Status"
def __init__(
self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry
) -> None:
"""Construct a FrigateStatusSensor."""
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, "sensor_status", "frigate"
)
@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) -> str:
"""Return the state of the sensor."""
return str(self.coordinator.server_status)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SERVER
class DetectorSpeedSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Detector Speed class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
detector_name: str,
) -> None:
"""Construct a DetectorSpeedSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._detector_name = detector_name
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, "sensor_detector_speed", self._detector_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 name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._detector_name)} inference speed"
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("detectors", {})
.get(self._detector_name, {})
.get("inference_speed")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return MS
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
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."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
cam_name: str,
fps_type: str,
) -> None:
"""Construct a CameraFpsSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._cam_name = cam_name
self._fps_type = fps_type
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,
"sensor_fps",
f"{self._cam_name}_{self._fps_type}",
)
@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 name(self) -> str:
"""Return the name of the sensor."""
return f"{self._fps_type} fps"
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return FPS
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get(f"{self._fps_type}_fps")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class CameraSoundSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Camera Sound Level class."""
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
cam_name: str,
) -> None:
"""Construct a CameraSoundSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._cam_name = cam_name
self._attr_entity_registry_enabled_default = True
@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,
"sensor_sound_level",
f"{self._cam_name}_dB",
)
@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 name(self) -> str:
"""Return the name of the sensor."""
return "sound level"
@property
def unit_of_measurement(self) -> Any:
"""Return the unit of measurement of the sensor."""
return UnitOfSoundPressure.DECIBEL
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get("audio_dBFS")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_WAVEFORM
class FrigateObjectCountSensor(FrigateMQTTEntity):
"""Frigate Motion Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a FrigateObjectCountSensor."""
self._cam_name = cam_name
self._obj_name = obj_name
self._state = 0
self._frigate_config = frigate_config
self._icon = get_icon_from_type(self._obj_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}/{self._obj_name}"
),
"encoding": None,
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._state = int(msg.payload)
self.async_write_ha_state()
except ValueError:
pass
@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,
"sensor_object_count",
f"{self._cam_name}_{self._obj_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 if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._obj_name} count"
@property
def state(self) -> int:
"""Return true if the binary sensor is on."""
return self._state
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return "objects"
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return self._icon
class DeviceTempSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Coral Temperature Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
name: str,
) -> None:
"""Construct a CoralTempSensor."""
self._name = name
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, "sensor_temp", self._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 name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._name)} temperature"
@property
def state(self) -> float | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("service", {})
.get("temperatures", {})
.get(self._name, 0.0)
)
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 UnitOfTemperature.CELSIUS
@property
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("cameras", {})
.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
@@ -0,0 +1,103 @@
---
export_recording:
name: Export recording
description: Export a custom recording or timelapse.
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
playback_factor:
name: Playback Factor
description: Playback factor for recordings
required: true
advanced: false
example: realtime
default: realtime
selector:
select:
options:
- "realtime"
- "timelapse_25x"
start_time:
name: Export Start Time
description: Start time of exported recording
required: true
advanced: false
selector:
datetime:
end_time:
name: Export End Time
description: End time of exported recording
required: true
advanced: false
selector:
datetime:
favorite_event:
name: Favorite or unfavorite Event
description: >
Favorites or unfavorites an event. Favorited events are retained
indefinitely.
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
event_id:
name: Event ID
description: ID of the event to favorite or unfavorite.
required: true
advanced: false
example: "1656510950.19548-ihtjj7"
default: ""
selector:
text:
favorite:
name: Favorite
description: >
If the event should be favorited or unfavorited. Enable to favorite,
disable to unfavorite.
required: false
advanced: false
example: true
default: true
selector:
boolean:
ptz:
name: Control camera via PTZ
description: >
Pan / Tilt, Zoom, or move a camera to a preset
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
action:
name: PTZ Service
description: Type of PTZ action
required: true
advanced: false
example: move
default: move
selector:
select:
options:
- "move"
- "preset"
- "stop"
- "zoom"
argument:
name: PTZ Action
description: >
left, right, up, down for move; in, out for zoom; name of preset
required: false
advanced: false
example: down
default: ""
selector:
text:
+182
View File
@@ -0,0 +1,182 @@
"""Sensor platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.mqtt import async_publish
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
decode_if_necessary,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
from .icons import get_icon_from_switch
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Switch entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities = []
for camera in frigate_config["cameras"].keys():
entities.extend(
[
FrigateSwitch(entry, frigate_config, camera, "detect", True),
FrigateSwitch(entry, frigate_config, camera, "motion", True),
FrigateSwitch(entry, frigate_config, camera, "recordings", True),
FrigateSwitch(entry, frigate_config, camera, "snapshots", True),
FrigateSwitch(entry, frigate_config, camera, "improve_contrast", False),
]
)
if (
frigate_config["cameras"][camera]
.get("audio", {})
.get("enabled_in_config", False)
):
entities.append(
FrigateSwitch(
entry, frigate_config, camera, "audio", True, "audio_detection"
),
)
if (
frigate_config["cameras"][camera]
.get("onvif", {})
.get("autotracking", {})
.get("enabled_in_config", False)
):
entities.append(
FrigateSwitch(
entry,
frigate_config,
camera,
"ptz_autotracker",
True,
"ptz_autotracker",
),
)
async_add_entities(entities)
class FrigateSwitch(FrigateMQTTEntity, SwitchEntity): # type: ignore[misc]
"""Frigate Switch class."""
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
switch_name: str,
default_enabled: bool,
descriptive_name: str = "",
) -> None:
"""Construct a FrigateSwitch."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._switch_name = switch_name
self._is_on = False
self._command_topic = (
f"{frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._switch_name}/set"
)
self._descriptive_name = descriptive_name if descriptive_name else switch_name
self._attr_entity_registry_enabled_default = default_enabled
self._icon = get_icon_from_switch(self._switch_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}/{self._switch_name}/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@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,
"switch",
f"{self._cam_name}_{self._switch_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 name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._descriptive_name)}".title()
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return self._icon
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await async_publish(
self.hass,
self._command_topic,
"ON",
0,
False,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await async_publish(
self.hass,
self._command_topic,
"OFF",
0,
False,
)
@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "L'URL que utilitzeu per accedir a Frigate (p. ex. 'http://frigate:5000/')\\n\\nSi feu servir HassOS amb el complement, l'URL hauria de ser 'http://ccab4aaf-frigate:5000/' \\n\\nHome Assistant necessita accedir al port 5000 (api) i 1935 (rtmp) per a totes les funcions.\\n\\nLa integració configurarà sensors, càmeres i la funcionalitat del navegador multimèdia.\\n\\nSensors:\\n- Estadístiques per supervisar el rendiment de Frigate\\n- Recompte d'objectes per a totes les zones i càmeres\\n\\nCàmeres:\\n- Càmeres per a la imatge de l'últim objecte detectat per a cada càmera\\n- Entitats de càmera amb suport de transmissió (requereix RTMP)\\n\\nNavegador multimèdia:\\n - Interfície d'usuari enriquida amb miniatures per explorar clips d'esdeveniments\\n- Interfície d'usuari enriquida per navegar per enregistraments les 24 hores al dia, els set dies a la setmana, per mes, dia, càmera, hora\\n\\nAPI:\\n- API de notificació amb punts de connexió públics per a imatges a les notificacions.",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "No s'ha pogut connectar",
"invalid_url": "URL no vàlid"
},
"abort": {
"already_configured": "El dispositiu ja està configurat"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Activa WebRTC per als fluxos de la càmera",
"rtmp_url_template": "Plantilla de l'URL del RTMP (vegeu la documentació)",
"rtsp_url_template": "Plantilla de l'URL del RTSP (vegeu la documentació)",
"media_browser_enable": "Habiliteu el navegador multimèdia",
"notification_proxy_enable": "Habiliteu el servidor intermediari no autenticat d'esdeveniments de notificacions",
"notification_proxy_expire_after_seconds": "No permetre l'accés a notificacions no autenticades després dels segons especificats (0=mai)"
}
}
},
"abort": {
"only_advanced_options": "El mode avançat està desactivat i només hi ha opcions avançades"
}
}
}
@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL, die Sie für den Zugriff auf Frigate verwenden (z. B. \"http://frigate:5000/\")\n\nWenn Sie HassOS mit dem Addon verwenden, sollte die URL „http://ccab4aaf-frigate:5000/“ lauten\n\nHome Assistant benötigt für alle Funktionen Zugriff auf Port 5000 (api) und 1935 (rtmp).\n\nDie Integration richtet Sensoren, Kameras und Medienbrowser-Funktionen ein.\n\nSensoren:\n- Statistiken zur Überwachung der Frigate-Leistung\n- Objektzählungen für alle Zonen und Kameras\n\nKameras:\n- Kameras für Bild des zuletzt erkannten Objekts für jede Kamera\n- Kameraeinheiten mit Stream-Unterstützung (erfordert RTMP)\n\nMedienbrowser:\n- Umfangreiche Benutzeroberfläche mit Vorschaubildern zum Durchsuchen von Event-Clips\n- Umfangreiche Benutzeroberfläche zum Durchsuchen von 24/7-Aufzeichnungen nach Monat, Tag, Kamera und Uhrzeit\n\nAPI:\n- Benachrichtigungs-API mit öffentlich zugänglichen Endpunkten für Bilder in Benachrichtigungen",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_url": "Ungültige URL"
},
"abort": {
"already_configured": "Gerät ist bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"data": {
"rtmp_url_template": "RTMP-URL-Vorlage (siehe Dokumentation)",
"rtsp_url_template": "RTSP-URL-Vorlage (siehe Dokumentation)",
"media_browser_enable": "Aktivieren Sie den Medienbrowser",
"notification_proxy_enable": "Aktivieren Sie den Proxy für nicht authentifizierte Benachrichtigungsereignisse",
"notification_proxy_expire_after_seconds": "Zugriff auf nicht authentifizierte Benachrichtigungen nach Sekunden verbieten (0=nie)"
}
}
},
"abort": {
"only_advanced_options": "Der erweiterte Modus ist deaktiviert und es stehen nur erweiterte Optionen zur Verfügung"
}
}
}
@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 1935 (rtmp) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support (requires RTMP)\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_url": "Invalid URL"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Enable WebRTC for camera streams",
"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)"
}
}
},
"abort": {
"only_advanced_options": "Advanced mode is disabled and there are only advanced options"
}
}
}
@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 1935 (rtmp) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux (nécessite RTMP)\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Échec de la connexion",
"invalid_url": "URL invalide"
},
"abort": {
"already_configured": "L'appareil est déjà configuré"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Activer WebRTC pour les flux de caméra",
"rtmp_url_template": "Modèle d'URL RTMP (voir la documentation)",
"rtsp_url_template": "Modèle d'URL RTSP (voir la documentation)",
"media_browser_enable": "Activer le navigateur multimédia",
"notification_proxy_enable": "Activer le proxy d'événement de notification non authentifié",
"notification_proxy_expire_after_seconds": "Interdire l'accès à la notification non authentifiée après secondes (0=jamais)"
}
}
},
"abort": {
"only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées"
}
}
}
@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Falhou ao conectar",
"invalid_url": "URL inválida"
},
"abort": {
"already_configured": "O dispositivo já está configurado"
}
},
"options": {
"step": {
"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",
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
"media_browser_enable": "Ative o navegador de mídia"
}
}
},
"abort": {
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
}
}
}
@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Falhou ao conectar",
"invalid_url": "URL inválida"
},
"abort": {
"already_configured": "O dispositivo já está configurado"
}
},
"options": {
"step": {
"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",
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
"media_browser_enable": "Ative o navegador de mídia"
}
}
},
"abort": {
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
}
}
}
@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL, который вы используете для доступа к Frigate (например, http://frigate:5000/)\n\nЕсли вы используете HassOS с дополнением, URL должен быть http://ccab4aaf-frigate:5000/\n\nHome Assistant требуется доступ к порту 5000 (API) и 1935 (RTMP) для всех функций.\n\n Интеграция настроит сенсоры, камеры и функциональность медиа-браузера.\n\nСенсоры:\n- Статистика для отслеживания производительности Frigate\n- Количество объектов для всех зон и камер\n\nКамеры:\n- Камеры для снимка последнего обнаруженного объекта с каждой камеры\n- Камеры с поддержкой потока (требуется RTMP)\n\nМедиа-браузер:\n- Пользовательский интерфейс с миниатюрами для просмотра записей 24/7 по времени и камерам\n\nAPI:\n- API для отправки событий во внешние системы",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться",
"invalid_url": "Неверный URL"
},
"abort": {
"already_configured": "Устройство уже настроено"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Использовать WebRTC для трансляции камер",
"rtmp_url_template": "Шаблон URL для RTMP (см. документацию)",
"rtsp_url_template": "Шаблон URL для RTSP (см. документацию)",
"media_browser_enable": "Включить медиа-браузер",
"notification_proxy_enable": "Включить незащищённый прокси-сервер уведомлений",
"notification_proxy_expire_after_seconds": "Запретить неаутентифицированный доступ к уведомлениям после N секунд (0=никогда)"
}
}
},
"abort": {
"only_advanced_options": "Режим расширенных настроек отключен; доступны только основные параметры"
}
}
}
+100
View File
@@ -0,0 +1,100 @@
"""Update platform for frigate."""
from __future__ import annotations
import logging
from homeassistant.components.update import UpdateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import (
FrigateDataUpdateCoordinator,
FrigateEntity,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_COORDINATOR, DOMAIN, FRIGATE_RELEASE_TAG_URL, NAME
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Sensor entry setup."""
coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR]
entities = []
entities.append(FrigateContainerUpdate(coordinator, entry))
async_add_entities(entities)
class FrigateContainerUpdate(FrigateEntity, UpdateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate container update."""
_attr_name = "Server"
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
) -> None:
"""Construct a FrigateContainerUpdate."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
@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, "update", "frigate_server"
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"via_device": 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 installed_version(self) -> str | None:
"""Version currently in use."""
version_hash = self.coordinator.data.get("service", {}).get("version")
if not version_hash:
return None
version = str(version_hash).split("-", maxsplit=1)[0]
return version
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
version = self.coordinator.data.get("service", {}).get("latest_version")
if not version or version == "unknown" or version == "disabled":
return None
return str(version)
@property
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
if (version := self.latest_version) is None:
return None
return f"{FRIGATE_RELEASE_TAG_URL}/v{version}"
+579
View File
@@ -0,0 +1,579 @@
"""Frigate HTTP views."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import datetime
from http import HTTPStatus
from ipaddress import ip_address
import logging
from typing import Any, Optional, cast
import aiohttp
from aiohttp import hdrs, web
from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized
import jwt
from multidict import CIMultiDict
from yarl import URL
from custom_components.frigate.api import FrigateApiClient
from custom_components.frigate.const import (
ATTR_CLIENT,
ATTR_CLIENT_ID,
ATTR_CONFIG,
ATTR_MQTT,
CONF_NOTIFICATION_PROXY_ENABLE,
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
DOMAIN,
)
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM
from homeassistant.components.http.const import KEY_HASS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER: logging.Logger = logging.getLogger(__name__)
def get_default_config_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""Get the default Frigate config entry.
This is for backwards compatibility for when only a single instance was
supported. If there's more than one instance configured, then there is no
default and the user must specify explicitly which instance they want.
"""
frigate_entries = hass.config_entries.async_entries(DOMAIN)
if len(frigate_entries) == 1:
return frigate_entries[0]
return None
def get_frigate_instance_id(config: dict[str, Any]) -> str | None:
"""Get the Frigate instance id from a Frigate configuration."""
# Use the MQTT client_id as a way to separate the frigate instances, rather
# than just using the config_entry_id, in order to make URLs maximally
# relatable/findable by the user. The MQTT client_id value is configured by
# the user in their Frigate configuration and will be unique per Frigate
# instance (enforced in practice on the Frigate/MQTT side).
return cast(Optional[str], config.get(ATTR_MQTT, {}).get(ATTR_CLIENT_ID))
def get_config_entry_for_frigate_instance_id(
hass: HomeAssistant, frigate_instance_id: str
) -> ConfigEntry | None:
"""Get a ConfigEntry for a given frigate_instance_id."""
for config_entry in hass.config_entries.async_entries(DOMAIN):
config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {})
if config and get_frigate_instance_id(config) == frigate_instance_id:
return config_entry
return None
def get_client_for_frigate_instance_id(
hass: HomeAssistant, frigate_instance_id: str
) -> FrigateApiClient | None:
"""Get a client for a given frigate_instance_id."""
config_entry = get_config_entry_for_frigate_instance_id(hass, frigate_instance_id)
if config_entry:
return cast(
FrigateApiClient,
hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CLIENT),
)
return None
def get_frigate_instance_id_for_config_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> ConfigEntry | None:
"""Get a frigate_instance_id for a ConfigEntry."""
config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {})
return get_frigate_instance_id(config) if config else None
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))
hass.http.register_view(ThumbnailsProxyView(session))
hass.http.register_view(VodProxyView(session))
hass.http.register_view(VodSegmentProxyView(session))
# These proxies are inspired by:
# - https://github.com/home-assistant/supervisor/blob/main/supervisor/api/ingress.py
class ProxyView(HomeAssistantView): # type: ignore[misc]
"""HomeAssistant view."""
requires_auth = True
def __init__(self, websession: aiohttp.ClientSession):
"""Initialize the frigate clips proxy view."""
self._websession = websession
def _get_config_entry_for_request(
self, request: web.Request, frigate_instance_id: str | None
) -> ConfigEntry | None:
"""Get a ConfigEntry for a given request."""
hass = request.app[KEY_HASS]
if frigate_instance_id:
return get_config_entry_for_frigate_instance_id(hass, frigate_instance_id)
return get_default_config_entry(hass)
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
raise NotImplementedError # pragma: no cover
def _permit_request(
self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any
) -> bool:
"""Determine whether to permit a request."""
return True
async def get(
self,
request: web.Request,
**kwargs: Any,
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
"""Route data to service."""
try:
return await self._handle_request(request, **kwargs)
except aiohttp.ClientError as err:
_LOGGER.debug("Reverse proxy error for %s: %s", request.rel_url, err)
raise HTTPBadGateway() from None
@staticmethod
def _get_query_params(request: web.Request) -> Mapping[str, str]:
"""Get the query params to send upstream."""
return {k: v for k, v in request.query.items() if k != "authSig"}
async def _handle_request(
self,
request: web.Request,
frigate_instance_id: str | None = None,
**kwargs: Any,
) -> web.Response | web.StreamResponse:
"""Handle route for request."""
config_entry = self._get_config_entry_for_request(request, frigate_instance_id)
if not config_entry:
return web.Response(status=HTTPStatus.BAD_REQUEST)
if not self._permit_request(request, config_entry, **kwargs):
return web.Response(status=HTTPStatus.FORBIDDEN)
full_path = self._create_path(**kwargs)
if not full_path:
return web.Response(status=HTTPStatus.NOT_FOUND)
url = str(URL(config_entry.data[CONF_URL]) / full_path)
data = await request.read()
source_header = _init_header(request)
async with self._websession.request(
request.method,
url,
headers=source_header,
params=self._get_query_params(request),
allow_redirects=False,
data=data,
) as result:
headers = _response_header(result)
# Stream response
response = web.StreamResponse(status=result.status, headers=headers)
response.content_type = result.content_type
try:
await response.prepare(request)
async for data in result.content.iter_any():
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err:
_LOGGER.debug("Stream error for %s: %s", request.rel_url, err)
except ConnectionResetError:
# Connection is reset/closed by peer.
pass
return response
class SnapshotsProxyView(ProxyView):
"""A proxy for snapshots."""
url = "/api/frigate/{frigate_instance_id:.+}/snapshot/{eventid:.*}"
extra_urls = ["/api/frigate/snapshot/{eventid:.*}"]
name = "api:frigate:snapshots"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"api/events/{kwargs['eventid']}/snapshot.jpg"
class RecordingProxyView(ProxyView):
"""A proxy for recordings."""
url = "/api/frigate/{frigate_instance_id:.+}/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}"
extra_urls = [
"/api/frigate/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}"
]
name = "api:frigate:recording"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return (
f"api/{kwargs['camera']}/start/{kwargs['start']}"
+ f"/end/{kwargs['end']}/clip.mp4"
)
class ThumbnailsProxyView(ProxyView):
"""A proxy for snapshots."""
url = "/api/frigate/{frigate_instance_id:.+}/thumbnail/{eventid:.*}"
name = "api:frigate:thumbnails"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"api/events/{kwargs['eventid']}/thumbnail.jpg"
class NotificationsProxyView(ProxyView):
"""A proxy for notifications."""
url = "/api/frigate/{frigate_instance_id:.+}/notifications/{event_id}/{path:.*}"
extra_urls = ["/api/frigate/notifications/{event_id}/{path:.*}"]
name = "api:frigate:notification"
requires_auth = False
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
path, event_id = kwargs["path"], kwargs["event_id"]
if path == "thumbnail.jpg":
return f"api/events/{event_id}/thumbnail.jpg"
if path == "snapshot.jpg":
return f"api/events/{event_id}/snapshot.jpg"
if path.endswith("clip.mp4"):
return f"api/events/{event_id}/clip.mp4"
return None
def _permit_request(
self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any
) -> bool:
"""Determine whether to permit a request."""
is_notification_proxy_enabled = bool(
config_entry.options.get(CONF_NOTIFICATION_PROXY_ENABLE, True)
)
# If proxy is disabled, immediately reject
if not is_notification_proxy_enabled:
return False
# Authenticated requests are always allowed.
if request[KEY_AUTHENTICATED]:
return True
# If request is not authenticated, check whether it is expired.
notification_expiration_seconds = int(
config_entry.options.get(CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, 0)
)
# If notification events never expire, immediately permit.
if notification_expiration_seconds == 0:
return True
try:
event_id_timestamp = int(kwargs["event_id"].partition(".")[0])
event_datetime = datetime.datetime.fromtimestamp(
event_id_timestamp, tz=datetime.timezone.utc
)
now_datetime = datetime.datetime.now(tz=datetime.timezone.utc)
expiration_datetime = event_datetime + datetime.timedelta(
seconds=notification_expiration_seconds
)
# Otherwise, permit only if notification event is not expired
return now_datetime.timestamp() <= expiration_datetime.timestamp()
except ValueError:
_LOGGER.warning(
"The event id %s does not have a valid format.", kwargs["event_id"]
)
return False
class VodProxyView(ProxyView):
"""A proxy for vod playlists."""
url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{manifest:.+}.m3u8"
extra_urls = ["/api/frigate/vod/{path:.+}/{manifest:.+}.m3u8"]
name = "api:frigate:vod:manifest"
@staticmethod
def _get_query_params(request: web.Request) -> Mapping[str, str]:
"""Get the query params to send upstream."""
return request.query
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"vod/{kwargs['path']}/{kwargs['manifest']}.m3u8"
class VodSegmentProxyView(ProxyView):
"""A proxy for vod segments."""
url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}"
extra_urls = ["/api/frigate/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}"]
name = "api:frigate:vod:segment"
requires_auth = False
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"vod/{kwargs['path']}/{kwargs['segment']}.{kwargs['extension']}"
async def _async_validate_signed_manifest(self, request: web.Request) -> bool:
"""Validate the signature for the manifest of this segment."""
hass = request.app[KEY_HASS]
secret = hass.data.get(DATA_SIGN_SECRET)
signature = request.query.get(SIGN_QUERY_PARAM)
if signature is None:
_LOGGER.warning("Missing authSig query parameter on VOD segment request.")
return False
try:
claims = jwt.decode(
signature, secret, algorithms=["HS256"], options={"verify_iss": False}
)
except jwt.InvalidTokenError:
_LOGGER.warning("Invalid JWT token for VOD segment request.")
return False
# Check that the base path is the same as what was signed
check_path = request.path.rsplit("/", maxsplit=1)[0]
if not claims["path"].startswith(check_path):
_LOGGER.warning("%s does not start with %s", claims["path"], check_path)
return False
return True
async def get(
self,
request: web.Request,
**kwargs: Any,
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
"""Route data to service."""
if not await self._async_validate_signed_manifest(request):
raise HTTPUnauthorized()
return await super().get(request, **kwargs)
class WebsocketProxyView(ProxyView):
"""A simple proxy for websockets."""
async def _proxy_msgs(
self,
ws_in: aiohttp.ClientWebSocketResponse | web.WebSocketResponse,
ws_out: aiohttp.ClientWebSocketResponse | web.WebSocketResponse,
) -> None:
async for msg in ws_in:
try:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws_out.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await ws_out.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.PING:
await ws_out.ping()
elif msg.type == aiohttp.WSMsgType.PONG:
await ws_out.pong()
except ConnectionResetError:
return
async def _handle_request(
self,
request: web.Request,
frigate_instance_id: str | None = None,
**kwargs: Any,
) -> web.Response | web.StreamResponse:
"""Handle route for request."""
config_entry = self._get_config_entry_for_request(request, frigate_instance_id)
if not config_entry:
return web.Response(status=HTTPStatus.BAD_REQUEST)
if not self._permit_request(request, config_entry, **kwargs):
return web.Response(status=HTTPStatus.FORBIDDEN)
full_path = self._create_path(**kwargs)
if not full_path:
return web.Response(status=HTTPStatus.NOT_FOUND)
req_protocols = []
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
req_protocols = [
str(proto.strip())
for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
]
ws_to_user = web.WebSocketResponse(
protocols=req_protocols, autoclose=False, autoping=False
)
await ws_to_user.prepare(request)
# Preparing
url = str(URL(config_entry.data[CONF_URL]) / full_path)
source_header = _init_header(request)
# Support GET query
if request.query_string:
url = f"{url}?{request.query_string}"
async with self._websession.ws_connect(
url,
headers=source_header,
protocols=req_protocols,
autoclose=False,
autoping=False,
) as ws_to_frigate:
await asyncio.wait(
[
asyncio.create_task(self._proxy_msgs(ws_to_frigate, ws_to_user)),
asyncio.create_task(self._proxy_msgs(ws_to_user, ws_to_frigate)),
],
return_when=asyncio.tasks.FIRST_COMPLETED,
)
return ws_to_user
class JSMPEGProxyView(WebsocketProxyView):
"""A proxy for JSMPEG websocket."""
url = "/api/frigate/{frigate_instance_id:.+}/jsmpeg/{path:.+}"
extra_urls = ["/api/frigate/jsmpeg/{path:.+}"]
name = "api:frigate:jsmpeg"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create 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]:
"""Create initial header."""
headers = {}
# filter flags
for name, value in request.headers.items():
if name in (
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_ENCODING,
hdrs.SEC_WEBSOCKET_EXTENSIONS,
hdrs.SEC_WEBSOCKET_PROTOCOL,
hdrs.SEC_WEBSOCKET_VERSION,
hdrs.SEC_WEBSOCKET_KEY,
hdrs.HOST,
hdrs.AUTHORIZATION,
):
continue
headers[name] = value
# Set X-Forwarded-For
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
assert request.transport
connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
if forward_for:
forward_for = f"{forward_for}, {connected_ip!s}"
else:
forward_for = f"{connected_ip!s}"
headers[hdrs.X_FORWARDED_FOR] = forward_for
# Set X-Forwarded-Host
forward_host = request.headers.get(hdrs.X_FORWARDED_HOST)
if not forward_host:
forward_host = request.host
headers[hdrs.X_FORWARDED_HOST] = forward_host
# Set X-Forwarded-Proto
forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO)
if not forward_proto:
forward_proto = request.url.scheme
headers[hdrs.X_FORWARDED_PROTO] = forward_proto
return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
"""Create response header."""
headers = {}
for name, value in response.headers.items():
if name in (
hdrs.TRANSFER_ENCODING,
# Removing Content-Length header for streaming responses
# prevents seeking from working for mp4 files
# hdrs.CONTENT_LENGTH,
hdrs.CONTENT_TYPE,
hdrs.CONTENT_ENCODING,
# Strips inbound CORS response headers since the aiohttp_cors
# library will assert that they are not already present for CORS
# requests.
hdrs.ACCESS_CONTROL_ALLOW_ORIGIN,
hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS,
hdrs.ACCESS_CONTROL_EXPOSE_HEADERS,
):
continue
headers[name] = value
return headers
+272
View File
@@ -0,0 +1,272 @@
"""Frigate HTTP views."""
from __future__ import annotations
import logging
import voluptuous as vol
from custom_components.frigate.api import FrigateApiClient, FrigateApiClientError
from custom_components.frigate.views import get_client_for_frigate_instance_id
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
_LOGGER: logging.Logger = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorder websocket API."""
websocket_api.async_register_command(hass, ws_retain_event)
websocket_api.async_register_command(hass, ws_get_recordings)
websocket_api.async_register_command(hass, ws_get_recordings_summary)
websocket_api.async_register_command(hass, ws_get_events)
websocket_api.async_register_command(hass, ws_get_events_summary)
websocket_api.async_register_command(hass, ws_get_ptz_info)
def _get_client_or_send_error(
hass: HomeAssistant,
instance_id: str,
msg_id: int,
connection: websocket_api.ActiveConnection,
) -> FrigateApiClient | None:
"""Get the API client or send an error that it cannot be found."""
client = get_client_for_frigate_instance_id(hass, instance_id)
if client is None:
connection.send_error(
msg_id,
websocket_api.const.ERR_NOT_FOUND,
f"Unable to find Frigate instance with ID: {instance_id}",
)
return None
return client
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/event/retain",
vol.Required("instance_id"): str,
vol.Required("event_id"): str,
vol.Required("retain"): bool,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_retain_event(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Un/Retain an event."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_retain(
msg["event_id"], msg["retain"], decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst un/retaining event {msg['event_id']} "
f"for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/recordings/get",
vol.Required("instance_id"): str,
vol.Required("camera"): str,
vol.Optional("after"): int,
vol.Optional("before"): int,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_recordings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get recordings for a camera."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_recordings(
msg["camera"], msg.get("after"), msg.get("before"), decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving recordings for camera {msg['camera']} "
f"for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
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]
async def ws_get_recordings_summary(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get recordings summary for a camera."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_recordings_summary(
msg["camera"], msg.get("timezone", "utc"), decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving recordings summary for camera "
f"{msg['camera']} for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/events/get",
vol.Required("instance_id"): 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]
async def ws_get_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get events."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_events(
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,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving events for cameras "
f"{msg['cameras']} for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/events/summary",
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]
async def ws_get_events_summary(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get events."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_event_summary(
msg.get("has_clip"),
msg.get("has_snapshot"),
msg.get("timezone", "utc"),
decode_json=False,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving events summary for Frigate instance "
f"{msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/ptz/info",
vol.Required("instance_id"): str,
vol.Required("camera"): str,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_ptz_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get PTZ info."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_ptz_info(
msg["camera"],
decode_json=False,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving PTZ info for camera "
f"{msg['camera']} for Frigate instance {msg['instance_id']}",
)