Skip to content

Commit 3ece4e4

Browse files
authored
Implement support for chat service (#1061)
* Add support for calling chat api as a voice service * Cleanup * Add tests * Fixes * Add tests * Cover test
1 parent ebfe86d commit 3ece4e4

File tree

6 files changed

+484
-1
lines changed

6 files changed

+484
-1
lines changed

custom_components/frigate/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
valid_entity_id,
4444
)
4545
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
46-
from homeassistant.helpers import device_registry as dr, entity_registry as er
46+
from homeassistant.helpers import device_registry as dr, entity_registry as er, llm
4747
from homeassistant.helpers.aiohttp_client import async_get_clientsession
4848
from homeassistant.helpers.entity import Entity
4949
from homeassistant.helpers.typing import ConfigType
@@ -57,6 +57,7 @@
5757
ATTR_CONFIG,
5858
ATTR_COORDINATOR,
5959
ATTR_END_TIME,
60+
ATTR_LLM_UNREGISTER,
6061
ATTR_START_TIME,
6162
ATTR_WS_EVENT_PROXY,
6263
ATTR_WS_REVIEW_PROXY,
@@ -73,6 +74,7 @@
7374
STATUS_RUNNING,
7475
STATUS_STARTING,
7576
)
77+
from .llm_functions import FrigateServiceAPI
7678
from .views import async_setup as views_async_setup
7779
from .ws_api import async_setup as ws_api_async_setup
7880
from .ws_proxy import WSEventProxy, WSReviewProxy
@@ -424,6 +426,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
424426
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
425427
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
426428

429+
# Register LLM API if Frigate 0.18+ and not already registered
430+
if (
431+
verify_frigate_version(config, "0.18")
432+
and ATTR_LLM_UNREGISTER not in hass.data[DOMAIN]
433+
):
434+
hass.data[DOMAIN][ATTR_LLM_UNREGISTER] = llm.async_register_api(
435+
hass, FrigateServiceAPI(hass=hass)
436+
)
437+
427438
# Register review summarize service if Frigate version is 0.17+
428439
if verify_frigate_version(config, "0.17"):
429440
hass.services.async_register(
@@ -508,6 +519,15 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
508519
)
509520
hass.data[DOMAIN].pop(config_entry.entry_id)
510521

522+
# Unregister LLM API if no more Frigate entries remain
523+
remaining = {
524+
k
525+
for k, v in hass.data[DOMAIN].items()
526+
if isinstance(v, dict) and ATTR_CLIENT in v
527+
}
528+
if not remaining and ATTR_LLM_UNREGISTER in hass.data[DOMAIN]:
529+
hass.data[DOMAIN].pop(ATTR_LLM_UNREGISTER)()
530+
511531
return unload_ok
512532

513533

custom_components/frigate/api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,30 @@ async def async_review_summarize(
382382
)
383383
return cast(dict[str, Any], result) if decode_json else result
384384

