Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a websocket API to the integration for event retain #295

Merged
merged 2 commits into from
Jun 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions custom_components/frigate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
VodProxyView,
VodSegmentProxyView,
)
from .ws_api import async_setup as ws_api_async_setup

SCAN_INTERVAL = timedelta(seconds=5)

Expand Down Expand Up @@ -169,6 +170,8 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool:

hass.data.setdefault(DOMAIN, {})

ws_api_async_setup(hass)

session = async_get_clientsession(hass)
hass.http.register_view(JSMPEGProxyView(session))
hass.http.register_view(NotificationsProxyView(session))
Expand Down
37 changes: 20 additions & 17 deletions custom_components/frigate/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import asyncio
import logging
import socket
from typing import Any, Dict, List, cast
from typing import Any, cast

import aiohttp
import async_timeout
Expand Down Expand Up @@ -47,7 +47,7 @@ async def async_get_version(self) -> str:
async def async_get_stats(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
Dict[str, Any],
dict[str, Any],
dermotduffy marked this conversation as resolved.
Show resolved Hide resolved
await self.api_wrapper("get", str(URL(self._host) / "api/stats")),
)

Expand Down Expand Up @@ -75,7 +75,7 @@ async def async_get_events(
}

return cast(
List[Dict[str, Any]],
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
Expand All @@ -98,7 +98,7 @@ async def async_get_event_summary(
}

return cast(
List[Dict[str, Any]],
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
Expand All @@ -112,14 +112,25 @@ async def async_get_event_summary(
async def async_get_config(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
Dict[str, Any],
dict[str, Any],
await self.api_wrapper("get", str(URL(self._host) / "api/config")),
)

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) -> dict[str, Any]:
"""Un/Retain an event."""
return cast(
dict[str, Any],
await self.api_wrapper(
"post" if retain else "delete",
str(URL(self._host) / f"api/events/{event_id}/retain"),
decode_json=True,
),
)

async def api_wrapper(
dermotduffy marked this conversation as resolved.
Show resolved Hide resolved
self,
method: str,
Expand All @@ -136,23 +147,15 @@ async def api_wrapper(

try:
async with async_timeout.timeout(TIMEOUT):
if method == "get":
response = await self._session.get(
url, headers=headers, raise_for_status=True
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()

if method == "put":
await self._session.put(url, headers=headers, json=data)

elif method == "patch":
await self._session.patch(url, headers=headers, json=data)

elif method == "post":
await self._session.post(url, headers=headers, json=data)

except asyncio.TimeoutError as exc:
_LOGGER.error(
"Timeout error fetching information from %s: %s",
Expand Down
14 changes: 5 additions & 9 deletions custom_components/frigate/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@

from . import get_friendly_name
from .api import FrigateApiClient, FrigateApiClientError
from .const import ATTR_CLIENT, CONF_MEDIA_BROWSER_ENABLE, DOMAIN, NAME
from .const import CONF_MEDIA_BROWSER_ENABLE, DOMAIN, NAME
from .views import (
get_client_for_frigate_instance_id,
get_config_entry_for_frigate_instance_id,
get_default_config_entry,
get_frigate_instance_id_for_config_entry,
Expand Down Expand Up @@ -565,16 +566,11 @@ def _is_allowed_as_media_source(self, instance_id: str) -> bool:

def _get_client(self, identifier: Identifier) -> FrigateApiClient:
"""Get client for a given identifier."""
config_entry = get_config_entry_for_frigate_instance_id(
client = get_client_for_frigate_instance_id(
self.hass, identifier.frigate_instance_id
)

if config_entry:
client: FrigateApiClient = (
self.hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CLIENT)
)
if client:
return client
if client:
return client

raise MediaSourceError(
"Could not find client for frigate instance id: %s"
Expand Down
16 changes: 16 additions & 0 deletions custom_components/frigate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
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,
Expand Down Expand Up @@ -67,6 +69,20 @@ def get_config_entry_for_frigate_instance_id(
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,
Expand Down
67 changes: 67 additions & 0 deletions custom_components/frigate/ws_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""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)


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"])
)
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']}",
)
29 changes: 29 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,32 @@ async def version_handler(request: web.Request) -> web.Response:

frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session)
assert await frigate_client.async_get_version() == TEST_SERVER_VERSION


async def test_async_retain(
aiohttp_session: aiohttp.ClientSession, aiohttp_server: Any
) -> None:
"""Test async_retain."""

post_success = {"success": True, "message": "Post success"}
post_handler = Mock(return_value=web.json_response(post_success))

delete_success = {"success": True, "message": "Delete success"}
delete_handler = Mock(return_value=web.json_response(delete_success))

event_id = "1656282822.206673-bovnfg"
server = await start_frigate_server(
aiohttp_server,
[
web.post(f"/api/events/{event_id}/retain", post_handler),
web.delete(f"/api/events/{event_id}/retain", delete_handler),
],
)

frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session)
assert await frigate_client.async_retain(event_id, True) == post_success
assert post_handler.called
assert not delete_handler.called

assert await frigate_client.async_retain(event_id, False) == delete_success
assert delete_handler.called
121 changes: 121 additions & 0 deletions tests/test_ws_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Test the Frigate HA websocket API."""
from __future__ import annotations

import logging
from typing import Any
from unittest.mock import AsyncMock

from custom_components.frigate.api import FrigateApiClientError
from homeassistant.core import HomeAssistant

from tests import (
TEST_FRIGATE_INSTANCE_ID,
create_mock_frigate_client,
setup_mock_frigate_config_entry,
)

_LOGGER: logging.Logger = logging.getLogger(__name__)

TEST_EVENT_ID = "1656282822.206673-bovnfg"


async def test_retain_success(hass: HomeAssistant, hass_ws_client: Any) -> None:
"""Test un/retaining an event."""

mock_client = create_mock_frigate_client()
await setup_mock_frigate_config_entry(hass, client=mock_client)

ws_client = await hass_ws_client()
retain_json: dict[str, Any] = {
"id": 1,
"type": "frigate/event/retain",
"instance_id": TEST_FRIGATE_INSTANCE_ID,
"event_id": TEST_EVENT_ID,
"retain": True,
}

retain_success = {"retain": "success"}
mock_client.async_retain = AsyncMock(return_value=retain_success)
await ws_client.send_json(retain_json)

response = await ws_client.receive_json()
mock_client.async_retain.assert_called_with(TEST_EVENT_ID, True)
assert response["success"]
assert response["result"] == retain_success

unretain_success = {"unretain": "success"}
mock_client.async_retain = AsyncMock(return_value=unretain_success)
await ws_client.send_json(
{
**retain_json,
"id": 2,
"retain": False,
}
)

response = await ws_client.receive_json()
mock_client.async_retain.assert_called_with(TEST_EVENT_ID, False)
assert response["success"]
assert response["result"] == unretain_success


async def test_retain_missing_args(hass: HomeAssistant, hass_ws_client: Any) -> None:
"""Test retaining an event with missing arguments."""

await setup_mock_frigate_config_entry(hass)

ws_client = await hass_ws_client()
retain_json = {
"id": 1,
"type": "frigate/event/retain",
}

await ws_client.send_json(retain_json)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_format"


async def test_retain_instance_not_found(
hass: HomeAssistant, hass_ws_client: Any
) -> None:
"""Test retaining an event with an instance that is not found."""

await setup_mock_frigate_config_entry(hass)

ws_client = await hass_ws_client()
retain_json = {
"id": 1,
"type": "frigate/event/retain",
"instance_id": "THIS-IS-NOT-A-REAL-INSTANCE-ID",
"event_id": TEST_EVENT_ID,
"retain": True,
}

await ws_client.send_json(retain_json)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "not_found"


async def test_retain_api_error(hass: HomeAssistant, hass_ws_client: Any) -> None:
"""Test retaining an event when the API has an error."""

mock_client = create_mock_frigate_client()
await setup_mock_frigate_config_entry(hass, client=mock_client)

ws_client = await hass_ws_client()
retain_json = {
"id": 1,
"type": "frigate/event/retain",
"instance_id": TEST_FRIGATE_INSTANCE_ID,
"event_id": TEST_EVENT_ID,
"retain": True,
}

mock_client.async_retain = AsyncMock(side_effect=FrigateApiClientError)

await ws_client.send_json(retain_json)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "frigate_error"