Home Assistant Git Exporter
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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:
|
||||
@@ -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": "Режим расширенных настроек отключен; доступны только основные параметры"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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']}",
|
||||
)
|
||||
Reference in New Issue
Block a user