385+
async def async_chat_completion(
386+
self,
387+
query: str,
388+
camera_name: str | None = None,
389+
) -> dict[str, Any]:
390+
"""Send a chat completion request to Frigate."""
391+
data: dict[str, Any] = {
392+
"messages": [{"role": "user", "content": query}],
393+
"max_tool_iterations": 5,
394+
"stream": False,
395+
}
396+
if camera_name:
397+
data["include_live_image"] = camera_name
398+
399+
return cast(
400+
dict[str, Any],
401+
await self.api_wrapper(
402+
"post",
403+
str(URL(self._host) / "api/chat/completion"),
404+
data=data,
405+
timeout=REVIEW_SUMMARIZE_TIMEOUT,
406+
),
407+
)
408+
385409
async def _get_token(self) -> None:
386410
"""
387411
Obtain a new JWT token using the provided username and password.

custom_components/frigate/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
ATTR_START_TIME = "start_time"
4343
ATTR_WS_EVENT_PROXY = "ws_event_proxy"
4444
ATTR_WS_REVIEW_PROXY = "ws_review_proxy"
45+
ATTR_LLM_UNREGISTER = "llm_unregister"
4546
ATTR_LABEL = "label"
4647
ATTR_SUB_LABEL = "sub_label"
4748
ATTR_DURATION = "duration"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""LLM API for Frigate integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
import voluptuous as vol
9+
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers import config_validation as cv, llm
12+
from homeassistant.util.json import JsonObjectType
13+
14+
from .api import FrigateApiClientError
15+
from .const import ATTR_CLIENT, ATTR_CONFIG, DOMAIN
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
FRIGATE_SERVICES_API_ID = "frigate_services"
20+
21+
22+
class FrigateQueryTool(llm.Tool):
23+
"""Tool that queries the Frigate NVR chat API."""
24+
25+
name = "frigate_query"
26+
description = (
27+
"Ask Frigate NVR a question about your security cameras, recent events, "
28+
"detected objects, or what is currently visible on a camera. Use this tool "
29+
"when the user asks about their security cameras, surveillance footage, "
30+
"who or what was detected, or wants to know what a camera sees right now. "
31+
"You can optionally specify a camera name to include a live image from "
32+
"that camera for visual analysis."
33+
)
34+
35+
def __init__(self, camera_names: list[str]) -> None:
36+
"""Initialize the tool with available camera names."""
37+
schema: dict[vol.Marker, Any] = {
38+
vol.Required(
39+
"query",
40+
description="The user's question about their security cameras or surveillance system",
41+
): cv.string,
42+
}
43+
if camera_names:
44+
schema[
45+
vol.Optional(
46+
"camera_name",
47+
description=(
48+
"The name of a specific camera to include a live image "
49+
"from for visual context. Use when the user asks about "
50+
"what a specific camera sees right now."
51+
),
52+
)
53+
] = vol.In(camera_names)
54+
self.parameters = vol.Schema(schema)
55+
56+
async def async_call(
57+
self,
58+
hass: HomeAssistant,
59+
tool_input: llm.ToolInput,
60+
llm_context: llm.LLMContext,
61+
) -> JsonObjectType:
62+
"""Call the Frigate chat completion API."""
63+
query = tool_input.tool_args["query"]
64+
camera_name = tool_input.tool_args.get("camera_name")
65+
66+
# Find the right client
67+
client = None
68+
for entry_id, entry_data in hass.data[DOMAIN].items():
69+
if not isinstance(entry_data, dict) or ATTR_CLIENT not in entry_data:
70+
continue
71+
if camera_name:
72+
config = entry_data.get(ATTR_CONFIG, {})
73+
if camera_name in config.get("cameras", {}):
74+
client = entry_data[ATTR_CLIENT]
75+
break
76+
else:
77+
client = entry_data[ATTR_CLIENT]
78+
break
79+
80+
if client is None:
81+
return {"error": "No Frigate instance available"}
82+
83+
try:
84+
result = await client.async_chat_completion(query, camera_name)
85+
content = result.get("message", {}).get("content", "")
86+
return {"response": content}
87+
except FrigateApiClientError as exc:
88+
_LOGGER.error("Frigate query failed: %s", exc)
89+
return {"error": f"Frigate query failed: {exc}"}
90+
91+
92+
class FrigateServiceAPI(llm.API):
93+
"""LLM API exposing Frigate Services."""
94+
95+
def __init__(self, hass: HomeAssistant) -> None:
96+
"""Initialize the API."""
97+
super().__init__(
98+
hass=hass,
99+
id=FRIGATE_SERVICES_API_ID,
100+
name="Frigate Services",
101+
)
102+
103+
async def async_get_api_instance(
104+
self, llm_context: llm.LLMContext
105+
) -> llm.APIInstance:
106+
"""Return the instance of the API."""
107+
# Collect camera names from all Frigate config entries
108+
camera_names: list[str] = []
109+
for entry_id, entry_data in self.hass.data.get(DOMAIN, {}).items():
110+
if not isinstance(entry_data, dict) or ATTR_CONFIG not in entry_data:
111+
continue
112+
config = entry_data[ATTR_CONFIG]
113+
for cam_name in config.get("cameras", {}).keys():
114+
if cam_name not in camera_names:
115+
camera_names.append(cam_name)
116+
117+
camera_list = ", ".join(camera_names) if camera_names else "none detected"
118+
api_prompt = (
119+
"Use Frigate Services to ask questions about security cameras, "
120+
"detected events, and live camera feeds. Frigate is an NVR "
121+
"(Network Video Recorder) that monitors security cameras, detects "
122+
"objects like people, cars, and animals, and records events. "
123+
f"Available cameras: {camera_list}."
124+
)
125+
126+
tool = FrigateQueryTool(camera_names)
127+
128+
return llm.APIInstance(
129+
api=self,
130+
api_prompt=api_prompt,
131+
llm_context=llm_context,
132+
tools=[tool],
133+
)

tests/test_api.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,75 @@ async def test_async_review_summarize(
397397
assert post_handler.called
398398

399399

400+
async def test_async_chat_completion(
401+
aiohttp_session: aiohttp.ClientSession, aiohttp_server: Any
402+
) -> None:
403+
"""Test async_chat_completion."""
404+
chat_response = {
405+
"message": {
406+
"role": "assistant",
407+
"content": "There is a person at the front door.",
408+
},
409+
"finish_reason": "stop",
410+
"tool_iterations": 1,
411+
"tool_calls": [],
412+
}
413+
414+
async def chat_handler(request: web.Request) -> web.Response:
415+
"""Chat completion handler."""
416+
body = await request.json()
417+
assert body["messages"] == [
418+
{"role": "user", "content": "Is there anyone at the front door?"}
419+
]
420+
assert body["max_tool_iterations"] == 5
421+
assert body["stream"] is False
422+
assert "include_live_image" not in body
423+
return web.json_response(chat_response)
424+
425+
server = await start_frigate_server(
426+
aiohttp_server,
427+
[web.post("/api/chat/completion", chat_handler)],
428+
)
429+
430+
frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session)
431+
result = await frigate_client.async_chat_completion(
432+
"Is there anyone at the front door?"
433+
)
434+
assert result == chat_response
435+
436+
437+
async def test_async_chat_completion_with_camera(
438+
aiohttp_session: aiohttp.ClientSession, aiohttp_server: Any
439+
) -> None:
440+
"""Test async_chat_completion with camera name for live image."""
441+
chat_response = {
442+
"message": {
443+
"role": "assistant",
444+
"content": "I can see a car in the driveway.",
445+
},
446+
"finish_reason": "stop",
447+
"tool_iterations": 1,
448+
"tool_calls": [],
449+
}
450+
451+
async def chat_handler(request: web.Request) -> web.Response:
452+
"""Chat completion handler."""
453+
body = await request.json()
454+
assert body["include_live_image"] == "front_door"
455+
return web.json_response(chat_response)
456+
457+
server = await start_frigate_server(
458+
aiohttp_server,
459+
[web.post("/api/chat/completion", chat_handler)],
460+
)
461+
462+
frigate_client = FrigateApiClient(str(server.make_url("/")), aiohttp_session)
463+
result = await frigate_client.async_chat_completion(
464+
"What do you see?", camera_name="front_door"
465+
)
466+
assert result == chat_response
467+
468+
400469
async def test_async_get_recordings_summary(
401470
aiohttp_session: aiohttp.ClientSession, aiohttp_server: Any
402471
) -> None:

0 commit comments

Comments
 (0)