feat: Add severity media filter (#2323)

This commit is contained in:
Dermot Duffy
2026-01-24 21:25:10 -08:00
committed by GitHub
parent f4e905a7fb
commit c03ddd9a13
22 changed files with 404 additions and 69 deletions
@@ -1,19 +1,33 @@
import { describe, expect, it } from 'vitest';
import { afterEach, assert, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { RecordingSegmentsCache } from '../../../src/camera-manager/cache';
import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate';
import {
FrigateCameraManagerEngine,
FrigateQueryResultsClassifier,
} from '../../../src/camera-manager/frigate/engine-frigate';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
} from '../../../src/camera-manager/frigate/media';
import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js';
import { CameraManagerRequestCache } from '../../../src/camera-manager/types';
import { getReviews } from '../../../src/camera-manager/frigate/requests';
import {
FrigateEvent,
FrigateReview,
eventSchema,
} from '../../../src/camera-manager/frigate/types.js';
import { CameraManagerStore } from '../../../src/camera-manager/store';
import { CameraManagerRequestCache, QueryType } from '../../../src/camera-manager/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { CameraConfig } from '../../../src/config/schema/cameras';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import { QuerySource } from '../../../src/query-source';
import { Severity } from '../../../src/severity';
import { ViewMedia, ViewMediaType } from '../../../src/view/item';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock';
import { createCameraConfig, createHASS } from '../../test-utils';
vi.mock('../../../src/camera-manager/frigate/requests');
const createEngine = (): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine(
new EntityRegistryManagerMock(),
@@ -56,6 +70,17 @@ const createEvent = (): FrigateEvent => {
});
};
const createFrigateReview = (id: string): FrigateReview => ({
id,
camera: 'camera-1',
start_time: 100,
end_time: 200,
severity: 'alert',
thumb_path: 'thumb.jpg',
data: { objects: [], zones: [] },
has_been_reviewed: false,
});
const createClipMedia = (): FrigateEventViewMedia => {
return new FrigateEventViewMedia(
ViewMediaType.Clip,
@@ -140,3 +165,169 @@ describe('getMediaDownloadPath', () => {
expect(endpoint).toBeNull();
});
});
describe('getReviews', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('should fan out requests for multiple severities', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
const reviewHigh = createFrigateReview('review-high');
const reviewMedium = createFrigateReview('review-medium');
vi.mocked(getReviews)
.mockResolvedValueOnce([reviewHigh])
.mockResolvedValueOnce([reviewMedium]);
const resultsMap = await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set(['high', 'medium']),
});
expect(getReviews).toHaveBeenCalledTimes(2);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'alert',
}),
);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'detection',
}),
);
const result = resultsMap?.values().next().value;
assert(result);
assert(FrigateQueryResultsClassifier.isFrigateReviewQueryResults(result));
expect(result.reviews).toHaveLength(2);
expect(result.reviews).toContainEqual(reviewHigh);
expect(result.reviews).toContainEqual(reviewMedium);
});
it('should handle single severity', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
const review = createFrigateReview('review-low');
vi.mocked(getReviews).mockResolvedValue([review]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set<Severity>(['high']),
});
expect(getReviews).toHaveBeenCalledTimes(1);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: 'alert',
}),
);
});
it('should ignore low severity', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: new Set<Severity>(['low']),
});
expect(getReviews).not.toHaveBeenCalled();
});
it('should query all valid severities when severity is undefined', async () => {
const engine = createEngine();
const hass = createHASS();
const cameraConfig = createCameraConfig({
frigate: { camera_name: 'camera-1', client_id: 'client-1' },
});
const store = mock<CameraManagerStore>();
store.getCameraIDs.mockReturnValue(new Set(['camera-1']));
store.getCameraIDsWithCapability.mockReturnValue(new Set(['camera-1']));
store.getCameraConfig.mockReturnValue(cameraConfig);
store.getCameraConfigs.mockImplementation(function* () {
yield cameraConfig;
});
store.getCameraConfigEntries.mockImplementation(function* () {
yield ['camera-1', cameraConfig];
});
store.hasCameraID.mockReturnValue(true);
vi.mocked(getReviews).mockResolvedValue([]);
await engine.getReviews(hass, store, {
type: QueryType.Review,
source: QuerySource.Camera,
cameraIDs: new Set(['camera-1']),
severity: undefined,
});
expect(getReviews).toHaveBeenCalledTimes(1);
expect(getReviews).toHaveBeenCalledWith(
hass,
expect.objectContaining({
severity: undefined,
}),
);
});
});
@@ -29,6 +29,7 @@ import {
MediaFilterMediaType,
} from '../../src/components-lib/media-filter-controller';
import { QuerySource } from '../../src/query-source';
import { Severity } from '../../src/severity';
import { UnifiedQuery } from '../../src/view/unified-query';
import {
createCameraConfig,
@@ -317,6 +318,17 @@ const queryDefaultTestCases: Array<[string, UnifiedQuery, MediaFilterCoreDefault
mediaTypes: [MediaFilterMediaType.Reviews],
},
],
[
'severity',
createQueryWithNodes([
{ type: QueryType.Review, severity: new Set<Severity>(['high']) },
]),
{
cameraIDs: ['camera.kitchen'],
mediaTypes: [MediaFilterMediaType.Reviews],
severity: ['high'],
},
],
];
// @vitest-environment jsdom
@@ -382,6 +394,15 @@ describe('MediaFilterController', () => {
const controller = new MediaFilterController(createLitElement());
expect(controller.getTagsOptions()).toEqual([]);
});
it('severity', () => {
const controller = new MediaFilterController(createLitElement());
expect(controller.getSeverityOptions()).toEqual([
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
]);
});
});
describe('should calculate correct dynamic options', () => {
@@ -675,6 +696,36 @@ describe('MediaFilterController', () => {
});
});
it('with severity', async () => {
const host = createLitElement();
const cameraManager = createCameraManager(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(host);
controller.setViewManager(viewManager);
await controller.valueChangeHandler(
cameraManager,
mock<FoldersManager>(),
{},
{
mediaTypes: [MediaFilterMediaType.Reviews],
when: {},
severity: ['high'],
},
);
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalled();
const nodes = getQueryNodes(viewManager);
expect(nodes).toHaveLength(1);
expect(nodes?.[0]).toMatchObject({
type: QueryType.Review,
severity: new Set(['high']),
});
});
it('with multiple media types', async () => {
const host = createLitElement();
const cameraManager = createCameraManager(createCameraStore());
+1 -1
View File
@@ -70,12 +70,12 @@ import { Device } from '../src/ha/registry/device/types';
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { QuerySource } from '../src/query-source';
import { Severity } from '../src/severity';
import {
CapabilitiesRaw,
EffectsControllerAPI,
Interaction,
MediaLoadedInfo,
Severity,
} from '../src/types';
import {
EventViewMedia,
+18
View File
@@ -462,6 +462,24 @@ describe('UnifiedQueryBuilder', () => {
expect(nodes[0].what).toEqual(new Set(['person']));
expect(nodes[0].where).toEqual(new Set(['zone']));
});
it('should apply severity filter', () => {
const { cameraManager, foldersManager } = createMocks();
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
const query = builder.buildFilterQuery(
new Set(['camera.office']),
new Set(['clips']),
{
severity: new Set(['high']),
},
);
assert(query);
const nodes = query.getNodes();
expect(nodes).toHaveLength(1);
expect(nodes[0].severity).toEqual(new Set(['high']));
});
});
});