feat: Add support for Frigate reviews / detections [initial PR] (#2315)

- Add support for Frigate reviews / detections.
 - Add support for GenAI metadata.
- Significant internal refactor to more flexible "UnifiedQuery" to allow
mixing cameras with simple metadata and review metadata (e.g. a timeline
view of a Frigate camera with reviews, and a Reolink camera with simple
metadata).
 - Add support for folder media as camera media.

There are a few more PRs to commit prior to this going live, but
commiting this for now due to the scale of the change.

BREAKING CHANGE: `media_type` and `events_type` are retired under
`live`, `viewer` and `timeline` configuration sections, instead media
type is associated (once) with the camera under `media`.
This commit is contained in:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
@@ -6,6 +6,7 @@ import { getMediaDownloadPath } from '../../ha/download';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { QuerySource } from '../../query-source.js';
import { Endpoint } from '../../types';
import { ViewMedia } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types';
@@ -55,6 +56,7 @@ export class BrowseMediaCameraManagerEngine
): EventQuery[] | null {
return [
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: cameraIDs,
...query,
+41
View File
@@ -8,6 +8,7 @@ import { CameraManagerReadOnlyConfigStore } from './store';
import {
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -17,11 +18,15 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -31,6 +36,15 @@ export interface CameraManagerEngine {
createCamera(hass: HomeAssistant, cameraConfig: CameraConfig): Promise<Camera>;
/**
* Get default query parameters for a camera based on its configuration.
* Engines read their own config and return generic filter params.
*/
getDefaultQueryParameters(
camera: Camera,
queryType: QueryType,
): DefaultQueryParameters;
generateDefaultEventQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
@@ -70,6 +84,19 @@ export interface CameraManagerEngine {
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null>;
generateDefaultReviewQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialReviewQuery,
): ReviewQuery[] | null;
getReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null>;
generateMediaFromEvents(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
@@ -84,6 +111,13 @@ export interface CameraManagerEngine {
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null;
generateMediaFromReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null;
getMediaDownloadPath(
hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -97,6 +131,13 @@ export interface CameraManagerEngine {
favorite: boolean,
): Promise<void>;
reviewMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
reviewed: boolean,
): Promise<void>;
getQueryResultMaxAge(query: CameraQuery): number | null;
getMediaSeekTime(
+102 -8
View File
@@ -11,6 +11,7 @@ import {
Endpoint,
PTZCapabilities,
PTZMovementType,
SEVERITIES,
} from '../../types';
import { errorToConsole } from '../../utils/basic';
import { Camera, CameraInitializationOptions } from '../camera';
@@ -22,18 +23,21 @@ import {
getGo2RTCStreamEndpoint,
} from '../utils/go2rtc/endpoint';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
import {
FrigateEventWatcherRequest,
FrigateEventWatcherSubscriptionInterface,
} from './event-watcher';
import { getPTZInfo } from './requests';
import { FrigateEventChange, PTZInfo } from './types';
import {
FRIGATE_SEVERITY_MAP,
FrigateEventChange,
FrigateReviewChange,
PTZInfo,
} from './types';
import { FrigateWatcherRequest, FrigateWatcherSubscriptionInterface } from './watcher';
const CAMERA_BIRDSEYE = 'birdseye' as const;
interface FrigateCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface;
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>;
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>;
}
export const isBirdseye = (cameraConfig: CameraConfig): boolean => {
@@ -47,6 +51,7 @@ export class FrigateCamera extends Camera {
if (this._capabilities?.has('trigger')) {
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
await this._subscribeToReviews(options.hass, options.frigateReviewWatcher);
}
return this;
@@ -183,6 +188,7 @@ export class FrigateCamera extends Camera {
clips: !birdseye,
snapshots: !birdseye,
recordings: !birdseye,
reviews: !birdseye,
...(combinedPTZ && { ptz: combinedPTZ }),
};
}
@@ -415,7 +421,7 @@ export class FrigateCamera extends Camera {
protected async _subscribeToEvents(
hass: HomeAssistant,
frigateEventWatcher: FrigateEventWatcherSubscriptionInterface,
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
): Promise<void> {
const config = this.getConfig();
if (!config.triggers.events.length || !config.frigate.camera_name) {
@@ -424,7 +430,7 @@ export class FrigateCamera extends Camera {
/* istanbul ignore next -- exercising the matcher is not possible when the
test uses an event watcher -- @preserve */
const request: FrigateEventWatcherRequest = {
const request: FrigateWatcherRequest<FrigateEventChange> = {
instanceID: config.frigate.client_id,
callback: (event: FrigateEventChange) => this._frigateEventHandler(event),
matcher: (event: FrigateEventChange): boolean =>
@@ -480,4 +486,92 @@ export class FrigateCamera extends Camera {
snapshot: snapshotChange && eventsToTriggerOn.includes('snapshots'),
});
};
protected async _subscribeToReviews(
hass: HomeAssistant,
frigateReviewWatcher: FrigateWatcherSubscriptionInterface<FrigateReviewChange>,
): Promise<void> {
const config = this.getConfig();
const reviewConfig = config.triggers.reviews;
// Must have at least one severity configured and a camera name to subscribe
if (!reviewConfig.severities.length || !config.frigate.camera_name) {
return;
}
/* istanbul ignore next -- exercising the matcher is not possible when the
test uses a review watcher -- @preserve */
const request: FrigateWatcherRequest<FrigateReviewChange> = {
instanceID: config.frigate.client_id,
callback: (review: FrigateReviewChange) => this._frigateReviewHandler(review),
matcher: (review: FrigateReviewChange): boolean =>
review.after.camera === config.frigate.camera_name,
};
await frigateReviewWatcher.subscribe(hass, request);
this._onDestroy(() => frigateReviewWatcher.unsubscribe(request));
}
protected _frigateReviewHandler = (review: FrigateReviewChange): void => {
const config = this.getConfig();
const cameraID = this._config.id;
if (!cameraID) {
return;
}
if (
config.frigate.zones?.length &&
!config.frigate.zones.some((zone) => review.after.data.zones?.includes(zone))
) {
return;
}
if (
config.frigate.labels?.length &&
!config.frigate.labels.some((label) => review.after.data.objects?.includes(label))
) {
return;
}
const reviewConfig = config.triggers.reviews;
// Map Frigate severity to card severity.
const cardSeverity = SEVERITIES.find(
(key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity,
);
// Check if this is a description update (GenAI added/changed title or scene)
const isDescriptionUpdate =
review.type === 'genai' ||
(review.type === 'update' &&
(review.after.data.metadata?.title !== review.before.data.metadata?.title ||
review.after.data.metadata?.scene !== review.before.data.metadata?.scene ||
review.after.data.metadata?.shortSummary !==
review.before.data.metadata?.shortSummary));
const shouldTriggerOnSeverity =
cardSeverity && reviewConfig.severities.includes(cardSeverity);
// Severity must match first - it's the gate condition.
if (!shouldTriggerOnSeverity) {
return;
}
// For 'update' events, only trigger if description changed (when
// description updates are on). For 'new' and 'end' events, always trigger
// if severity matched
const shouldTriggerOnDescription = reviewConfig.description && isDescriptionUpdate;
if (review.type === 'update' && !shouldTriggerOnDescription) {
return;
}
this._eventCallback?.({
cameraID,
fidelity: 'high',
type: review.type,
review: true,
});
};
}
+273 -49
View File
@@ -5,6 +5,7 @@ import { CameraConfig } from '../../config/schema/cameras';
import { getEntityTitle } from '../../ha/get-entity-title';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
import { QuerySource, hasUnsupportedFilters } from '../../query-source.js';
import { Endpoint } from '../../types';
import {
allPromises,
@@ -29,6 +30,7 @@ import {
CameraManagerCameraMetadata,
CameraManagerRequestCache,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -40,6 +42,7 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -50,30 +53,38 @@ import {
RecordingSegment,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from '../types';
import { FrigateCamera, isBirdseye } from './camera';
import { FrigateEventWatcher } from './event-watcher';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import {
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
NativeFrigateReviewQuery,
getEventSummary,
getEvents,
getRecordingSegments,
getRecordingsSummary,
getReviews,
retainEvent,
setReviewsReviewed,
} from './requests';
import {
FRIGATE_SEVERITY_MAP,
FrigateEventQueryResults,
FrigateRecording,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
FrigateReviewQueryResults,
} from './types';
import { FrigateEventWatcher, FrigateReviewWatcher } from './watcher';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
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 {
public static isFrigateEventQueryResults(
@@ -98,6 +109,12 @@ class FrigateQueryResultsClassifier {
results.type === QueryResultsType.RecordingSegments
);
}
public static isFrigateReviewQueryResults(
results: QueryResults,
): results is FrigateReviewQueryResults {
return results.engine === Engine.Frigate && results.type === QueryResultsType.Review;
}
}
export class FrigateCameraManagerEngine
@@ -106,6 +123,7 @@ export class FrigateCameraManagerEngine
{
protected _entityRegistryManager: EntityRegistryManager;
protected _frigateEventWatcher: FrigateEventWatcher;
protected _frigateReviewWatcher: FrigateReviewWatcher;
protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: CameraManagerRequestCache;
@@ -126,6 +144,7 @@ export class FrigateCameraManagerEngine
super(stateWatcher, eventCallback);
this._entityRegistryManager = entityRegistryManager;
this._frigateEventWatcher = new FrigateEventWatcher();
this._frigateReviewWatcher = new FrigateReviewWatcher();
this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache;
}
@@ -146,9 +165,25 @@ export class FrigateCameraManagerEngine
entityRegistryManager: this._entityRegistryManager,
stateWatcher: this._stateWatcher,
frigateEventWatcher: this._frigateEventWatcher,
frigateReviewWatcher: this._frigateReviewWatcher,
});
}
public override getDefaultQueryParameters(
camera: Camera,
queryType: QueryType,
): DefaultQueryParameters {
if (queryType !== QueryType.Event && queryType !== QueryType.Review) {
return {};
}
const cameraConfig = camera.getConfig();
return {
...(cameraConfig.frigate.labels && { what: new Set(cameraConfig.frigate.labels) }),
...(cameraConfig.frigate.zones && { where: new Set(cameraConfig.frigate.zones) }),
};
}
public async getMediaDownloadPath(
_hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -182,51 +217,10 @@ export class FrigateCameraManagerEngine
cameraIDs: Set<string>,
query?: PartialEventQuery,
): EventQuery[] | null {
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
// If all cameras specify exactly the same zones or labels (incl. none), we
// can use a single batch query which will be better performance wise,
// otherwise we must fan out to multiple queries in order to precisely match
// the user's intent.
const uniqueZoneArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.zones),
isEqual,
);
const uniqueLabelArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.labels),
isEqual,
);
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
return [
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
...query,
},
];
}
const output: EventQuery[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = store.getCameraConfig(cameraID);
if (cameraConfig) {
output.push({
type: QueryType.Event,
cameraIDs: new Set([cameraID]),
...(cameraConfig.frigate.labels && {
what: new Set(cameraConfig.frigate.labels),
}),
...(cameraConfig.frigate.zones && {
where: new Set(cameraConfig.frigate.zones),
}),
...query,
});
}
}
return output.length ? output : null;
return this._generateBatchableQuery(store, cameraIDs, {
type: QueryType.Event,
...query,
});
}
public generateDefaultRecordingQuery(
@@ -236,6 +230,7 @@ export class FrigateCameraManagerEngine
): RecordingQuery[] {
return [
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: cameraIDs,
...query,
@@ -262,6 +257,86 @@ export class FrigateCameraManagerEngine
];
}
public generateDefaultReviewQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialReviewQuery,
): ReviewQuery[] | null {
return this._generateBatchableQuery(store, cameraIDs, {
type: QueryType.Review,
...query,
});
}
/**
* Helper to generate batchable queries for events and reviews.
* If all cameras have identical zones/labels config, creates a single batch query.
* Otherwise fans out to per-camera queries.
*/
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialEventQuery & { type: QueryType.Event },
): EventQuery[] | null;
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialReviewQuery & { type: QueryType.Review },
): ReviewQuery[] | null;
protected _generateBatchableQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: (PartialEventQuery | PartialReviewQuery) & {
type: QueryType.Event | QueryType.Review;
},
): (EventQuery | ReviewQuery)[] | null {
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
// If all cameras specify exactly the same zones or labels (incl. none), we
// can use a single batch query which will be better performance wise,
// otherwise we must fan out to multiple queries in order to precisely match
// the user's intent.
const uniqueZoneArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.zones),
isEqual,
);
const uniqueLabelArrays = uniqWith(
relevantCameraConfigs.map((config) => config?.frigate.labels),
isEqual,
);
if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) {
return [
{
source: QuerySource.Camera,
...query,
cameraIDs: cameraIDs,
...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }),
...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }),
},
];
}
const output: (EventQuery | ReviewQuery)[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = store.getCameraConfig(cameraID);
if (cameraConfig) {
output.push({
source: QuerySource.Camera,
...query,
cameraIDs: new Set([cameraID]),
...(cameraConfig.frigate.labels && {
what: new Set(cameraConfig.frigate.labels),
}),
...(cameraConfig.frigate.zones && {
where: new Set(cameraConfig.frigate.zones),
}),
});
}
}
return output.length ? output : null;
}
public async favoriteMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
@@ -276,6 +351,24 @@ export class FrigateCameraManagerEngine
media.setFavorite(favorite);
}
public async reviewMedia(
hass: HomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
reviewed: boolean,
): Promise<void> {
if (!FrigateViewMediaClassifier.isFrigateReview(media)) {
return;
}
await setReviewsReviewed(
hass,
cameraConfig.frigate.client_id,
[media.getID()],
reviewed,
);
}
protected _buildInstanceToCameraIDMapFromQuery(
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
@@ -314,6 +407,17 @@ export class FrigateCameraManagerEngine
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
if (
hasUnsupportedFilters(query, {
favorite: true,
tags: true,
what: true,
where: true,
})
) {
return null;
}
const output: EventQueryResultsMap = new Map();
const processInstanceQuery = async (
@@ -374,12 +478,80 @@ export class FrigateCameraManagerEngine
return output.size ? output : null;
}
public async getReviews(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null> {
if (hasUnsupportedFilters(query, { what: true, where: true, reviewed: true })) {
return null;
}
const output: ReviewQueryResultsMap = new Map();
const processInstanceQuery = async (
instanceID: string,
cameraIDs?: Set<string>,
): Promise<void> => {
if (!cameraIDs || !cameraIDs.size) {
return;
}
const instanceQuery = { ...query, cameraIDs: cameraIDs };
const cachedResult =
engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null;
if (cachedResult) {
output.set(query, cachedResult as FrigateReviewQueryResults);
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 result: FrigateReviewQueryResults = {
type: QueryResultsType.Review,
engine: Engine.Frigate,
instanceID: instanceID,
reviews: await getReviews(hass, nativeQuery),
expiry: add(new Date(), { seconds: REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS }),
cached: false,
};
if (engineOptions?.useCache ?? true) {
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
}
output.set(instanceQuery, result);
};
const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs);
await Promise.all(
Array.from(instances.keys()).map((instanceID) =>
processInstanceQuery(instanceID, instances.get(instanceID)),
),
);
return output.size ? output : null;
}
public async getRecordings(
hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> {
if (hasUnsupportedFilters(query)) {
return null;
}
const output: RecordingQueryResultsMap = new Map();
const processQuery = async (
@@ -641,11 +813,50 @@ export class FrigateCameraManagerEngine
return output;
}
public generateMediaFromReviews(
_hass: HomeAssistant,
store: CameraManagerReadOnlyConfigStore,
query: ReviewQuery,
results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null {
if (!FrigateQueryResultsClassifier.isFrigateReviewQueryResults(results)) {
return null;
}
const output: ViewMedia[] = [];
for (const review of results.reviews) {
const cameraID = this._getCameraIDMatch(
store,
query,
results.instanceID,
review.camera,
);
if (!cameraID) {
continue;
}
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig) {
continue;
}
const media = FrigateViewMediaFactory.createReviewViewMedia(
cameraID,
review,
cameraConfig,
);
if (media) {
output.push(media);
}
}
return output;
}
public getQueryResultMaxAge(query: CameraQuery): number | null {
if (query.type === QueryType.Event) {
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Recording) {
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
} else if (query.type === QueryType.Review) {
return REVIEW_REQUEST_CACHE_MAX_AGE_SECONDS;
}
return null;
}
@@ -657,11 +868,22 @@ export class FrigateCameraManagerEngine
target: Date,
engineOptions?: EngineOptions,
): Promise<number | null> {
const start = media.getStartTime();
const end = media.getEndTime();
const mediaStart = media.getStartTime();
const mediaEnd = media.getEndTime();
const cameraID = media.getCameraID();
const mediaType = media.getMediaType();
if (!start || !end || target < start || target > end || !cameraID) {
if (!mediaStart || !cameraID) {
return null;
}
// For recordings/reviews, use hour boundaries since Frigate recordings are
// hour-long. For clips/snapshots, use the actual media time range.
const isRecordingBased = mediaType === 'recording' || mediaType === 'review';
const start = isRecordingBased ? startOfHour(mediaStart) : mediaStart;
const end = isRecordingBased ? endOfHour(mediaStart) : mediaEnd;
if (!end || target < start || target > end) {
return null;
}
@@ -762,6 +984,7 @@ export class FrigateCameraManagerEngine
hass,
store,
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: cameraIDs,
},
@@ -822,6 +1045,7 @@ export class FrigateCameraManagerEngine
): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQuery: RecordingQuery = {
source: QuerySource.Camera,
cameraIDs: new Set(cameraIDs),
type: QueryType.Recording,
};
@@ -1,77 +0,0 @@
import { HomeAssistant } from '../../ha/types';
import { FrigateEventChange, frigateEventChangeSchema } from './types';
export interface FrigateEventWatcherRequest {
instanceID: string;
matcher?(event: FrigateEventChange): boolean;
callback(event: FrigateEventChange): void;
}
export interface FrigateEventWatcherSubscriptionInterface {
subscribe(hass: HomeAssistant, request: FrigateEventWatcherRequest): Promise<void>;
unsubscribe(callback: FrigateEventWatcherRequest): void;
}
type SubscriptionUnsubscribe = () => Promise<void>;
export class FrigateEventWatcher implements FrigateEventWatcherSubscriptionInterface {
protected _requests: FrigateEventWatcherRequest[] = [];
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
public async subscribe(
hass: HomeAssistant,
request: FrigateEventWatcherRequest,
): Promise<void> {
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
this._requests.push(request);
if (shouldSubscribe) {
this._unsubscribeCallback[request.instanceID] =
await hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
{ type: 'frigate/events/subscribe', instance_id: request.instanceID },
);
}
}
public async unsubscribe(request: FrigateEventWatcherRequest): Promise<void> {
this._requests = this._requests.filter(
(existingRequest) => existingRequest !== request,
);
if (!this._hasSubscribers(request.instanceID)) {
await this._unsubscribeCallback[request.instanceID]();
delete this._unsubscribeCallback[request.instanceID];
}
}
protected _hasSubscribers(instanceID: string): boolean {
return !!this._requests.filter((request) => request.instanceID === instanceID)
.length;
}
protected _receiveHandler(instanceID: string, data: string): void {
let json: unknown;
try {
json = JSON.parse(data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.warn('Received non-JSON payload as Frigate event', data);
return;
}
const parsedEvent = frigateEventChangeSchema.safeParse(json);
if (!parsedEvent.success) {
console.warn('Received malformed Frigate event from Home Assistant', data);
return;
}
for (const request of this._requests) {
if (
request.instanceID === instanceID &&
(!request.matcher || request.matcher(parsedEvent.data))
) {
request.callback(parsedEvent.data);
}
}
}
}
+17 -3
View File
@@ -1,11 +1,22 @@
import { ViewMedia } from '../../view/item';
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
import {
FrigateEventViewMedia,
FrigateRecordingViewMedia,
FrigateReviewViewMedia,
} from './media';
export class FrigateViewMediaClassifier {
public static isFrigateMedia(
media: ViewMedia,
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
): media is
| FrigateEventViewMedia
| FrigateRecordingViewMedia
| FrigateReviewViewMedia {
return (
this.isFrigateEvent(media) ||
this.isFrigateRecording(media) ||
this.isFrigateReview(media)
);
}
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
return media instanceof FrigateEventViewMedia;
@@ -15,4 +26,7 @@ export class FrigateViewMediaClassifier {
): media is FrigateRecordingViewMedia {
return media instanceof FrigateRecordingViewMedia;
}
public static isFrigateReview(media: ViewMedia): media is FrigateReviewViewMedia {
return media instanceof FrigateReviewViewMedia;
}
}
+99 -1
View File
@@ -1,14 +1,16 @@
import { fromUnixTime } from 'date-fns';
import { isEqual } from 'lodash-es';
import { CameraConfig } from '../../config/schema/cameras';
import { Severity } from '../../types';
import {
EventViewMedia,
RecordingViewMedia,
ReviewViewMedia,
VideoContentType,
ViewMedia,
ViewMediaType,
} from '../../view/item';
import { FrigateEvent, FrigateRecording } from './types';
import { FrigateEvent, FrigateRecording, FrigateReview } from './types';
import {
getEventMediaContentID,
getEventThumbnailURL,
@@ -16,6 +18,10 @@ import {
getRecordingID,
getRecordingMediaContentID,
getRecordingTitle,
getReviewMediaContentID,
getReviewSeverity,
getReviewThumbnailURL,
getReviewTitle,
} from './util';
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
@@ -66,6 +72,9 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
public getTitle(): string | null {
return getEventTitle(this._event);
}
public getDescription(): string | null {
return this._event.data?.description ?? null;
}
public getThumbnail(): string | null {
return this._thumbnail;
}
@@ -146,6 +155,74 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
}
}
export class FrigateReviewViewMedia extends ViewMedia implements ReviewViewMedia {
protected _review: FrigateReview;
protected _contentID: string;
protected _thumbnail: string | null;
protected _title: string;
constructor(
cameraID: string,
review: FrigateReview,
contentID: string,
thumbnail: string | null,
) {
super(ViewMediaType.Review, { cameraID });
this._review = review;
this._contentID = contentID;
this._thumbnail = thumbnail;
this._title = this._review.data.metadata?.title ?? getReviewTitle(review);
}
public getID(): string {
return this._review.id;
}
public getStartTime(): Date {
return fromUnixTime(this._review.start_time);
}
public getEndTime(): Date | null {
return this._review.end_time ? fromUnixTime(this._review.end_time) : null;
}
public inProgress(): boolean | null {
return !this.getEndTime();
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getContentID(): string | null {
return this._contentID;
}
public getTitle(): string | null {
return this._review.data.metadata?.title ?? this._title;
}
public getDescription(): string | null {
return (
this._review.data.metadata?.scene ??
this._review.data.metadata?.shortSummary ??
null
);
}
public getThumbnail(): string | null {
return this._thumbnail;
}
public getSeverity(): Severity | null {
return getReviewSeverity(this._review.severity);
}
public isReviewed(): boolean {
return !!this._review.has_been_reviewed;
}
public setReviewed(reviewed: boolean): void {
this._review.has_been_reviewed = reviewed;
}
public getWhat(): string[] | null {
return this._review.data.objects ?? null;
}
public getWhere(): string[] | null {
const zones = this._review.data.zones;
return zones?.length ? zones : null;
}
}
export class FrigateViewMediaFactory {
static createEventViewMedia(
mediaType: ViewMediaType,
@@ -201,4 +278,25 @@ export class FrigateViewMediaFactory {
getRecordingTitle(cameraTitle, recording),
);
}
static createReviewViewMedia(
cameraID: string,
review: FrigateReview,
cameraConfig: CameraConfig,
): FrigateReviewViewMedia | null {
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
return null;
}
return new FrigateReviewViewMedia(
cameraID,
review,
getReviewMediaContentID(
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
review,
),
getReviewThumbnailURL(cameraConfig.frigate.client_id, review),
);
}
}
+66
View File
@@ -8,6 +8,8 @@ import {
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
FrigateReview,
frigateReviewsSchema,
PTZInfo,
ptzInfoSchema,
recordingSegmentsSchema,
@@ -15,6 +17,8 @@ import {
recordingSummarySchema,
RetainResult,
retainResultSchema,
ReviewResult,
reviewResultSchema,
} from './types';
/**
@@ -176,3 +180,65 @@ export const getPTZInfo = async (
true,
);
};
export interface NativeFrigateReviewQuery {
instance_id: string;
cameras?: string[];
labels?: string[];
zones?: string[];
severity?: string;
after?: number;
before?: number;
limit?: number;
reviewed?: boolean;
}
/**
* Get review items over websocket. May throw.
* @param hass The Home Assistant object.
* @param params The review search parameters.
* @returns An array of 'FrigateReview's.
*/
export const getReviews = async (
hass: HomeAssistant,
params: NativeFrigateReviewQuery,
): Promise<FrigateReview[]> => {
return await homeAssistantWSRequest(
hass,
frigateReviewsSchema,
{
type: 'frigate/reviews/get',
...params,
},
true,
);
};
export const setReviewsReviewed = async (
hass: HomeAssistant,
instance_id: string,
ids: string[],
reviewed?: boolean,
): Promise<void> => {
const request = {
type: 'frigate/reviews/viewed',
instance_id,
ids,
// Frigate uses 'viewed' to mean the review has been reviewed.
...(reviewed !== undefined && { viewed: reviewed }),
};
const response = await homeAssistantWSRequest<ReviewResult>(
hass,
reviewResultSchema,
request,
);
if (!response.success) {
throw new AdvancedCameraCardError(localize('error.failed_response'), {
request: request,
response: response,
});
}
};
+70
View File
@@ -5,6 +5,7 @@ import {
EventQueryResults,
RecordingQueryResults,
RecordingSegmentsQueryResults,
ReviewQueryResults,
} from '../types';
const dayStringToDate = (arg: unknown): Date | unknown => {
@@ -24,6 +25,12 @@ export const eventSchema = z.object({
top_score: z.number().nullable(),
zones: z.string().array(),
retain_indefinitely: z.boolean().optional(),
data: z
.object({
// GenAI-generated text description of the object/event
description: z.string().optional(),
})
.optional(),
});
export const frigateEventsSchema = eventSchema.array();
@@ -57,6 +64,12 @@ export const retainResultSchema = z.object({
});
export type RetainResult = z.infer<typeof retainResultSchema>;
export const reviewResultSchema = z.object({
success: z.boolean(),
message: z.string(),
});
export type ReviewResult = z.infer<typeof reviewResultSchema>;
export interface FrigateRecording {
cameraID: string;
startTime: Date;
@@ -124,3 +137,60 @@ export interface FrigateRecordingSegmentsQueryResults
engine: Engine.Frigate;
instanceID: string;
}
// =============
// Review Types
// =============
// Maps card severity to Frigate severity
export const FRIGATE_SEVERITY_MAP = {
high: 'alert',
medium: 'detection',
low: 'significant_motion',
} as const;
export type FrigateReviewSeverity =
(typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP];
// Review data schema (only fields we need for display)
const frigateReviewDataSchema = z.object({
objects: z.string().array().optional(),
zones: z.string().array().optional(),
metadata: z
.object({
title: z.string().optional(),
scene: z.string().optional(),
shortSummary: z.string().optional(),
})
.nullable()
.optional(),
});
// Review item schema
const frigateReviewSchema = z.object({
id: z.string(),
camera: z.string(),
severity: z.enum(['alert', 'detection', 'significant_motion']),
start_time: z.number(),
end_time: z.number().nullable(),
thumb_path: z.string().nullable(),
has_been_reviewed: z.boolean().optional(),
data: frigateReviewDataSchema,
});
export const frigateReviewsSchema = frigateReviewSchema.array();
export type FrigateReview = z.infer<typeof frigateReviewSchema>;
// Review change schema for live WebSocket updates
export const frigateReviewChangeSchema = z.object({
before: frigateReviewSchema,
after: frigateReviewSchema,
type: z.enum(['new', 'update', 'end', 'genai']),
});
export type FrigateReviewChange = z.infer<typeof frigateReviewChangeSchema>;
export interface FrigateReviewQueryResults extends ReviewQueryResults {
engine: Engine.Frigate;
instanceID: string;
reviews: FrigateReview[];
}
+89 -22
View File
@@ -1,8 +1,13 @@
import { toZonedTime } from 'date-fns-tz';
import { CameraConfig } from '../../config/schema/cameras';
import { ClipsOrSnapshots } from '../../types';
import { Severity } from '../../types';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import { FrigateEvent, FrigateRecording } from './types';
import {
FrigateEvent,
FrigateRecording,
FrigateReview,
FrigateReviewSeverity,
} from './types';
/**
* Given an event generate a title.
@@ -51,36 +56,36 @@ export const getEventMediaContentID = (
clientId: string,
cameraName: string,
event: FrigateEvent,
mediaType: ClipsOrSnapshots,
mediaType: 'clips' | 'snapshots',
): string => {
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
};
/**
* Generate a recording identifier.
* @param clientId The Frigate client id.
* @param cameraName The Frigate camera name.
* @param recording The Frigate recording.
* @returns A recording identifier.
* Build a recording media content ID from a start time.
*/
const buildRecordingMediaContentID = (
clientId: string,
cameraName: string,
startTime: Date,
): string =>
[
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${startTime.getFullYear()}-${String(startTime.getMonth() + 1).padStart(2, '0')}-${String(startTime.getDate()).padStart(2, '0')}`,
String(startTime.getHours()).padStart(2, '0'),
].join('/');
/**
* Generate a recording media content ID.
*/
export const getRecordingMediaContentID = (
clientId: string,
cameraName: string,
recording: FrigateRecording,
): string => {
return [
'media-source://frigate',
clientId,
'recordings',
cameraName,
`${recording.startTime.getFullYear()}-${String(
recording.startTime.getMonth() + 1,
).padStart(2, '0')}-${String(
String(recording.startTime.getDate()).padStart(2, '0'),
)}`,
String(recording.startTime.getHours()).padStart(2, '0'),
].join('/');
};
): string => buildRecordingMediaContentID(clientId, cameraName, recording.startTime);
/**
* Get a recording ID for internal de-duping.
@@ -96,3 +101,65 @@ export const getRecordingID = (
cameraConfig.frigate.camera_name ?? ''
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}`;
};
/**
* Given a review generate a title.
* @param review The Frigate review item.
*/
export const getReviewTitle = (review: FrigateReview): string => {
const objects = review.data.objects?.length
? review.data.objects.map((o) => prettifyTitle(o)).join(', ')
: '';
if (objects) {
return objects;
}
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const durationSeconds = Math.round(
review.end_time
? review.end_time - review.start_time
: Date.now() / 1000 - review.start_time,
);
return `${formatDateAndTime(
toZonedTime(review.start_time * 1000, localTimezone),
)} [${durationSeconds}s${objects}]`;
};
/**
* Generate a review media content ID.
*/
export const getReviewMediaContentID = (
clientId: string,
cameraName: string,
review: FrigateReview,
): string =>
buildRecordingMediaContentID(clientId, cameraName, new Date(review.start_time * 1000));
/**
* Get a thumbnail URL for a review.
*/
export const getReviewThumbnailURL = (
clientId: string,
review: FrigateReview,
): string | null => {
if (!review.thumb_path) {
return null;
}
const path = review.thumb_path.replace('/media/frigate/', '');
return `/api/frigate/${clientId}/${path}`;
};
/**
* Get generic review severity.
*/
export const getReviewSeverity = (severity: FrigateReviewSeverity): Severity => {
if (severity === 'alert') {
return 'high';
}
if (severity === 'detection') {
return 'medium';
}
return 'low';
};
+108
View File
@@ -0,0 +1,108 @@
import { z } from 'zod';
import { HomeAssistant } from '../../ha/types';
import {
FrigateEventChange,
FrigateReviewChange,
frigateEventChangeSchema,
frigateReviewChangeSchema,
} from './types';
// Generic request interface for Frigate watchers
export interface FrigateWatcherRequest<T> {
instanceID: string;
matcher?(item: T): boolean;
callback(item: T): void;
}
// Generic subscription interface
export interface FrigateWatcherSubscriptionInterface<T> {
subscribe(hass: HomeAssistant, request: FrigateWatcherRequest<T>): Promise<void>;
unsubscribe(request: FrigateWatcherRequest<T>): void;
}
type SubscriptionUnsubscribe = () => Promise<void>;
/**
* Base class for Frigate WebSocket watchers.
* Handles subscription management and message routing to callbacks.
*/
abstract class FrigateWatcher<T> implements FrigateWatcherSubscriptionInterface<T> {
protected abstract _type: string;
protected abstract _schema: z.ZodType<T>;
protected _requests: FrigateWatcherRequest<T>[] = [];
protected _unsubscribeCallback: Record<string, SubscriptionUnsubscribe> = {};
public async subscribe(
hass: HomeAssistant,
request: FrigateWatcherRequest<T>,
): Promise<void> {
const shouldSubscribe = !this._hasSubscribers(request.instanceID);
this._requests.push(request);
if (shouldSubscribe) {
this._unsubscribeCallback[request.instanceID] =
await hass.connection.subscribeMessage<string>(
(data) => this._receiveHandler(request.instanceID, data),
{ type: this._type, instance_id: request.instanceID },
);
}
}
public async unsubscribe(request: FrigateWatcherRequest<T>): Promise<void> {
this._requests = this._requests.filter(
(existingRequest) => existingRequest !== request,
);
if (!this._hasSubscribers(request.instanceID)) {
await this._unsubscribeCallback[request.instanceID]();
delete this._unsubscribeCallback[request.instanceID];
}
}
protected _hasSubscribers(instanceID: string): boolean {
return !!this._requests.filter((request) => request.instanceID === instanceID)
.length;
}
protected _receiveHandler(instanceID: string, data: string): void {
let json: unknown;
try {
json = JSON.parse(data);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.warn(`Received non-JSON payload from subscription: ${this._type}`, data);
return;
}
const parseResult = this._schema.safeParse(json);
if (!parseResult.success) {
console.warn(`Received malformed message from subscription: ${this._type}`, data);
return;
}
for (const request of this._requests) {
if (
request.instanceID === instanceID &&
(!request.matcher || request.matcher(parseResult.data))
) {
request.callback(parseResult.data);
}
}
}
}
/**
* Watcher for Frigate event updates via WebSocket.
*/
export class FrigateEventWatcher extends FrigateWatcher<FrigateEventChange> {
protected _type = 'frigate/events/subscribe';
protected _schema = frigateEventChangeSchema;
}
/**
* Watcher for Frigate review updates via WebSocket.
*/
export class FrigateReviewWatcher extends FrigateWatcher<FrigateReviewChange> {
protected _type = 'frigate/reviews/subscribe';
protected _schema = frigateReviewChangeSchema;
}
@@ -14,6 +14,7 @@ import {
CameraEventCallback,
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -23,11 +24,15 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryReturnType,
QueryType,
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
ReviewQuery,
ReviewQueryResultsMap,
} from '../types';
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
@@ -67,6 +72,13 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
});
}
public getDefaultQueryParameters(
_camera: Camera,
_queryType: QueryType,
): DefaultQueryParameters {
return {};
}
public generateDefaultEventQuery(
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
@@ -118,6 +130,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null;
}
public generateDefaultReviewQuery(
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
_query?: PartialReviewQuery,
): ReviewQuery[] | null {
return null;
}
public async getReviews(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
_query: ReviewQuery,
_engineOptions?: EngineOptions,
): Promise<ReviewQueryResultsMap | null> {
return null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
@@ -136,6 +165,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null;
}
public generateMediaFromReviews(
_hass: HomeAssistant,
_store: CameraManagerReadOnlyConfigStore,
_query: ReviewQuery,
_results: QueryReturnType<ReviewQuery>,
): ViewMedia[] | null {
return null;
}
public async getMediaDownloadPath(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
@@ -153,6 +191,15 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return;
}
public async reviewMedia(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
_reviewed: boolean,
): Promise<void> {
return;
}
public getQueryResultMaxAge(_query: CameraQuery): number | null {
return null;
}
+81 -1
View File
@@ -35,6 +35,7 @@ import {
CameraEndpointsContext,
CameraManagerCameraMetadata,
CameraQuery,
DefaultQueryParameters,
Engine,
EngineOptions,
EventQuery,
@@ -49,6 +50,7 @@ import {
PartialQueryConcreteType,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PartialReviewQuery,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -60,6 +62,8 @@ import {
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
ReviewQuery,
ReviewQueryResults,
} from './types.js';
export class CameraQueryClassifier {
@@ -83,6 +87,11 @@ export class CameraQueryClassifier {
): query is MediaMetadataQuery {
return query.type === QueryType.MediaMetadata;
}
public static isReviewQuery(
query: CameraQuery | PartialCameraQuery,
): query is ReviewQuery {
return query.type === QueryType.Review;
}
}
export class QueryResultClassifier {
@@ -106,9 +115,14 @@ export class QueryResultClassifier {
): queryResults is MediaMetadataQueryResults {
return queryResults?.type === QueryResultsType.MediaMetadata;
}
public static isReviewQueryResult(
queryResults?: QueryResults | null,
): queryResults is ReviewQueryResults {
return queryResults?.type === QueryResultsType.Review;
}
}
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
interface ExtendedMediaQueryResult<T extends MediaQuery> {
queries: T[];
results: ViewItem[];
}
@@ -308,6 +322,17 @@ export class CameraManager {
});
}
public getDefaultQueryParameters(
cameraID: string,
queryType: QueryType,
): DefaultQueryParameters {
const camera = this._store.getCamera(cameraID);
if (!camera) {
return {};
}
return camera.getEngine().getDefaultQueryParameters(camera, queryType);
}
public generateDefaultRecordingQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialRecordingQuery,
@@ -328,6 +353,16 @@ export class CameraManager {
});
}
public generateDefaultReviewQueries(
cameraIDs: string | Set<string>,
partialQuery?: PartialReviewQuery,
): ReviewQuery[] | null {
return this._generateDefaultQueries(cameraIDs, {
type: QueryType.Review,
...partialQuery,
});
}
protected _generateDefaultQueries<PQT extends PartialCameraQuery>(
cameraIDs: string | Set<string>,
partialQuery: PQT,
@@ -356,6 +391,12 @@ export class CameraManager {
cameraIDs,
partialQuery,
);
} else if (CameraQueryClassifier.isReviewQuery(partialQuery)) {
queries = engine.generateDefaultReviewQuery(
this._store,
cameraIDs,
partialQuery,
);
}
for (const query of queries ?? []) {
@@ -568,6 +609,33 @@ export class CameraManager {
);
}
public async reviewMedia(media: ViewMedia, reviewed: boolean): Promise<void> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine || !hass) {
return;
}
const queryStartTime = new Date();
await this._requestLimit.add(() =>
engine.reviewMedia(hass, cameraConfig, media, reviewed),
);
log(
this._api.getConfigManager().getCardWideConfig(),
'Advanced Camera Card CameraManager review media request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:',
media.getID(),
', Reviewed:',
reviewed,
')',
);
}
public areMediaQueriesResultsFresh<T extends MediaQuery>(
resultsTimestamp: Date,
queries: T[] | null,
@@ -667,6 +735,13 @@ export class CameraManager {
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (CameraQueryClassifier.isReviewQuery(query)) {
engineResult = (await engine.getReviews(
hass,
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
}
engineResult?.forEach((value, key) => results.set(key, value));
@@ -739,6 +814,11 @@ export class CameraManager {
QueryResultClassifier.isRecordingQueryResult(result)
) {
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
} else if (
CameraQueryClassifier.isReviewQuery(query) &&
QueryResultClassifier.isReviewQueryResult(result)
) {
media = engine.generateMediaFromReviews(hass, this._store, query, result);
}
if (media) {
mediaArray.push(...media);
@@ -14,6 +14,7 @@ import {
import { BrowseMediaStep, BrowseMediaTarget } from '../../ha/browse-media/walker';
import { isMediaWithinDates } from '../../ha/browse-media/within-dates';
import { HomeAssistant } from '../../ha/types';
import { hasUnsupportedFilters } from '../../query-source.js';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
@@ -224,8 +225,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// MotionEye does not support these query types and they will never match.
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
if (hasUnsupportedFilters(query)) {
return null;
}
+2 -8
View File
@@ -18,6 +18,7 @@ import { DeviceRegistryManager } from '../../ha/registry/device';
import { EntityRegistryManager } from '../../ha/registry/entity/types';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
import { hasUnsupportedFilters } from '../../query-source.js';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import { ViewMedia } from '../../view/item';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
@@ -255,14 +256,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// Reolink does not support these query types and they will never match.
if (
query.favorite ||
query.tags?.size ||
query.what?.size ||
query.where?.size ||
query.hasSnapshot
) {
if (hasUnsupportedFilters(query) || query.hasSnapshot) {
return null;
}
+53 -16
View File
@@ -1,7 +1,8 @@
import { ExpiringEqualityCache } from '../cache/expiring-cache';
import { SSLCiphers } from '../config/schema/cameras';
import { AdvancedCameraCardView } from '../config/schema/common/const';
import { CapabilityKey, Endpoint, Icon } from '../types';
import { BaseQuery, QueryFilters, QuerySource } from '../query-source';
import { CapabilityKey, Endpoint, Icon, Severity } from '../types';
import { ViewMedia } from '../view/item';
// ====
@@ -13,6 +14,7 @@ export enum QueryType {
Recording = 'recording-query',
RecordingSegments = 'recording-segments-query',
MediaMetadata = 'media-metadata',
Review = 'review-query',
}
export enum QueryResultsType {
@@ -20,6 +22,7 @@ export enum QueryResultsType {
Recording = 'recording-results',
RecordingSegments = 'recording-segments-results',
MediaMetadata = 'media-metadata-results',
Review = 'review-results',
}
export enum Engine {
@@ -46,10 +49,12 @@ interface LimitedDataQuery {
}
export interface MediaQuery
extends CameraQuery,
extends BaseQuery,
CameraQuery,
QueryFilters,
Partial<TimeBasedDataQuery>,
Partial<LimitedDataQuery> {
favorite?: boolean;
source: QuerySource.Camera;
}
export interface QueryResults {
@@ -74,14 +79,18 @@ export type QueryReturnType<QT> = QT extends EventQuery
? RecordingSegmentsQueryResults
: QT extends MediaMetadataQuery
? MediaMetadataQueryResults
: never;
: QT extends ReviewQuery
? ReviewQueryResults
: never;
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
? EventQuery
: PQT extends PartialRecordingQuery
? RecordingQuery
: PQT extends PartialRecordingSegmentsQuery
? RecordingSegmentsQuery
: never;
: PQT extends PartialReviewQuery
? ReviewQuery
: never;
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
export type EventQueryResultsMap = ResultsMap<EventQuery>;
@@ -140,16 +149,21 @@ export interface EngineOptions {
export interface CameraEvent {
cameraID: string;
type: 'new' | 'update' | 'end';
type:
| 'new' // A new event has started.
| 'update' // An update for an event is available (except GenAI).
| 'end' // An event has ended.
| 'genai'; // An AI based update is available.
// When fidelity is `high`, the engine is assumed to provide exact details of
// what new media is available. Otherwise all media types are assumed to be
// possibly newly available.
fidelity?: 'high' | 'low';
// Whether a new clip/snapshot/recording may be available.
// Whether new media may be available.
clip?: boolean;
snapshot?: boolean;
review?: boolean;
}
export type CameraEventCallback = (ev: CameraEvent) => void;
@@ -158,6 +172,21 @@ export class CameraManagerRequestCache extends ExpiringEqualityCache<
QueryResults
> {}
// ====================
// Default Query Params
// ====================
/**
* Default query parameters that engines can provide based on camera configuration.
*/
export interface DefaultQueryParameters {
// Object labels (what was detected)
what?: Set<string>;
// Zones (where detection occurred)
where?: Set<string>;
}
// ===========
// Event Query
// ===========
@@ -170,15 +199,6 @@ export interface EventQuery extends MediaQuery {
// Frigate equivalent: has_clip
hasClip?: boolean;
// Frigate equivalent: label
what?: Set<string>;
// Frigate equivalent: sub_label
tags?: Set<string>;
// Frigate equivalent: zone
where?: Set<string>;
}
export type PartialEventQuery = Partial<EventQuery>;
@@ -225,3 +245,20 @@ export interface MediaMetadataQueryResults extends QueryResults {
type: QueryResultsType.MediaMetadata;
metadata: MediaMetadata;
}
// ============
// Review Query
// ============
export interface ReviewQuery extends MediaQuery {
type: QueryType.Review;
severity?: Severity;
}
export type PartialReviewQuery = Partial<ReviewQuery>;
export interface ReviewQueryResults extends QueryResults {
type: QueryResultsType.Review;
}
export type ReviewQueryResultsMap = ResultsMap<ReviewQuery>;