From c03ddd9a1314457dbd7e63e99d6fe2aeea69ae0d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 Jan 2026 21:25:10 -0800 Subject: [PATCH] feat: Add severity media filter (#2323) --- src/camera-manager/frigate/camera.ts | 2 +- src/camera-manager/frigate/engine-frigate.ts | 49 +++-- src/camera-manager/frigate/media.ts | 2 +- src/camera-manager/frigate/requests.ts | 2 +- src/camera-manager/frigate/types.ts | 3 +- src/camera-manager/frigate/util.ts | 2 +- src/camera-manager/types.ts | 4 +- src/components-lib/media-filter-controller.ts | 80 ++++--- src/components/media-filter.ts | 19 +- src/components/overlay-message.ts | 8 +- src/config/schema/common/severity.ts | 2 +- src/localize/languages/en.json | 1 + src/query-source.ts | 7 +- src/scss/overlay-message.scss | 13 ++ src/severity.ts | 2 + src/types.ts | 4 +- src/view/item.ts | 2 +- src/view/unified-query-builder.ts | 1 + .../frigate/engine-frigate.test.ts | 199 +++++++++++++++++- .../media-filter-controller.test.ts | 51 +++++ tests/test-utils.ts | 2 +- tests/view/unified-query-builder.test.ts | 18 ++ 22 files changed, 404 insertions(+), 69 deletions(-) create mode 100644 src/severity.ts diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index ad8ff4ed..ad6a011c 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -6,12 +6,12 @@ import { CameraConfig } from '../../config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; +import { SEVERITIES } from '../../severity'; import { CapabilitiesRaw, Endpoint, PTZCapabilities, PTZMovementType, - SEVERITIES, } from '../../types'; import { errorToConsole } from '../../utils/basic'; import { Camera, CameraInitializationOptions } from '../camera'; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 888cd9ba..e1f15472 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -62,7 +62,6 @@ import { FrigateViewMediaClassifier } from './media-classifier'; import { NativeFrigateEventQuery, NativeFrigateRecordingSegmentsQuery, - NativeFrigateReviewQuery, getEventSummary, getEvents, getRecordingSegments, @@ -86,7 +85,7 @@ const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60; const REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS = 60; -class FrigateQueryResultsClassifier { +export class FrigateQueryResultsClassifier { public static isFrigateEventQueryResults( results: QueryResults, ): results is FrigateEventQueryResults { @@ -484,7 +483,14 @@ export class FrigateCameraManagerEngine query: ReviewQuery, engineOptions?: EngineOptions, ): Promise { - if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) { + if ( + hasUnsupportedFilters(query, { + what: true, + where: true, + reviewed: true, + severity: true, + }) + ) { return null; } @@ -505,23 +511,36 @@ export class FrigateCameraManagerEngine return; } - const nativeQuery: NativeFrigateReviewQuery = { - instance_id: instanceID, - cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)), - ...(query.what && { labels: Array.from(query.what) }), - ...(query.where && { zones: Array.from(query.where) }), - ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), - ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), - ...(query.limit && { limit: query.limit }), - ...(query.severity && { severity: FRIGATE_SEVERITY_MAP[query.severity] }), - ...(query.reviewed !== undefined && { reviewed: query.reviewed }), - }; + const severities = query.severity?.size ? Array.from(query.severity) : [undefined]; + + // Frigate only supports querying for a single severity, so we generate + // multiple queries and combine the results. + const reviewPromises = severities + // Frigate does not support a 'low' severity. + .filter((severity) => severity !== 'low') + .map((severity) => (!!severity ? FRIGATE_SEVERITY_MAP[severity] : undefined)) + .map( + async (frigateSeverity) => + await getReviews(hass, { + instance_id: instanceID, + cameras: Array.from( + this._getFrigateCameraNamesForCameraIDs(store, cameraIDs), + ), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(query.where) }), + ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), + ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), + ...(query.limit && { limit: query.limit }), + severity: frigateSeverity, + ...(query.reviewed !== undefined && { reviewed: query.reviewed }), + }), + ); const result: FrigateReviewQueryResults = { type: QueryResultsType.Review, engine: Engine.Frigate, instanceID: instanceID, - reviews: await getReviews(hass, nativeQuery), + reviews: (await Promise.all(reviewPromises)).flat(), expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }), cached: false, }; diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts index 56ce4eb9..ad7be211 100644 --- a/src/camera-manager/frigate/media.ts +++ b/src/camera-manager/frigate/media.ts @@ -1,7 +1,7 @@ import { fromUnixTime } from 'date-fns'; import { isEqual } from 'lodash-es'; import { CameraConfig } from '../../config/schema/cameras'; -import { Severity } from '../../types'; +import { Severity } from '../../severity'; import { EventViewMedia, RecordingViewMedia, diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts index 692b9221..2dbd7482 100644 --- a/src/camera-manager/frigate/requests.ts +++ b/src/camera-manager/frigate/requests.ts @@ -181,7 +181,7 @@ export const getPTZInfo = async ( ); }; -export interface NativeFrigateReviewQuery { +interface NativeFrigateReviewQuery { instance_id: string; cameras?: string[]; labels?: string[]; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index 31dc034f..36e478ba 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -146,7 +146,6 @@ export interface FrigateRecordingSegmentsQueryResults export const FRIGATE_SEVERITY_MAP = { high: 'alert', medium: 'detection', - low: 'significant_motion', } as const; export type FrigateReviewSeverity = @@ -170,7 +169,7 @@ const frigateReviewDataSchema = z.object({ const frigateReviewSchema = z.object({ id: z.string(), camera: z.string(), - severity: z.enum(['alert', 'detection', 'significant_motion']), + severity: z.enum(['alert', 'detection']), start_time: z.number(), end_time: z.number().nullable(), thumb_path: z.string().nullable(), diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts index 9a51da76..d7a01c07 100644 --- a/src/camera-manager/frigate/util.ts +++ b/src/camera-manager/frigate/util.ts @@ -1,6 +1,6 @@ import { toZonedTime } from 'date-fns-tz'; import { CameraConfig } from '../../config/schema/cameras'; -import { Severity } from '../../types'; +import { Severity } from '../../severity'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { FrigateEvent, diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index 0a696eaa..8a41d500 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -2,7 +2,7 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache'; import { SSLCiphers } from '../config/schema/cameras'; import { AdvancedCameraCardView } from '../config/schema/common/const'; import { BaseQuery, QueryFilters, QuerySource } from '../query-source'; -import { CapabilityKey, Endpoint, Icon, Severity } from '../types'; +import { CapabilityKey, Endpoint, Icon } from '../types'; import { ViewMedia } from '../view/item'; // ==== @@ -252,8 +252,6 @@ export interface MediaMetadataQueryResults extends QueryResults { export interface ReviewQuery extends MediaQuery { type: QueryType.Review; - - severity?: Severity; } export type PartialReviewQuery = Partial; diff --git a/src/components-lib/media-filter-controller.ts b/src/components-lib/media-filter-controller.ts index 7c92aaa2..27ecc87d 100644 --- a/src/components-lib/media-filter-controller.ts +++ b/src/components-lib/media-filter-controller.ts @@ -11,7 +11,7 @@ import { sub, } from 'date-fns'; import { LitElement } from 'lit'; -import { isEqual, orderBy, uniqWith } from 'lodash-es'; +import { isEqual, orderBy } from 'lodash-es'; import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager'; import { DateRange, PartialDateRange } from '../camera-manager/range'; import { @@ -25,6 +25,7 @@ import { ViewManagerInterface } from '../card-controller/view/types'; import { SelectOption, SelectValues } from '../components/select'; import { CardWideConfig } from '../config/schema/types'; import { localize } from '../localize/localize'; +import { SEVERITIES, Severity } from '../severity'; import { ViewMediaType } from '../types'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; import { UnifiedQueryBuilder } from '../view/unified-query-builder'; @@ -38,6 +39,7 @@ export interface MediaFilterCoreDefaults { when?: string; where?: string[]; tags?: string[]; + severity?: Severity[]; } export enum MediaFilterCoreFavoriteSelection { @@ -80,6 +82,7 @@ export class MediaFilterController { protected _tagsOptions: SelectOption[] = []; protected _favoriteOptions: SelectOption[]; protected _reviewedOptions: SelectOption[]; + protected _severityOptions: SelectOption[]; protected _defaults: MediaFilterCoreDefaults | null = null; protected _viewManager: ViewManagerInterface | null = null; @@ -125,6 +128,10 @@ export class MediaFilterController { label: localize('media_filter.not_reviewed'), }, ]; + this._severityOptions = SEVERITIES.map((severity) => ({ + value: severity, + label: localize(`common.severities.${severity}`), + })); this._staticWhenOptions = [ { value: MediaFilterCoreWhen.Today, @@ -174,6 +181,9 @@ export class MediaFilterController { public getReviewedOptions(): SelectOption[] { return this._reviewedOptions; } + public getSeverityOptions(): SelectOption[] { + return this._severityOptions; + } public getDefaults(): MediaFilterCoreDefaults | null { return this._defaults; } @@ -198,6 +208,7 @@ export class MediaFilterController { where?: SelectValues; what?: SelectValues; tags?: SelectValues; + severity?: SelectValues; }, // eslint-disable-next-line @typescript-eslint/no-unused-vars _ev?: unknown, @@ -221,6 +232,7 @@ export class MediaFilterController { const where = getArrayValueAsSet(values.where); const what = getArrayValueAsSet(values.what); const tags = getArrayValueAsSet(values.tags); + const severity = getArrayValueAsSet(values.severity); const limit = cardWideConfig.performance?.features.media_chunk_size; const builder = new UnifiedQueryBuilder(cameraManager, foldersManager); @@ -236,6 +248,7 @@ export class MediaFilterController { ...(tags && { tags }), ...(what && { what }), ...(where && { where }), + ...(severity && { severity }), }, ); @@ -280,6 +293,7 @@ export class MediaFilterController { let favorite: MediaFilterCoreFavoriteSelection | undefined; let reviewed: MediaFilterCoreReviewedSelection | undefined; let tags: string[] | undefined; + let severity: Severity[] | undefined; const cameraIDsFromQuery = query.getAllCameraIDs(); @@ -326,44 +340,38 @@ export class MediaFilterController { mediaTypes.push(MediaFilterMediaType.Reviews); } - if (eventQueries.length > 0) { - const whatSets = uniqWith( - eventQueries.map((q) => q.what), - isEqual, - ); - if (whatSets.length === 1 && eventQueries[0].what?.size) { - what = [...eventQueries[0].what]; - } - const whereSets = uniqWith( - eventQueries.map((q) => q.where), - isEqual, - ); - if (whereSets.length === 1 && eventQueries[0].where?.size) { - where = [...eventQueries[0].where]; - } - const tagsSets = uniqWith( - eventQueries.map((q) => q.tags), - isEqual, - ); - if (tagsSets.length === 1 && eventQueries[0].tags?.size) { - tags = [...eventQueries[0].tags]; - } + const whatSets = eventQueries.map((q) => q.what); + if (this._hasSingleUniqueValue(whatSets) && whatSets[0]?.size) { + what = [...whatSets[0]]; + } + + const whereSets = eventQueries.map((q) => q.where); + if (this._hasSingleUniqueValue(whereSets) && whereSets[0]?.size) { + where = [...whereSets[0]]; + } + + const tagsSets = eventQueries.map((q) => q.tags); + if (this._hasSingleUniqueValue(tagsSets) && tagsSets[0]?.size) { + tags = [...tagsSets[0]]; } // Extract reviewed from review queries (only if explicitly set to true/false) const reviewQueries = query.getMediaQueries({ type: QueryType.Review }); - if (reviewQueries.length > 0) { - const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed)); - if (reviewedValues.size === 1) { - const rev = [...reviewedValues][0]; - if (rev !== undefined) { - reviewed = rev - ? MediaFilterCoreReviewedSelection.Reviewed - : MediaFilterCoreReviewedSelection.NotReviewed; - } + const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed)); + if (reviewedValues.size === 1) { + const rev = [...reviewedValues][0]; + if (rev !== undefined) { + reviewed = rev + ? MediaFilterCoreReviewedSelection.Reviewed + : MediaFilterCoreReviewedSelection.NotReviewed; } } + const severitySets = reviewQueries.map((q) => q.severity); + if (this._hasSingleUniqueValue(severitySets) && severitySets[0]?.size) { + severity = [...severitySets[0]]; + } + this._defaults = { ...(mediaTypes.length && { mediaTypes }), ...(cameraIDs && { cameraIDs }), @@ -372,6 +380,7 @@ export class MediaFilterController { ...(favorite !== undefined && { favorite }), ...(reviewed !== undefined && { reviewed }), ...(tags && { tags }), + ...(severity && { severity }), }; } @@ -489,4 +498,11 @@ export class MediaFilterController { return this._stringToDateRange(values.selected); } } + + protected _hasSingleUniqueValue(sets: (Set | undefined)[]): boolean { + if (sets.length === 0) { + return false; + } + return sets.every((s) => isEqual(s, sets[0])); + } } diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts index c88e3345..a48d1a71 100644 --- a/src/components/media-filter.ts +++ b/src/components/media-filter.ts @@ -59,6 +59,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { protected _refWhere: Ref = createRef(); protected _refFavorite: Ref = createRef(); protected _refReviewed: Ref = createRef(); + protected _refSeverity: Ref = createRef(); protected _refTags: Ref = createRef(); protected willUpdate(changedProps: PropertyValues): void { @@ -119,6 +120,7 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { where: this._refWhere.value?.value ?? undefined, what: this._refWhat.value?.value ?? undefined, tags: this._refTags.value?.value ?? undefined, + severity: this._refSeverity.value?.value ?? undefined, }, ); }; @@ -260,7 +262,22 @@ class AdvancedCameraCardMediaFilter extends ScopedRegistryHost(LitElement) { clearable @advanced-camera-card:select:change=${() => valueChange()} > - `; + + ${this._mediaFilterController.getSeverityOptions().length + ? html` + valueChange()} + > + + ` + : ''}`; } static get styles(): CSSResultGroup { diff --git a/src/components/overlay-message.ts b/src/components/overlay-message.ts index cce58369..590511b9 100644 --- a/src/components/overlay-message.ts +++ b/src/components/overlay-message.ts @@ -1,5 +1,6 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import overlayMessageStyle from '../scss/overlay-message.scss'; import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js'; @@ -90,8 +91,13 @@ export class AdvancedCameraCardOverlayMessage extends LitElement { } protected _renderDetail(detail: MetadataField, isHeading = false): TemplateResult { + const classes = { + detail: true, + heading: isHeading, + [`emphasis-${detail.emphasis}`]: !!detail.emphasis, + }; return html` -
+
${detail.icon ? html`; where?: Set; reviewed?: boolean; + severity?: Set; } /** @@ -32,6 +35,7 @@ interface SupportedFilters { what?: boolean; where?: boolean; reviewed?: boolean; + severity?: boolean; } /** @@ -47,6 +51,7 @@ export const hasUnsupportedFilters = ( (!supported.tags && !!query.tags?.size) || (!supported.what && !!query.what?.size) || (!supported.where && !!query.where?.size) || - (!supported.reviewed && query.reviewed !== undefined) + (!supported.reviewed && query.reviewed !== undefined) || + (!supported.severity && !!query.severity?.size) ); }; diff --git a/src/scss/overlay-message.scss b/src/scss/overlay-message.scss index 60a16a1b..290c6c6a 100644 --- a/src/scss/overlay-message.scss +++ b/src/scss/overlay-message.scss @@ -260,6 +260,19 @@ opacity: 1; } } + + &.emphasis-low advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-low-color); + opacity: 1; + } + &.emphasis-medium advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-medium-color); + opacity: 1; + } + &.emphasis-high advanced-camera-card-icon { + color: var(--advanced-camera-card-foreground-severity-high-color); + opacity: 1; + } } .description { diff --git a/src/severity.ts b/src/severity.ts new file mode 100644 index 00000000..a5990cfa --- /dev/null +++ b/src/severity.ts @@ -0,0 +1,2 @@ +export const SEVERITIES = ['high', 'medium', 'low'] as const; +export type Severity = (typeof SEVERITIES)[number]; diff --git a/src/types.ts b/src/types.ts index 3d4dc9b3..b00a9028 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { EffectOptions } from './components-lib/effects/types'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; +import { Severity } from './severity'; // UI-facing media types for galleries and views. export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const; @@ -221,6 +222,3 @@ export interface EffectsControllerAPI { stopEffect(effect: EffectName): void; toggleEffect(effect: EffectName, options?: EffectOptions): Promise; } - -export const SEVERITIES = ['high', 'medium', 'low'] as const; -export type Severity = (typeof SEVERITIES)[number]; diff --git a/src/view/item.ts b/src/view/item.ts index a3f1521e..8d9decbc 100644 --- a/src/view/item.ts +++ b/src/view/item.ts @@ -1,7 +1,7 @@ import { clone } from 'lodash-es'; import { FolderPathComponent } from '../card-controller/folders/types'; import { FolderConfig } from '../config/schema/folders'; -import { Severity } from '../types'; +import { Severity } from '../severity'; export enum ViewMediaType { Clip = 'clip', diff --git a/src/view/unified-query-builder.ts b/src/view/unified-query-builder.ts index a01ace7f..fc87764c 100644 --- a/src/view/unified-query-builder.ts +++ b/src/view/unified-query-builder.ts @@ -537,6 +537,7 @@ export class UnifiedQueryBuilder { ...(options?.what && { what: options.what }), ...(options?.where && { where: options.where }), ...(options?.reviewed !== undefined && { reviewed: options.reviewed }), + ...(options?.severity && { severity: options.severity }), }; } diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index cb3e0f55..cccd63ab 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -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(); + 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(); + 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(['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(); + 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(['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(); + 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, + }), + ); + }); +}); diff --git a/tests/components-lib/media-filter-controller.test.ts b/tests/components-lib/media-filter-controller.test.ts index c7f424b6..068fcc19 100644 --- a/tests/components-lib/media-filter-controller.test.ts +++ b/tests/components-lib/media-filter-controller.test.ts @@ -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(['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.getView.mockReturnValue(createView()); + + const controller = new MediaFilterController(host); + controller.setViewManager(viewManager); + + await controller.valueChangeHandler( + cameraManager, + mock(), + {}, + { + 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()); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 136f5569..80a2f76a 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -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, diff --git a/tests/view/unified-query-builder.test.ts b/tests/view/unified-query-builder.test.ts index 6e70957e..ca36bd14 100644 --- a/tests/view/unified-query-builder.test.ts +++ b/tests/view/unified-query-builder.test.ts @@ -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'])); + }); }); });