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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user