Skip to content

Commit

Permalink
chore(deps): update dependency blakeblackshear/frigate-hass-integrati…
Browse files Browse the repository at this point in the history
…on to v5.6.0
  • Loading branch information
jnewland-renovate committed Dec 11, 2024
1 parent 7e85fa3 commit 6906fe6
Show file tree
Hide file tree
Showing 22 changed files with 920 additions and 715 deletions.
76 changes: 55 additions & 21 deletions custom_components/frigate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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
Expand All @@ -17,18 +18,26 @@
from custom_components.frigate.config_flow import get_config_entry_title
from homeassistant.components.mqtt.models import ReceiveMessage
from homeassistant.components.mqtt.subscription import (
EntitySubscription,
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.const import (
ATTR_MODEL,
CONF_HOST,
CONF_PASSWORD,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.core import 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.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
Expand All @@ -38,8 +47,10 @@
ATTR_CLIENT,
ATTR_CONFIG,
ATTR_COORDINATOR,
ATTR_WS_EVENT_PROXY,
ATTRIBUTE_LABELS,
CONF_CAMERA_STATIC_IMAGE_HEIGHT,
CONF_RTMP_URL_TEMPLATE,
DOMAIN,
FRIGATE_RELEASES_URL,
FRIGATE_VERSION_ERROR_CUTOFF,
Expand All @@ -52,6 +63,7 @@
)
from .views import async_setup as views_async_setup
from .ws_api import async_setup as ws_api_async_setup
from .ws_event_proxy import WSEventProxy

SCAN_INTERVAL = timedelta(seconds=5)

Expand Down Expand Up @@ -164,7 +176,12 @@ def get_zones(config: dict[str, Any]) -> set[str]:
return cameras_zones


async def async_setup(hass: HomeAssistant, config: Config) -> bool:
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: ConfigType) -> bool:
"""Set up this integration using YAML is not supported."""
integration = await async_get_integration(hass, DOMAIN)
_LOGGER.info(
Expand All @@ -183,8 +200,10 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
client = FrigateApiClient(
entry.data.get(CONF_URL),
str(entry.data.get(CONF_URL)),
async_get_clientsession(hass),
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
coordinator = FrigateDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh()
Expand All @@ -195,11 +214,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except FrigateApiClientError as exc:
raise ConfigEntryNotReady from exc

if AwesomeVersion(server_version.split("-")[0]) <= AwesomeVersion(
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 "
"Using a Frigate server (%s) with version %s < %s which is not "
"compatible -- you must upgrade: %s",
entry.data[CONF_URL],
server_version,
Expand All @@ -210,11 +229,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

model = f"{(await async_get_integration(hass, DOMAIN)).version}/{server_version}"

ws_event_proxy = WSEventProxy(hass, config["mqtt"]["topic_prefix"])
entry.async_on_unload(lambda: ws_event_proxy.unsubscribe_all(hass))

hass.data[DOMAIN][entry.entry_id] = {
ATTR_COORDINATOR: coordinator,
ATTR_CLIENT: client,
ATTR_CONFIG: config,
ATTR_MODEL: model,
ATTR_WS_EVENT_PROXY: ws_event_proxy,
}

# Remove old devices associated with cameras that have since been removed
Expand Down Expand Up @@ -249,10 +272,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if entity_id:
entity_registry.async_remove(entity_id)

# Remove old `camera_image_height` option.
if CONF_CAMERA_STATIC_IMAGE_HEIGHT in entry.options:
# Remove old options.
OLD_OPTIONS = [
CONF_CAMERA_STATIC_IMAGE_HEIGHT,
CONF_RTMP_URL_TEMPLATE,
]
if any(option in entry.options for option in OLD_OPTIONS):
new_options = entry.options.copy()
new_options.pop(CONF_CAMERA_STATIC_IMAGE_HEIGHT)
for option in OLD_OPTIONS:
new_options.pop(option, None)
hass.config_entries.async_update_entry(entry, options=new_options)

# Cleanup object_motion sensors (replaced with occupancy sensors).
Expand Down Expand Up @@ -308,7 +336,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True


class FrigateDataUpdateCoordinator(DataUpdateCoordinator): # type: ignore[misc]
class FrigateDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""

def __init__(self, hass: HomeAssistant, client: FrigateApiClient):
Expand All @@ -334,6 +362,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
)
if unload_ok:
await (
hass.data[DOMAIN][config_entry.entry_id]
.get(ATTR_COORDINATOR)
.async_shutdown()
)
hass.data[DOMAIN].pop(config_entry.entry_id)

return unload_ok
Expand All @@ -353,11 +386,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
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,
data=data,
title=get_config_entry_title(data[CONF_URL]),
version=2,
)
config_entry.version = 2

@callback # type: ignore[misc]
@callback
def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""

Expand Down Expand Up @@ -411,7 +446,7 @@ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
return True


class FrigateEntity(Entity): # type: ignore[misc]
class FrigateEntity(Entity):
"""Base class for Frigate entities."""

_attr_has_entity_name = True
Expand Down Expand Up @@ -445,7 +480,7 @@ def __init__(
"""Construct a FrigateMQTTEntity."""
super().__init__(config_entry)
self._frigate_config = frigate_config
self._sub_state = None
self._sub_state: dict[str, EntitySubscription] | None = None
self._available = False
self._topic_map = topic_map

Expand All @@ -457,22 +492,21 @@ async def async_added_to_hass(self) -> None:
"qos": 0,
}

state = async_prepare_subscribe_topics(
self._sub_state = async_prepare_subscribe_topics(
self.hass,
self._sub_state,
self._topic_map,
)
self._sub_state = await async_subscribe_topics(self.hass, state)
await async_subscribe_topics(self.hass, self._sub_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
self._sub_state = async_unsubscribe_topics(self.hass, self._sub_state)
await super().async_will_remove_from_hass()

@callback # type: ignore[misc]
@callback
def _availability_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT availability message."""
self._available = msg.payload == "online"
self._available = decode_if_necessary(msg.payload) == "online"
self.async_write_ha_state()
107 changes: 106 additions & 1 deletion custom_components/frigate/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Frigate API client."""

from __future__ import annotations

import asyncio
import datetime
import logging
import socket
from typing import Any, cast
Expand All @@ -10,6 +12,8 @@
import async_timeout
from yarl import URL

from homeassistant.auth import jwt_wrapper

TIMEOUT = 10

_LOGGER: logging.Logger = logging.getLogger(__name__)
Expand All @@ -30,10 +34,19 @@ class FrigateApiClientError(Exception):
class FrigateApiClient:
"""Frigate API client."""

def __init__(self, host: str, session: aiohttp.ClientSession) -> None:
def __init__(
self,
host: str,
session: aiohttp.ClientSession,
username: str | None = None,
password: str | None = None,
) -> None:
"""Construct API Client."""
self._host = host
self._session = session
self._username = username
self._password = password
self._token_data: dict[str, Any] = {}

async def async_get_version(self) -> str:
"""Get data from the API."""
Expand Down Expand Up @@ -215,27 +228,98 @@ async def async_get_recordings(
)
return cast(dict[str, Any], result) if decode_json else result

async def _get_token(self) -> None:
"""
Obtain a new JWT token using the provided username and password.
Sends a POST request to the login endpoint and extracts the token
and expiration date from the response headers.
"""
response = await self.api_wrapper(
method="post",
url=str(URL(self._host) / "api/login"),
data={"user": self._username, "password": self._password},
decode_json=False,
is_login_request=True,
)

set_cookie_header = response.headers.get("Set-Cookie", "")
if not set_cookie_header:
raise KeyError("Missing Set-Cookie header in response")

for cookie_prop in set_cookie_header.split(";"):
cookie_prop = cookie_prop.strip()
if cookie_prop.startswith("frigate_token="):
jwt_token = cookie_prop.split("=", 1)[1]
self._token_data["token"] = jwt_token
try:
decoded_token = jwt_wrapper.unverified_hs256_token_decode(jwt_token)
except Exception as e:
raise ValueError(f"Failed to decode JWT token: {e}")
exp_timestamp = decoded_token.get("exp")
if not exp_timestamp:
raise KeyError("JWT is missing 'exp' claim")
self._token_data["expires"] = datetime.datetime.fromtimestamp(
exp_timestamp, datetime.UTC
)
break
else:
raise KeyError("Missing 'frigate_token' in Set-Cookie header")

async def _refresh_token_if_needed(self) -> None:
"""
Refresh the JWT token if it is expired or about to expire.
"""
if "expires" not in self._token_data:
await self._get_token()
return

current_time = datetime.datetime.now(datetime.UTC)
if current_time >= self._token_data["expires"]: # Compare UTC-aware datetimes
await self._get_token()

async def _get_auth_headers(self) -> dict[str, str]:
"""
Get headers for API requests, including the JWT token if available.
Ensures that the token is refreshed if needed.
"""
headers = {}

if self._username and self._password:
await self._refresh_token_if_needed()

if "token" in self._token_data:
headers["Authorization"] = f"Bearer {self._token_data['token']}"

return headers

async def api_wrapper(
self,
method: str,
url: str,
data: dict | None = None,
headers: dict | None = None,
decode_json: bool = True,
is_login_request: bool = False,
) -> Any:
"""Get information from the API."""
if data is None:
data = {}
if headers is None:
headers = {}

if not is_login_request:
headers.update(await self._get_auth_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
)
response.raise_for_status()
if is_login_request:
return response
if decode_json:
return await response.json()
return await response.text()
Expand All @@ -248,6 +332,27 @@ async def api_wrapper(
)
raise FrigateApiClientError from exc

except aiohttp.ClientResponseError as exc:
if exc.status == 401:
_LOGGER.error(
"Unauthorized (401) error for URL %s: %s", url, exc.message
)
raise FrigateApiClientError(
"Unauthorized access - check credentials."
) from exc
elif exc.status == 403:
_LOGGER.error("Forbidden (403) error for URL %s: %s", url, exc.message)
raise FrigateApiClientError(
"Forbidden - insufficient permissions."
) from exc
else:
_LOGGER.error(
"Client response error (%d) for URL %s: %s",
exc.status,
url,
exc.message,
)
raise FrigateApiClientError from exc
except (KeyError, TypeError) as exc:
_LOGGER.error(
"Error parsing information from %s: %s",
Expand Down
Loading

0 comments on commit 6906fe6

Please sign in to comment.