Compare commits

..
2 Commits
Author SHA1 Message Date
Nicolas Mowen 1a3abd472a Pull from event DB directly 2026-11-04 10:54:50 -07:00
Nicolas Mowen 9fe12ed0b7 Fix reading labels when there are multiple that match 2026-11-03 09:17:26 -07:00
15 changed files with 102 additions and 431 deletions
+1 -3
View File
@@ -1,6 +1,5 @@
"""Chat and LLM tool calling APIs.""" """Chat and LLM tool calling APIs."""
import asyncio
import base64 import base64
import json import json
import logging import logging
@@ -1654,8 +1653,7 @@ async def chat_completion(
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) " f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s) in conversation" f"with {len(conversation)} message(s) in conversation"
) )
response = await asyncio.to_thread( response = genai_client.chat_with_tools(
genai_client.chat_with_tools,
messages=conversation, messages=conversation,
tools=llm_tools if llm_tools else None, tools=llm_tools if llm_tools else None,
tool_choice="auto", tool_choice="auto",
+7 -2
View File
@@ -57,6 +57,11 @@ def birdseye_modes_to_mqtt_payload(modes: list[BirdseyeModeEnum]) -> str:
return payload or MQTT_NO_MODES return payload or MQTT_NO_MODES
def default_birdseye_modes() -> list[BirdseyeModeEnum]:
"""Return the default Birdseye activity modes."""
return [BirdseyeModeEnum.all_objects]
class BirdseyeLayoutConfig(FrigateBaseModel): class BirdseyeLayoutConfig(FrigateBaseModel):
scaling_factor: float = Field( scaling_factor: float = Field(
default=2.0, default=2.0,
@@ -79,7 +84,7 @@ class BirdseyeConfig(FrigateBaseModel):
description="Enable or disable the Birdseye view feature.", description="Enable or disable the Birdseye view feature.",
) )
modes: list[BirdseyeModeEnum] = Field( modes: list[BirdseyeModeEnum] = Field(
default=[BirdseyeModeEnum.all_objects], default_factory=default_birdseye_modes,
title="Activity types", title="Activity types",
description="Activity types that include cameras in Birdseye.", description="Activity types that include cameras in Birdseye.",
) )
@@ -134,7 +139,7 @@ class BirdseyeCameraConfig(BaseModel):
description="Enable or disable the Birdseye view feature.", description="Enable or disable the Birdseye view feature.",
) )
modes: list[BirdseyeModeEnum] = Field( modes: list[BirdseyeModeEnum] = Field(
default=[BirdseyeModeEnum.all_objects], default_factory=default_birdseye_modes,
title="Activity types", title="Activity types",
description="Activity types that include cameras in Birdseye.", description="Activity types that include cameras in Birdseye.",
) )
+10 -18
View File
@@ -7,8 +7,7 @@ overrides what is genuinely Azure-specific:
- Client construction: parses ``api-version`` out of the configured - Client construction: parses ``api-version`` out of the configured
``base_url`` query string and instantiates :class:`openai.AzureOpenAI` ``base_url`` query string and instantiates :class:`openai.AzureOpenAI`
(or :class:`openai.AsyncAzureOpenAI` for streaming chat) with with ``azure_endpoint`` instead of ``base_url``. Raises if the URL is
``azure_endpoint`` instead of ``base_url``. Raises if the URL is
malformed; :class:`GenAIClientManager` catches the exception and malformed; :class:`GenAIClientManager` catches the exception and
disables the provider. disables the provider.
- Context size: Azure does not expose a per-model ``max_model_len`` field - Context size: Azure does not expose a per-model ``max_model_len`` field
@@ -30,10 +29,9 @@ the ``transcribe`` role:
""" """
import logging import logging
from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from openai import AsyncAzureOpenAI, AzureOpenAI from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import register_genai_provider from frigate.genai import register_genai_provider
@@ -46,8 +44,8 @@ logger = logging.getLogger(__name__)
class AzureOpenAIClient(OpenAIClient): class AzureOpenAIClient(OpenAIClient):
"""Generative AI client for Frigate using Azure OpenAI.""" """Generative AI client for Frigate using Azure OpenAI."""
def _client_options(self) -> dict[str, Any]: def _init_provider(self) -> AzureOpenAI:
"""Build the Azure client options from the configured base_url.""" """Initialize the AzureOpenAI client from the configured base_url."""
parsed_url = urlparse(self.genai_config.base_url or "") parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0] api_version = query_params.get("api-version", [None])[0]
@@ -55,19 +53,13 @@ class AzureOpenAIClient(OpenAIClient):
if not api_version: if not api_version:
raise ValueError("Azure OpenAI base_url is missing api-version.") raise ValueError("Azure OpenAI base_url is missing api-version.")
return { azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
"api_key": self.genai_config.api_key,
"api_version": api_version,
"azure_endpoint": f"{parsed_url.scheme}://{parsed_url.netloc}/",
}
def _init_provider(self) -> AzureOpenAI: return AzureOpenAI(
"""Initialize the AzureOpenAI client from the configured base_url.""" api_key=self.genai_config.api_key,
return AzureOpenAI(**self._client_options()) api_version=api_version,
azure_endpoint=azure_endpoint,
def _init_async_provider(self) -> AsyncAzureOpenAI: )
"""Create an async Azure client for streaming chat."""
return AsyncAzureOpenAI(**self._client_options())
def get_context_size(self) -> int: def get_context_size(self) -> int:
"""Azure does not reliably surface per-model context size; use 128K.""" """Azure does not reliably surface per-model context size; use 128K."""
+47 -64
View File
@@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator
from typing import Any from typing import Any
from httpx import TimeoutException from httpx import TimeoutException
from openai import AsyncOpenAI, OpenAI from openai import OpenAI
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider from frigate.genai import GenAIClient, register_genai_provider
@@ -15,7 +15,6 @@ from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# gpt-transcribe replaced the singular `language` field with a `languages` array # gpt-transcribe replaced the singular `language` field with a `languages` array
# and rejects a request that sends both. Older transcription models # and rejects a request that sends both. Older transcription models
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular # (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
@@ -46,8 +45,8 @@ class OpenAIClient(GenAIClient):
provider: OpenAI provider: OpenAI
context_size: int | None = None context_size: int | None = None
def _client_options(self) -> dict[str, Any]: def _init_provider(self) -> OpenAI:
"""Build the constructor options shared by the sync and async clients. """Initialize the client.
Subclasses (e.g. Azure) should raise on configuration errors; the Subclasses (e.g. Azure) should raise on configuration errors; the
manager catches construction failures and disables the provider. manager catches construction failures and disables the provider.
@@ -63,19 +62,7 @@ class OpenAIClient(GenAIClient):
if self.genai_config.base_url: if self.genai_config.base_url:
provider_opts["base_url"] = self.genai_config.base_url provider_opts["base_url"] = self.genai_config.base_url
return {"api_key": self.genai_config.api_key, **provider_opts} return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
def _init_provider(self) -> OpenAI:
"""Initialize the client."""
return OpenAI(**self._client_options())
def _init_async_provider(self) -> AsyncOpenAI:
"""Create an async client for streaming chat.
A new client is created per stream because its connection pool binds
to the event loop that first uses it.
"""
return AsyncOpenAI(**self._client_options())
def _send( def _send(
self, self,
@@ -425,61 +412,57 @@ class OpenAIClient(GenAIClient):
finish_reason = "stop" finish_reason = "stop"
usage_stats: dict[str, Any] | None = None usage_stats: dict[str, Any] | None = None
# Exiting these blocks, including when the caller disconnects and stream = self.provider.chat.completions.create(**request_params)
# the generator is cancelled, closes the response and its connection
async with (
self._init_async_provider() as client,
await client.chat.completions.create(**request_params) as stream,
):
async for chunk in stream:
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
if not chunk or not chunk.choices: for chunk in stream:
continue chunk_usage = getattr(chunk, "usage", None)
if chunk_usage is not None:
usage_stats = _stats_from_openai_usage(chunk_usage)
choice = chunk.choices[0] if not chunk or not chunk.choices:
delta = choice.delta continue
# Check for finish reason choice = chunk.choices[0]
if choice.finish_reason: delta = choice.delta
finish_reason = choice.finish_reason
# Extract reasoning deltas (reasoning_content or reasoning, # Check for finish reason
# depending on the server) if choice.finish_reason:
reasoning_delta = getattr( finish_reason = choice.finish_reason
delta, "reasoning_content", None
) or getattr(delta, "reasoning", None)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract content deltas # Extract reasoning deltas (reasoning_content or reasoning,
if delta.content: # depending on the server)
content_parts.append(delta.content) reasoning_delta = getattr(delta, "reasoning_content", None) or getattr(
yield ("content_delta", delta.content) delta, "reasoning", None
)
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
yield ("reasoning_delta", reasoning_delta)
# Extract tool calls # Extract content deltas
if delta.tool_calls: if delta.content:
for tc in delta.tool_calls: content_parts.append(delta.content)
idx = tc.index yield ("content_delta", delta.content)
fn = tc.function
if idx not in tool_calls_by_index: # Extract tool calls
tool_calls_by_index[idx] = { if delta.tool_calls:
"id": tc.id or "", for tc in delta.tool_calls:
"name": fn.name if fn and fn.name else "", idx = tc.index
"arguments": "", fn = tc.function
}
t = tool_calls_by_index[idx] if idx not in tool_calls_by_index:
if tc.id: tool_calls_by_index[idx] = {
t["id"] = tc.id "id": tc.id or "",
if fn and fn.name: "name": fn.name if fn and fn.name else "",
t["name"] = fn.name "arguments": "",
if fn and fn.arguments: }
t["arguments"] += fn.arguments
t = tool_calls_by_index[idx]
if tc.id:
t["id"] = tc.id
if fn and fn.name:
t["name"] = fn.name
if fn and fn.arguments:
t["arguments"] += fn.arguments
# Build final message # Build final message
full_content = "".join(content_parts).strip() or None full_content = "".join(content_parts).strip() or None
+25 -31
View File
@@ -233,8 +233,7 @@ class PtzAutoTracker(threading.Thread):
and camera_config.onvif.autotracking.enabled_in_config and camera_config.onvif.autotracking.enabled_in_config
): ):
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self._autotracker_setup(camera_config, camera, startup=True), self._autotracker_setup(camera_config, camera), self.onvif.loop
self.onvif.loop,
) )
# Wait for the coroutine to complete # Wait for the coroutine to complete
future.result() future.result()
@@ -262,9 +261,7 @@ class PtzAutoTracker(threading.Thread):
self.config_subscriber.stop() self.config_subscriber.stop()
logger.info("Exiting autotracker...") logger.info("Exiting autotracker...")
async def _autotracker_setup( async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
self, camera_config: CameraConfig, camera: str, startup: bool = False
):
logger.debug(f"{camera}: Autotracker init") logger.debug(f"{camera}: Autotracker init")
self.tracked_object[camera] = None self.tracked_object[camera] = None
@@ -291,34 +288,35 @@ class PtzAutoTracker(threading.Thread):
self._disable(camera, "Unable to initialize onvif") self._disable(camera, "Unable to initialize onvif")
return return
if "pt-r-fov" not in self.onvif.cams[camera]["features"]: if "pt-r-fov" not in self.onvif.cams[camera]["features"]:
self._disable(camera, "FOV relative movement not supported") self._disable(camera, "FOV relative movement not supported")
return return
move_status_supported = await self.onvif.get_service_capabilities(camera) move_status_supported = await self.onvif.get_service_capabilities(camera)
if str(move_status_supported).lower() != "true": if str(move_status_supported).lower() != "true":
self._disable(camera, "ONVIF MoveStatus not supported") self._disable(camera, "ONVIF MoveStatus not supported")
return return
await self.onvif.get_camera_status(camera) if self.onvif.cams[camera]["init"]:
await self.onvif.get_camera_status(camera)
# movement queue with asyncio on OnvifController loop # movement queue with asyncio on OnvifController loop
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self._process_move_queue(camera), self.onvif.loop self._process_move_queue(camera), self.onvif.loop
) )
if camera_config.onvif.autotracking.movement_weights: if camera_config.onvif.autotracking.movement_weights:
( (
self.ptz_metrics[camera].min_zoom.value, self.ptz_metrics[camera].min_zoom.value,
self.ptz_metrics[camera].max_zoom.value, self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera], self.intercept[camera],
*self.move_coefficients[camera], *self.move_coefficients[camera],
self.zoom_time[camera], self.zoom_time[camera],
) = map(float, camera_config.onvif.autotracking.movement_weights) ) = map(float, camera_config.onvif.autotracking.movement_weights)
if startup and camera_config.onvif.autotracking.calibrate_on_startup: if camera_config.onvif.autotracking.calibrate_on_startup:
await self._calibrate_camera(camera) await self._calibrate_camera(camera)
self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False) self.dispatcher.publish(f"{camera}/ptz_autotracker/active", "OFF", retain=False)
self.autotracker_init[camera] = True self.autotracker_init[camera] = True
@@ -333,7 +331,6 @@ class PtzAutoTracker(threading.Thread):
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera), CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera),
autotracking_config, autotracking_config,
) )
self.dispatcher.publish(f"{camera}/ptz_autotracker/state", "OFF", retain=True)
def _reset_tracked_object_metrics(self, camera: str) -> None: def _reset_tracked_object_metrics(self, camera: str) -> None:
self.tracked_object_metrics[camera] = {} self.tracked_object_metrics[camera] = {}
@@ -1272,9 +1269,6 @@ class PtzAutoTracker(threading.Thread):
# Wait for the coroutine to complete # Wait for the coroutine to complete
future.result() future.result()
if not camera_config.onvif.autotracking.enabled:
return
if self.calibrating[camera]: if self.calibrating[camera]:
logger.debug(f"{camera}: Calibrating camera") logger.debug(f"{camera}: Calibrating camera")
return return
+7 -150
View File
@@ -145,72 +145,12 @@ def _openai_chunk(content=None, tool_calls=None, finish_reason=None, usage=None)
return SimpleNamespace(choices=[choice], usage=usage) return SimpleNamespace(choices=[choice], usage=usage)
class _FakeAsyncOpenAIStream:
"""Mimics openai.AsyncStream: async iterable and async context manager.
With ``stall_after`` set, the stream stops producing after that many
chunks, like a model that stops sending tokens, and sets ``stalled``.
"""
def __init__(self, chunks, stall_after=None):
self._chunks = chunks
self._stall_after = stall_after
self.stalled = asyncio.Event()
self.close = AsyncMock()
async def __aiter__(self):
for i, chunk in enumerate(self._chunks):
if i == self._stall_after:
self.stalled.set()
await asyncio.Event().wait()
yield chunk
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.close()
class _FakeAsyncOpenAI:
"""Mimics openai.AsyncOpenAI: async context manager with a completions API."""
def __init__(self, create):
self.chat = SimpleNamespace(completions=SimpleNamespace(create=create))
self.close = AsyncMock()
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
await self.close()
class TestOpenAIProvider(unittest.TestCase): class TestOpenAIProvider(unittest.TestCase):
def _client(self): def _client(self):
return _make_client( return _make_client(
"openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1" "openai", model="gpt-4o", api_key="k", base_url="http://localhost:9999/v1"
) )
def _streaming_client(self, create):
"""Build a client whose async completions call is ``create``."""
client = self._client()
async_client = _FakeAsyncOpenAI(create)
client._init_async_provider = MagicMock(return_value=async_client)
return client, async_client
def test_async_client_uses_configured_options(self):
from openai import AsyncOpenAI
client = self._client()
async_client = client._init_async_provider()
try:
self.assertIsInstance(async_client, AsyncOpenAI)
self.assertEqual(async_client.base_url, client.provider.base_url)
self.assertEqual(async_client.api_key, "k")
finally:
asyncio.run(async_client.close())
def test_stream_tool_call_arguments_are_dict(self): def test_stream_tool_call_arguments_are_dict(self):
# Arguments arrive split across chunks, as the real API streams them. # Arguments arrive split across chunks, as the real API streams them.
chunks = [ chunks = [
@@ -222,9 +162,8 @@ class TestOpenAIProvider(unittest.TestCase):
_openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]), _openai_chunk(tool_calls=[_openai_tc(0, arguments=' "person"}')]),
_openai_chunk(finish_reason="tool_calls"), _openai_chunk(finish_reason="tool_calls"),
] ]
client, _ = self._streaming_client( client = self._client()
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks)) client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
)
final = _final_message(_collect(client, SIMPLE_MESSAGES)) final = _final_message(_collect(client, SIMPLE_MESSAGES))
self.assertEqual(final["finish_reason"], "tool_calls") self.assertEqual(final["finish_reason"], "tool_calls")
@@ -238,9 +177,8 @@ class TestOpenAIProvider(unittest.TestCase):
_openai_chunk(content="lo"), _openai_chunk(content="lo"),
_openai_chunk(finish_reason="stop"), _openai_chunk(finish_reason="stop"),
] ]
client, _ = self._streaming_client( client = self._client()
AsyncMock(return_value=_FakeAsyncOpenAIStream(chunks)) client.provider.chat.completions.create = MagicMock(return_value=iter(chunks))
)
events = _collect(client, SIMPLE_MESSAGES) events = _collect(client, SIMPLE_MESSAGES)
deltas = [v for (k, v) in events if k == "content_delta"] deltas = [v for (k, v) in events if k == "content_delta"]
@@ -248,84 +186,14 @@ class TestOpenAIProvider(unittest.TestCase):
self.assertEqual(_final_message(events)["content"], "hello") self.assertEqual(_final_message(events)["content"], "hello")
def test_multimodal_message_does_not_crash(self): def test_multimodal_message_does_not_crash(self):
client, _ = self._streaming_client( client = self._client()
AsyncMock( client.provider.chat.completions.create = MagicMock(
return_value=_FakeAsyncOpenAIStream( return_value=iter([_openai_chunk(content="ok", finish_reason="stop")])
[_openai_chunk(content="ok", finish_reason="stop")]
)
)
) )
# Passing the OpenAI-native multimodal list through must not raise. # Passing the OpenAI-native multimodal list through must not raise.
final = _final_message(_collect(client, MULTIMODAL_MESSAGES)) final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") self.assertEqual(final["content"], "ok")
def test_stream_uses_async_client_and_closes_it(self):
# The blocking sync client must never be used on the event loop.
stream = _FakeAsyncOpenAIStream(
[_openai_chunk(content="ok", finish_reason="stop")]
)
create = AsyncMock(return_value=stream)
client, async_client = self._streaming_client(create)
client.provider.chat.completions.create = MagicMock()
final = _final_message(_collect(client, SIMPLE_MESSAGES))
self.assertEqual(final["content"], "ok")
self.assertTrue(create.await_args.kwargs["stream"])
client.provider.chat.completions.create.assert_not_called()
stream.close.assert_awaited_once()
async_client.close.assert_awaited_once()
def test_client_closed_when_caller_leaves_while_opening(self):
# Cancelling before the response arrives must still close the client.
async def _run():
opening = asyncio.Event()
async def hang(**kwargs):
opening.set()
await asyncio.Event().wait()
client, async_client = self._streaming_client(hang)
gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
)
task = asyncio.create_task(gen.__anext__())
await opening.wait()
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
return async_client
async_client = asyncio.run(_run())
async_client.close.assert_awaited_once()
def test_stream_closed_when_caller_leaves_mid_stream(self):
# Cancelling while waiting on a stalled model must close the stream
# right away instead of waiting for the next chunk.
async def _run():
stream = _FakeAsyncOpenAIStream(
[_openai_chunk(content="a"), _openai_chunk(finish_reason="stop")],
stall_after=1,
)
client, async_client = self._streaming_client(
AsyncMock(return_value=stream)
)
gen = client.chat_with_tools_stream(
messages=SIMPLE_MESSAGES, tools=None, tool_choice="auto"
)
first = await gen.__anext__()
task = asyncio.create_task(gen.__anext__())
await stream.stalled.wait()
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
return first, stream, async_client
first, stream, async_client = asyncio.run(_run())
self.assertEqual(first, ("content_delta", "a"))
stream.close.assert_awaited_once()
async_client.close.assert_awaited_once()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gemini # Gemini
@@ -1072,17 +940,6 @@ class TestAzureOpenAITranscribe(unittest.TestCase):
self.assertIsInstance(client.provider, AzureOpenAI) self.assertIsInstance(client.provider, AzureOpenAI)
self.assertTrue(client.supports_transcription) self.assertTrue(client.supports_transcription)
def test_streaming_routes_through_async_azure_client(self):
from openai import AsyncAzureOpenAI
client = self._client()
async_client = client._init_async_provider()
try:
self.assertIsInstance(async_client, AsyncAzureOpenAI)
self.assertEqual(async_client.base_url, client.provider.base_url)
finally:
asyncio.run(async_client.close())
def test_transcribe_inherited(self): def test_transcribe_inherited(self):
client = self._client() client = self._client()
create = MagicMock(return_value="azure text") create = MagicMock(return_value="azure text")
File diff suppressed because one or more lines are too long
@@ -1,126 +0,0 @@
/**
* Section reset button visibility -- MEDIUM tier.
*
* Reset deletes the section from config.yml, which fails when the key isn't
* in the file. The button only shows when the section's values must have
* come from the YAML.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect, type FrigateApp } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import {
configFactory,
type DeepPartial,
} from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
async function openSettings(
app: FrigateApp,
url: string,
overrides: DeepPartial<ReturnType<typeof configFactory>> = {},
) {
const { page } = app;
const config = configFactory(overrides);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { cameras: {}, go2rtc: { streams: {} } } }),
);
await app.goto(url);
}
const resetToDefault = (page: Page) =>
page.getByRole("button", { name: "Reset to Default" });
const resetToGlobal = (page: Page) =>
page.getByRole("button", { name: "Reset to Global" });
test.describe("section reset visibility @medium", () => {
test("global section at its defaults hides reset", async ({ frigateApp }) => {
await openSettings(frigateApp, "/settings?page=globalRecording");
await expect(
frigateApp.page.getByText("Retention", { exact: false }).first(),
).toBeVisible();
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
});
test("global section with a changed value shows reset", async ({
frigateApp,
}) => {
await openSettings(frigateApp, "/settings?page=globalRecording", {
record: { expire_interval: 30 },
});
await expect(resetToDefault(frigateApp.page)).toBeVisible();
});
test("global ffmpeg with only resolved hwaccel args hides reset", async ({
frigateApp,
}) => {
await openSettings(frigateApp, "/settings?page=globalFfmpeg");
await expect(
frigateApp.page
.getByText("Hardware acceleration", { exact: false })
.first(),
).toBeVisible();
await expect(resetToDefault(frigateApp.page)).toHaveCount(0);
});
test("camera live with only backend streams hides reset", async ({
frigateApp,
}) => {
await openSettings(
frigateApp,
"/settings?page=cameraLivePlayback&camera=front_door",
);
await expect(
frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
}),
).toBeVisible();
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
});
test("camera live with a quality override shows reset", async ({
frigateApp,
}) => {
await openSettings(
frigateApp,
"/settings?page=cameraLivePlayback&camera=front_door",
{ cameras: { front_door: { live: { quality: 4 } } } },
);
await expect(resetToGlobal(frigateApp.page)).toBeVisible();
});
test("camera ffmpeg never shows reset", async ({ frigateApp }) => {
await openSettings(
frigateApp,
"/settings?page=cameraFfmpeg&camera=front_door",
{ cameras: { front_door: { ffmpeg: { output_args: { record: "x" } } } } },
);
await expect(
frigateApp.page
.getByText("Hardware acceleration", { exact: false })
.first(),
).toBeVisible();
await expect(resetToGlobal(frigateApp.page)).toHaveCount(0);
});
});
@@ -53,7 +53,6 @@ const audio: SectionConfigOverrides = {
}, },
global: { global: {
restartRequired: ["num_threads"], restartRequired: ["num_threads"],
runtimeFields: ["filters"],
}, },
camera: { camera: {
restartRequired: ["num_threads"], restartRequired: ["num_threads"],
@@ -103,6 +103,7 @@ const ffmpeg: SectionConfigOverrides = {
hiddenFields: ["retry_interval"], hiddenFields: ["retry_interval"],
advancedFields: ["path", "global_args", "gpu"], advancedFields: ["path", "global_args", "gpu"],
overrideFields: [ overrideFields: [
"inputs",
"path", "path",
"global_args", "global_args",
"input_args", "input_args",
@@ -168,7 +169,6 @@ const ffmpeg: SectionConfigOverrides = {
}, },
global: { global: {
restartRequired: [], restartRequired: [],
runtimeFields: ["hwaccel_args"],
fieldOrder: [ fieldOrder: [
"hwaccel_args", "hwaccel_args",
"path", "path",
@@ -194,8 +194,6 @@ const ffmpeg: SectionConfigOverrides = {
}, },
}, },
camera: { camera: {
// reset would delete the camera's inputs along with its overrides
hideReset: true,
fieldGroups: { fieldGroups: {
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"], cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
}, },
@@ -24,7 +24,6 @@ const live: SectionConfigOverrides = {
fieldGroups: {}, fieldGroups: {},
hiddenFields: ["enabled_in_config"], hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"], advancedFields: ["height", "quality"],
overrideFields: ["transcode", "height", "quality"],
}, },
global: { global: {
restartRequired: ["streams", "height", "quality"], restartRequired: ["streams", "height", "quality"],
@@ -16,7 +16,6 @@ const modelFields = [
const models: SectionConfigOverrides = { const models: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/object_detectors", sectionDocs: "/configuration/object_detectors",
hideReset: true,
// the default-model rule must be enforced as the list is edited, not // the default-model rule must be enforced as the list is edited, not
// only when the form is submitted // only when the form is submitted
liveValidate: true, liveValidate: true,
@@ -116,7 +116,6 @@ const record: SectionConfigOverrides = {
}, },
global: { global: {
restartRequired: [], restartRequired: [],
runtimeFields: ["export.hwaccel_args"],
}, },
camera: { camera: {
restartRequired: [], restartRequired: [],
@@ -37,7 +37,6 @@ import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import get from "lodash/get"; import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import omit from "lodash/omit";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { import {
Collapsible, Collapsible,
@@ -55,7 +54,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { cn, isJsonObject } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ConfigSectionData, ConfigSectionData,
HiddenFieldEntry, HiddenFieldEntry,
@@ -126,10 +125,6 @@ export interface SectionConfig {
fieldMessages?: FieldConditionalMessage[]; fieldMessages?: FieldConditionalMessage[];
/** Maps whose key order is saved (e.g. live stream ladder order) */ /** Maps whose key order is saved (e.g. live stream ladder order) */
orderedMaps?: string[]; orderedMaps?: string[];
/** Fields the backend resolves at runtime, ignored when checking for reset */
runtimeFields?: string[];
/** Hide the Reset to Global / Reset to Default button */
hideReset?: boolean;
} }
export interface BaseSectionProps { export interface BaseSectionProps {
@@ -200,17 +195,6 @@ export interface CreateSectionOptions {
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions; export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
// applySchemaDefaults only descends into objects present in the data, and
// default_factory objects carry no schema default, so keep the nesting.
function objectShape(value: unknown): Record<string, unknown> {
if (!isJsonObject(value)) return {};
return Object.fromEntries(
Object.entries(value)
.filter(([, v]) => isJsonObject(v))
.map(([k, v]) => [k, objectShape(v)]),
);
}
export function ConfigSection({ export function ConfigSection({
sectionPath, sectionPath,
defaultConfig, defaultConfig,
@@ -508,15 +492,6 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData], [rawFormData, sanitizeSectionData],
); );
// A section at its schema defaults may be absent from the YAML, and
// deleting an absent key fails. Non-default values must come from the YAML.
const globalDiffersFromDefaults = useMemo(() => {
if (!modifiedSchema) return true;
const current = omit(compareBaseData, sectionConfig.runtimeFields ?? []);
const defaults = applySchemaDefaults(modifiedSchema, objectShape(current));
return buildOverrides(current, undefined, defaults) !== undefined;
}, [compareBaseData, modifiedSchema, sectionConfig.runtimeFields]);
const orderedMaps = sectionConfig.orderedMaps; const orderedMaps = sectionConfig.orderedMaps;
const buildSectionOverrides = useCallback( const buildSectionOverrides = useCallback(
(current: unknown, base: unknown, defaults: unknown) => (current: unknown, base: unknown, defaults: unknown) =>
@@ -1158,8 +1133,7 @@ export function ConfigSection({
)} )}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto"> <div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{((effectiveLevel === "camera" && isOverridden) || {((effectiveLevel === "camera" && isOverridden) ||
(effectiveLevel === "global" && globalDiffersFromDefaults)) && effectiveLevel === "global") &&
!sectionConfig.hideReset &&
!hasChanges && !hasChanges &&
!skipSave && !skipSave &&
!profileName && ( !profileName && (
+1 -1
View File
@@ -473,7 +473,7 @@ export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
{ key: "review" }, { key: "review" },
{ key: "audio" }, { key: "audio" },
{ key: "notifications" }, { key: "notifications" },
{ key: "live", compareFields: ["transcode", "height", "quality"] }, { key: "live" },
{ key: "timestamp_style" }, { key: "timestamp_style" },
{ {
key: "audio_transcription", key: "audio_transcription",