Rename camera directory to camera-manager.
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
||||
import { RecordingSegment } from '../types';
|
||||
import { DateRange, MemoryRangeSet } from './range';
|
||||
import { DataQuery, QueryResults } from './types';
|
||||
|
||||
interface RequestCacheItem<Request, Response> {
|
||||
request: Request;
|
||||
response: Response;
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
interface CameraManagerCache<Request, Response> {
|
||||
get(request: Request): Response | null;
|
||||
has(request: Request): boolean;
|
||||
set(request: Request, response: Response, expiry?: Date): void;
|
||||
}
|
||||
|
||||
export class MemoryRequestCache<Request, Response>
|
||||
implements CameraManagerCache<Request, Response>
|
||||
{
|
||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||
|
||||
public get(request: Request): Response | null {
|
||||
const now = new Date();
|
||||
for (const item of this._data) {
|
||||
if (
|
||||
(!item.expires || now <= item.expires) &&
|
||||
this._contains(request, item.request)
|
||||
) {
|
||||
return item.response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public has(request: Request): boolean {
|
||||
return !!this.get(request);
|
||||
}
|
||||
|
||||
public set(request: Request, response: Response, expiry?: Date): void {
|
||||
this._data.push({
|
||||
request: request,
|
||||
response: response,
|
||||
expires: expiry,
|
||||
});
|
||||
|
||||
// Clean up old requests on set.
|
||||
this._expireOldRequests();
|
||||
}
|
||||
|
||||
protected _contains(a: Request, b: Request): boolean {
|
||||
return isEqual(a, b);
|
||||
}
|
||||
|
||||
protected _expireOldRequests(): void {
|
||||
const now = new Date();
|
||||
this._data = this._data.filter((item) => !item.expires || now < item.expires);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
|
||||
|
||||
export class MemoryRangedCache<Data> {
|
||||
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
||||
protected _data: Data[] = [];
|
||||
protected _timeFunc: (data: Data) => number;
|
||||
protected _idFunc: (data: Data) => string;
|
||||
|
||||
constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) {
|
||||
this._timeFunc = timeFunc;
|
||||
this._idFunc = idFunc;
|
||||
}
|
||||
|
||||
public add(range: DateRange, data: Data[]) {
|
||||
this._ranges.add(range);
|
||||
this._data = sortedUniqBy(
|
||||
orderBy(this._data.concat(data), this._timeFunc, 'asc'),
|
||||
this._idFunc,
|
||||
);
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(range: DateRange): Data[] | null {
|
||||
if (!this.hasCoverage(range)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Data[] = [];
|
||||
for (const data of this._data) {
|
||||
const start = this._timeFunc(data);
|
||||
if (start > range.start.getTime()) {
|
||||
if (start > range.end.getTime()) {
|
||||
// Data is kept in order.
|
||||
break;
|
||||
}
|
||||
output.push(data);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this._data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old data that matches a given predicate. No change to the covered
|
||||
* ranges is made, i.e. this is asserting authoritiatively that this data does
|
||||
* not exist in the current ranges.
|
||||
* @param predicate A predicate to run on each data element.
|
||||
*/
|
||||
public expireMatches(predicate: (data: Data) => boolean): void {
|
||||
this._data = this._data.filter(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingSegmentsCache {
|
||||
protected _segments: Map<string, MemoryRangedCache<RecordingSegment>> = new Map();
|
||||
|
||||
public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) {
|
||||
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | undefined =
|
||||
this._segments.get(cameraID);
|
||||
if (!cameraSegmentCache) {
|
||||
cameraSegmentCache = new MemoryRangedCache(
|
||||
(segment: RecordingSegment) => segment.start_time * 1000,
|
||||
(segment: RecordingSegment) => segment.id,
|
||||
);
|
||||
this._segments.set(cameraID, cameraSegmentCache);
|
||||
}
|
||||
cameraSegmentCache.add(range, segments);
|
||||
}
|
||||
|
||||
public hasCoverage(cameraID: string, range: DateRange): boolean {
|
||||
return !!this._segments.get(cameraID)?.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(cameraID: string, range: DateRange): RecordingSegment[] | null {
|
||||
return this._segments.get(cameraID)?.get(range) ?? null;
|
||||
}
|
||||
|
||||
public getCache(cameraID: string): MemoryRangedCache<RecordingSegment> | null {
|
||||
return this._segments.get(cameraID) ?? null;
|
||||
}
|
||||
|
||||
public getCameraIDs(): string[] {
|
||||
return [...this._segments.keys()];
|
||||
}
|
||||
|
||||
public expireMatches(
|
||||
cameraID: string,
|
||||
func: (segment: RecordingSegment) => boolean,
|
||||
): void {
|
||||
this._segments.get(cameraID)?.expireMatches(func);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { CameraConfig, CardWideConfig } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
||||
import { Engine } from './types';
|
||||
|
||||
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
protected _engines: Map<Engine, CameraManagerEngine> = new Map();
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
constructor(cardWideConfig: CardWideConfig) {
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
}
|
||||
|
||||
public getEngine(engine: Engine): CameraManagerEngine | null {
|
||||
const cachedEngine = this._engines.get(engine);
|
||||
if (cachedEngine) {
|
||||
return cachedEngine;
|
||||
}
|
||||
let cameraManagerEngine: CameraManagerEngine | null = null;
|
||||
switch (engine) {
|
||||
case Engine.Frigate:
|
||||
cameraManagerEngine = new FrigateCameraManagerEngine(
|
||||
this._cardWideConfig,
|
||||
new RecordingSegmentsCache(),
|
||||
new RequestCache(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (cameraManagerEngine) {
|
||||
this._engines.set(engine, cameraManagerEngine);
|
||||
}
|
||||
return cameraManagerEngine;
|
||||
}
|
||||
|
||||
public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null {
|
||||
if (!cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let engine: Engine | null = null;
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
engine = Engine.Frigate;
|
||||
}
|
||||
return engine ? this.getEngine(engine) : null;
|
||||
}
|
||||
|
||||
public getEnginesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): CameraManagerEngineCameraIDMap | null {
|
||||
const output: CameraManagerEngineCameraIDMap = new Map();
|
||||
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const engine = this.getEngineForCamera(cameraConfig);
|
||||
if (!engine) {
|
||||
continue;
|
||||
}
|
||||
if (!output.has(engine)) {
|
||||
output.set(engine, new Set());
|
||||
}
|
||||
output.get(engine)?.add(cameraID);
|
||||
}
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public getEngineForMedia(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
): CameraManagerEngine | null {
|
||||
const cameraID = media.getCameraID();
|
||||
if (!cameraID) {
|
||||
return null;
|
||||
}
|
||||
const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID]));
|
||||
return engines ? ([...engines.keys()][0] ?? null) : null;
|
||||
}
|
||||
|
||||
public getAllEngines(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): CameraManagerEngine[] | null {
|
||||
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
|
||||
return engines ? [...engines.keys()] : null;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryReturnType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
CameraManagerEngineCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
|
||||
export interface CameraManagerEngine {
|
||||
generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery[] | null;
|
||||
|
||||
generateDefaultRecordingQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingQuery,
|
||||
): RecordingQuery[] | null;
|
||||
|
||||
generateDefaultRecordingSegmentsQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null;
|
||||
|
||||
getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null>;
|
||||
|
||||
getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null>;
|
||||
|
||||
getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||
|
||||
favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
getQueryResultMaxAge(query: DataQuery): number | null;
|
||||
|
||||
getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null>;
|
||||
|
||||
getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null>;
|
||||
|
||||
getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata;
|
||||
|
||||
getCapabilities(): CameraManagerEngineCapabilities | null;
|
||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||
}
|
||||
@@ -1,838 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import { CAMERA_BIRDSEYE } from '../../const';
|
||||
import { CameraConfig, CardWideConfig, RecordingSegment } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RequestCache, RecordingSegmentsCache } from '../cache';
|
||||
import {
|
||||
CameraManagerEngine,
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
} from '../engine';
|
||||
import { DateRange } from '../range';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerEngineCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
Engine,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResults,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { FrigateRecording } from './types';
|
||||
import {
|
||||
getEvents,
|
||||
getEventSummary,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
retainEvent,
|
||||
} from './requests';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import {
|
||||
allPromises,
|
||||
formatDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
} from '../../utils/basic';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import { sum } from 'lodash-es';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
|
||||
class FrigateQueryResultsClassifier {
|
||||
public static isFrigateEventQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateEventQueryResults {
|
||||
return results.engine === Engine.Frigate && results.type === QueryResultsType.Event;
|
||||
}
|
||||
|
||||
public static isFrigateRecordingQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate && results.type === QueryResultsType.Recording
|
||||
);
|
||||
}
|
||||
|
||||
public static isFrigateRecordingSegmentsResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingSegmentsQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate &&
|
||||
results.type === QueryResultsType.RecordingSegments
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||
protected _requestCache: RequestCache;
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
this._garbageCollectSegments.bind(this),
|
||||
60 * 60 * 1000,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
constructor(
|
||||
cardWideConfig: CardWideConfig,
|
||||
recordingSegmentsCache: RecordingSegmentsCache,
|
||||
requestCache: RequestCache,
|
||||
) {
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): string | null {
|
||||
let path: string | null = null;
|
||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`;
|
||||
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`?download=true`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) =>
|
||||
cameras.get(cameraID),
|
||||
);
|
||||
|
||||
// If there isn't a label or zone specified, we can come up with a single
|
||||
// batch query for Frigate that will match across all cameras.
|
||||
const canDoBatchQuery = relevantCameraConfigs.every(
|
||||
(cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone,
|
||||
);
|
||||
|
||||
if (canDoBatchQuery) {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output: EventQuery[] = [];
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
output.push({
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set([cameraID]),
|
||||
...(cameraConfig.frigate.label && {
|
||||
what: new Set([cameraConfig.frigate.label]),
|
||||
}),
|
||||
...(cameraConfig.frigate.zone && {
|
||||
where: new Set([cameraConfig.frigate.zone]),
|
||||
}),
|
||||
...query,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output.length ? output : null;
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialRecordingQuery,
|
||||
): RecordingQuery[] {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null {
|
||||
if (!query.start || !query.end) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: QueryType.RecordingSegments,
|
||||
cameraIDs: cameraIDs,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
if (!FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite);
|
||||
media.setFavorite(favorite);
|
||||
}
|
||||
|
||||
protected _buildInstanceToCameraIDMapFromQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Map<string, Set<string>> {
|
||||
const output: Map<string, Set<string>> = new Map();
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
const clientID = cameraConfig?.frigate.client_id;
|
||||
if (clientID) {
|
||||
if (!output.has(clientID)) {
|
||||
output.set(clientID, new Set());
|
||||
}
|
||||
output.get(clientID)?.add(cameraID);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNamesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Set<string> {
|
||||
const output = new Set<string>();
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (cameraConfig?.frigate.camera_name) {
|
||||
output.add(cameraConfig.frigate.camera_name);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
|
||||
const processInstanceQuery = async (
|
||||
instanceID: string,
|
||||
cameraIDs?: Set<string>,
|
||||
): Promise<void> => {
|
||||
if (!cameraIDs || !cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
const instanceQuery = { ...query, cameraIDs: cameraIDs };
|
||||
const cachedResult = this._requestCache.get(instanceQuery);
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as EventQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeQuery: NativeFrigateEventQuery = {
|
||||
instance_id: instanceID,
|
||||
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, 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.hasClip && { has_clip: query.hasClip }),
|
||||
...(query.hasSnapshot && { has_snapshot: query.hasSnapshot }),
|
||||
...(query.favorite && { favorites: query.favorite }),
|
||||
limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
const result: FrigateEventQueryResults = {
|
||||
type: QueryResultsType.Event,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: instanceID,
|
||||
events: await getEvents(hass, nativeQuery),
|
||||
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
|
||||
cached: false,
|
||||
};
|
||||
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
output.set(instanceQuery, result);
|
||||
};
|
||||
|
||||
// Frigate allows multiple cameras to be searched for events in a single
|
||||
// query. Break them down into groups of cameras per Frigate instance, then
|
||||
// query once per instance for all cameras in that instance.
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
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,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
const output: RecordingQueryResultsMap = new Map();
|
||||
|
||||
const processQuery = async (
|
||||
baseQuery: RecordingQuery,
|
||||
cameraID: string,
|
||||
): Promise<void> => {
|
||||
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
|
||||
const cachedResult = this._requestCache.get(query);
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as RecordingQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingSummary = await getRecordingsSummary(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
);
|
||||
|
||||
let recordings: FrigateRecording[] = [];
|
||||
|
||||
for (const dayData of recordingSummary ?? []) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
if (
|
||||
(!query.start || startHour >= query.start) &&
|
||||
(!query.end || endHour <= query.end)
|
||||
) {
|
||||
recordings.push({
|
||||
cameraID: cameraID,
|
||||
startTime: startHour,
|
||||
endTime: endHour,
|
||||
events: hourData.events,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.limit !== undefined) {
|
||||
// Frigate does not natively support a way to limit recording searches so
|
||||
// this simulates it.
|
||||
recordings = orderBy(
|
||||
recordings,
|
||||
(recording: FrigateRecording) => recording.startTime,
|
||||
'desc',
|
||||
).slice(0, query.limit);
|
||||
}
|
||||
|
||||
const result: FrigateRecordingQueryResults = {
|
||||
type: QueryResultsType.Recording,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
recordings: recordings,
|
||||
expiry: add(new Date(), {
|
||||
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
|
||||
}),
|
||||
cached: false,
|
||||
};
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
output.set(query, result);
|
||||
};
|
||||
|
||||
// Frigate recordings can only be queried for a single camera, so fan out
|
||||
// the inbound query into multiple outbound queries.
|
||||
await Promise.all(
|
||||
Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)),
|
||||
);
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||
const output: RecordingSegmentsQueryResultsMap = new Map();
|
||||
|
||||
const processQuery = async (
|
||||
baseQuery: RecordingSegmentsQuery,
|
||||
cameraID: string,
|
||||
): Promise<void> => {
|
||||
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range: DateRange = { start: query.start, end: query.end };
|
||||
|
||||
// A note on Frigate Recording Segments:
|
||||
// - There is an internal cache at the engine level for segments to allow
|
||||
// caching "within an existing query" (e.g. if we already cached hour
|
||||
// 1-8, we will avoid a fetch if we request hours 2-3 even though the
|
||||
// query is different -- the segments won't be). This is since the
|
||||
// volume of data in segment transfers can be high, and the segments can
|
||||
// be used in high frequency situations (e.g. video seeking).
|
||||
const cachedSegments = this._recordingSegmentsCache.get(cameraID, range);
|
||||
if (cachedSegments) {
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
segments: cachedSegments,
|
||||
cached: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const request: NativeFrigateRecordingSegmentsQuery = {
|
||||
instance_id: cameraConfig.frigate.client_id,
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
after: Math.floor(query.start.getTime() / 1000),
|
||||
before: Math.floor(query.end.getTime() / 1000),
|
||||
};
|
||||
|
||||
const segments = await getRecordingSegments(hass, request);
|
||||
this._recordingSegmentsCache.add(cameraID, range, segments);
|
||||
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
segments: segments,
|
||||
cached: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Frigate recording segments can only be queried for a single camera, so
|
||||
// fan out the inbound query into multiple outbound queries.
|
||||
await Promise.all(
|
||||
Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)),
|
||||
);
|
||||
|
||||
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras));
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
protected _getCameraIDMatch(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: DataQuery,
|
||||
instanceID: string,
|
||||
cameraName: string,
|
||||
): string | null {
|
||||
// If the query is only for a single cameraID, all results are assumed to
|
||||
// belong to it for performance reasons. Otherwise, we need to map the
|
||||
// instanceID and camera name for the known cameras, and get the precise
|
||||
// cameraID that matches the expected instance ID / camera name.
|
||||
if (query.cameraIDs.size === 1) {
|
||||
return [...query.cameraIDs][0];
|
||||
}
|
||||
for (const [cameraID, cameraConfig] of cameras.entries()) {
|
||||
if (
|
||||
cameraConfig.frigate.client_id === instanceID &&
|
||||
cameraConfig.frigate.camera_name === cameraName
|
||||
) {
|
||||
return cameraID;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const event of results.events) {
|
||||
const cameraID = this._getCameraIDMatch(
|
||||
cameras,
|
||||
query,
|
||||
results.instanceID,
|
||||
event.camera,
|
||||
);
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
let mediaType: 'clip' | 'snapshot' | null = null;
|
||||
if (
|
||||
!query.hasClip &&
|
||||
!query.hasSnapshot &&
|
||||
(event.has_clip || event.has_snapshot)
|
||||
) {
|
||||
mediaType = event.has_clip ? 'clip' : 'snapshot';
|
||||
} else if (query.hasSnapshot && event.has_snapshot) {
|
||||
mediaType = 'snapshot';
|
||||
} else if (query.hasClip && event.has_clip) {
|
||||
mediaType = 'clip';
|
||||
}
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = FrigateViewMediaFactory.createEventViewMedia(
|
||||
mediaType,
|
||||
cameraID,
|
||||
cameraConfig,
|
||||
event,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
_query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const recording of results.recordings) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
const media = FrigateViewMediaFactory.createRecordingViewMedia(
|
||||
recording.cameraID,
|
||||
recording,
|
||||
cameraConfig,
|
||||
this.getCameraMetadata(hass, cameraConfig).title,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: DataQuery): 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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null> {
|
||||
const start = media.getStartTime();
|
||||
const end = media.getEndTime();
|
||||
if (!start || !end || target < start || target > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraID = media.getCameraID();
|
||||
const query: RecordingSegmentsQuery = {
|
||||
cameraIDs: new Set([cameraID]),
|
||||
start: start,
|
||||
end: end,
|
||||
type: QueryType.RecordingSegments,
|
||||
};
|
||||
|
||||
const results = await this.getRecordingSegments(hass, cameras, query);
|
||||
|
||||
if (results) {
|
||||
return this._getSeekTimeInSegments(
|
||||
start,
|
||||
target,
|
||||
// There will only be a single result since Frigate recording segments
|
||||
// searches are per camera which is specified singularly above.
|
||||
Array.from(results.values())[0].segments,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getQueryableCameraConfig(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraID: string,
|
||||
): CameraConfig | null {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) {
|
||||
return null;
|
||||
}
|
||||
return cameraConfig;
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
new Set(cameras.keys()),
|
||||
);
|
||||
|
||||
const processEventSummary = async (
|
||||
instanceID: string,
|
||||
cameraIDs: Set<string>,
|
||||
): Promise<void> => {
|
||||
const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs);
|
||||
for (const entry of await getEventSummary(hass, instanceID)) {
|
||||
if (!cameraNames.has(entry.camera)) {
|
||||
// If this entry applies to a camera that *is* in this Frigate
|
||||
// instance, but is *not* a configured camera in the card, skip it.
|
||||
continue;
|
||||
}
|
||||
if (entry.label) {
|
||||
what.add(entry.label);
|
||||
}
|
||||
if (entry.zones.length) {
|
||||
entry.zones.forEach(where.add, where);
|
||||
}
|
||||
if (entry.day) {
|
||||
days.add(entry.day);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processRecordings = async (cameraIDs: Set<string>): Promise<void> => {
|
||||
const recordings = await this.getRecordings(hass, cameras, {
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
});
|
||||
if (!recordings) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of recordings.values()) {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const recording of result.recordings) {
|
||||
// Frigate recordings are always 1 hour long, i.e. never span a day.
|
||||
days.add(formatDate(recording.startTime));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises([...instances.entries()], ([instanceID, cameraIDs]) =>
|
||||
(async () => {
|
||||
await Promise.all([
|
||||
processEventSummary(instanceID, cameraIDs),
|
||||
processRecordings(cameraIDs),
|
||||
]);
|
||||
})(),
|
||||
);
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect recording segments that no longer feature in the recordings
|
||||
* returned by the Frigate backend.
|
||||
*/
|
||||
protected async _garbageCollectSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<void> {
|
||||
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
||||
const recordingQuery: RecordingQuery = {
|
||||
cameraIDs: new Set(cameraIDs),
|
||||
type: QueryType.Recording,
|
||||
};
|
||||
|
||||
const countSegments = () =>
|
||||
sum(
|
||||
cameraIDs.map(
|
||||
(cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0,
|
||||
),
|
||||
);
|
||||
const segmentsStart = countSegments();
|
||||
|
||||
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
||||
// items) and each item must be examined, so care required here to stick to
|
||||
// nothing worse than O(n) performance.
|
||||
const getHourID = (cameraID: string, startTime: Date): string => {
|
||||
return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`;
|
||||
};
|
||||
|
||||
const results = await this.getRecordings(hass, cameras, recordingQuery);
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [query, result] of results) {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const goodHours: Set<string> = new Set();
|
||||
for (const recording of result.recordings) {
|
||||
goodHours.add(getHourID(recording.cameraID, recording.startTime));
|
||||
}
|
||||
|
||||
// Frigate recordings are always executed individually, so there'll only
|
||||
// be a single results.
|
||||
const cameraID = Array.from(query.cameraIDs)[0];
|
||||
this._recordingSegmentsCache.expireMatches(
|
||||
cameraID,
|
||||
(segment: RecordingSegment) => {
|
||||
const hourID = getHourID(cameraID, fromUnixTime(segment.start_time));
|
||||
// ~O(1) lookup time for a JS set.
|
||||
return goodHours.has(hourID);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card recording segment garbage collection: ' +
|
||||
`Released ${segmentsStart - countSegments()} segment(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param startTime The earliest allowable time to seek from.
|
||||
* @param targetTime Target time.
|
||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||
* @returns
|
||||
*/
|
||||
protected _getSeekTimeInSegments(
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegment[],
|
||||
): number | null {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
let seekMilliseconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
const segmentStart = fromUnixTime(segment.start_time);
|
||||
if (segmentStart > targetTime) {
|
||||
break;
|
||||
}
|
||||
const segmentEnd = fromUnixTime(segment.end_time);
|
||||
const start = segmentStart < startTime ? startTime : segmentStart;
|
||||
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
||||
seekMilliseconds += end.getTime() - start.getTime();
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerEngineCapabilities {
|
||||
return {
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: false,
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
|
||||
return {
|
||||
canFavorite: ViewMediaClassifier.isEvent(media),
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata {
|
||||
return {
|
||||
title:
|
||||
cameraConfig.title ??
|
||||
getEntityTitle(hass, cameraConfig.camera_entity) ??
|
||||
getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ??
|
||||
prettifyTitle(cameraConfig.frigate?.camera_name) ??
|
||||
cameraConfig.id ??
|
||||
'',
|
||||
icon:
|
||||
cameraConfig?.icon ??
|
||||
getEntityIcon(hass, cameraConfig.camera_entity) ??
|
||||
'mdi:video',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export const FRIGATE_ICON_SVG_PATH =
|
||||
'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
|
||||
'-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
|
||||
'-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
|
||||
'5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
|
||||
'1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
|
||||
'-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
|
||||
'-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
|
||||
'-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
|
||||
'-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
|
||||
'-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
|
||||
'0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
|
||||
'0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
|
||||
'0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
|
||||
'0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
|
||||
'-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
|
||||
'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
|
||||
'-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
|
||||
'-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
|
||||
'-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
||||
|
||||
export class FrigateViewMediaClassifier {
|
||||
public static isFrigateMedia(
|
||||
media: ViewMedia,
|
||||
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
|
||||
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
|
||||
}
|
||||
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
||||
return media instanceof FrigateEventViewMedia;
|
||||
}
|
||||
public static isFrigateRecording(
|
||||
media: ViewMedia,
|
||||
): media is FrigateRecordingViewMedia {
|
||||
return media instanceof FrigateRecordingViewMedia;
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { CameraConfig } from '../../types';
|
||||
import {
|
||||
ViewMedia,
|
||||
EventViewMedia,
|
||||
RecordingViewMedia,
|
||||
ViewMediaType,
|
||||
} from '../../view/media';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import {
|
||||
getEventMediaContentID,
|
||||
getEventThumbnailURL,
|
||||
getEventTitle,
|
||||
getRecordingID,
|
||||
getRecordingMediaContentID,
|
||||
getRecordingTitle,
|
||||
} from './util';
|
||||
|
||||
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
protected _event: FrigateEvent;
|
||||
protected _contentID: string;
|
||||
protected _thumbnail: string;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
event: FrigateEvent,
|
||||
contentID: string,
|
||||
thumbnail: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._event = event;
|
||||
this._contentID = contentID;
|
||||
this._thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
public hasClip(): boolean {
|
||||
return !!this._event.has_clip;
|
||||
}
|
||||
public getStartTime(): Date {
|
||||
return fromUnixTime(this._event.start_time);
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
|
||||
}
|
||||
public getID(): string {
|
||||
return this._event.id;
|
||||
}
|
||||
public getContentID(): string {
|
||||
return this._contentID;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return getEventTitle(this._event);
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._thumbnail;
|
||||
}
|
||||
public isFavorite(): boolean | null {
|
||||
return this._event.retain_indefinitely ?? null;
|
||||
}
|
||||
public setFavorite(favorite: boolean): void {
|
||||
this._event.retain_indefinitely = favorite;
|
||||
}
|
||||
public getWhat(): string[] | null {
|
||||
return [this._event.label];
|
||||
}
|
||||
public getWhere(): string[] | null {
|
||||
const zones = this._event.zones;
|
||||
return zones.length ? zones : null;
|
||||
}
|
||||
public getScore(): number | null {
|
||||
return this._event.top_score;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public isGroupableWith(that: EventViewMedia): boolean {
|
||||
return (
|
||||
this.getMediaType() === that.getMediaType() &&
|
||||
isEqual(this.getWhere(), that.getWhere()) &&
|
||||
isEqual(this.getWhat(), that.getWhat())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia {
|
||||
protected _recording: FrigateRecording;
|
||||
protected _id: string;
|
||||
protected _contentID: string;
|
||||
protected _title: string;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
recording: FrigateRecording,
|
||||
id: string,
|
||||
contentID: string,
|
||||
title: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._recording = recording;
|
||||
this._id = id;
|
||||
this._contentID = contentID;
|
||||
this._title = title;
|
||||
}
|
||||
|
||||
public getID(): string {
|
||||
return this._id;
|
||||
}
|
||||
public getStartTime(): Date {
|
||||
return this._recording.startTime;
|
||||
}
|
||||
public getEndTime(): Date {
|
||||
return this._recording.endTime;
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return this._contentID;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return this._title;
|
||||
}
|
||||
public getEventCount(): number {
|
||||
return this._recording.events;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateViewMediaFactory {
|
||||
static createEventViewMedia(
|
||||
mediaType: 'clip' | 'snapshot',
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
event: FrigateEvent,
|
||||
): FrigateEventViewMedia | null {
|
||||
if (
|
||||
(mediaType === 'clip' && !event.has_clip) ||
|
||||
(mediaType === 'snapshot' && !event.has_snapshot) ||
|
||||
!cameraConfig.frigate.client_id ||
|
||||
!cameraConfig.frigate.camera_name
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FrigateEventViewMedia(
|
||||
mediaType,
|
||||
cameraID,
|
||||
event,
|
||||
getEventMediaContentID(
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
event,
|
||||
mediaType === 'clip' ? 'clips' : 'snapshots',
|
||||
),
|
||||
getEventThumbnailURL(cameraConfig.frigate.client_id, event),
|
||||
);
|
||||
}
|
||||
|
||||
static createRecordingViewMedia(
|
||||
cameraID: string,
|
||||
recording: FrigateRecording,
|
||||
cameraConfig: CameraConfig,
|
||||
cameraTitle: string,
|
||||
): FrigateRecordingViewMedia | null {
|
||||
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FrigateRecordingViewMedia(
|
||||
'recording',
|
||||
cameraID,
|
||||
recording,
|
||||
getRecordingID(cameraConfig, recording),
|
||||
getRecordingMediaContentID(
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
recording,
|
||||
),
|
||||
getRecordingTitle(cameraTitle, recording),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { FrigateCardError, RecordingSegment } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha';
|
||||
import {
|
||||
EventSummary,
|
||||
eventSummarySchema,
|
||||
FrigateEvent,
|
||||
frigateEventsSchema,
|
||||
recordingSegmentsSchema,
|
||||
RecordingSummary,
|
||||
recordingSummarySchema,
|
||||
RetainResult,
|
||||
retainResultSchema,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Get the recordings summary. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @returns A RecordingSummary object.
|
||||
*/
|
||||
export const getRecordingsSummary = async (
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
camera_name: string,
|
||||
): Promise<RecordingSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSummarySchema,
|
||||
{
|
||||
type: 'frigate/recordings/summary',
|
||||
instance_id: clientID,
|
||||
camera: camera_name,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export interface NativeFrigateRecordingSegmentsQuery {
|
||||
instance_id: string;
|
||||
camera: string;
|
||||
after: number;
|
||||
before: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recording segments. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The recording segment query parameters.
|
||||
* @returns A RecordingSegments object.
|
||||
*/
|
||||
export const getRecordingSegments = async (
|
||||
hass: HomeAssistant,
|
||||
params: NativeFrigateRecordingSegmentsQuery,
|
||||
): Promise<RecordingSegment[]> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSegmentsSchema,
|
||||
{
|
||||
type: 'frigate/recordings/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request that Frigate retain an event. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param eventID The event ID to retain.
|
||||
* @param retain `true` to retain or `false` to unretain.
|
||||
*/
|
||||
export async function retainEvent(
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
eventID: string,
|
||||
retain: boolean,
|
||||
): Promise<void> {
|
||||
const retainRequest = {
|
||||
type: 'frigate/event/retain',
|
||||
instance_id: clientID,
|
||||
event_id: eventID,
|
||||
retain: retain,
|
||||
};
|
||||
const response = await homeAssistantWSRequest<RetainResult>(
|
||||
hass,
|
||||
retainResultSchema,
|
||||
retainRequest,
|
||||
true,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new FrigateCardError(localize('error.failed_retain'), {
|
||||
request: retainRequest,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeFrigateEventQuery {
|
||||
instance_id?: string;
|
||||
cameras?: string[];
|
||||
labels?: string[];
|
||||
zones?: string[];
|
||||
after?: number;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
has_clip?: boolean;
|
||||
has_snapshot?: boolean;
|
||||
favorites?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events over websocket. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The events search parameters.
|
||||
* @returns An array of 'FrigateEvent's.
|
||||
*/
|
||||
export const getEvents = async (
|
||||
hass: HomeAssistant,
|
||||
params?: NativeFrigateEventQuery,
|
||||
): Promise<FrigateEvent[]> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
frigateEventsSchema,
|
||||
{
|
||||
type: 'frigate/events/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export const getEventSummary = async (
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
): Promise<EventSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
eventSummarySchema,
|
||||
{
|
||||
type: 'frigate/events/summary',
|
||||
instance_id: clientID,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { dayToDate } from '../../utils/basic';
|
||||
|
||||
const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||
return typeof arg === 'string' ? dayToDate(arg) : arg;
|
||||
};
|
||||
|
||||
export const eventSchema = z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number().nullable(),
|
||||
false_positive: z.boolean().nullable(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
start_time: z.number(),
|
||||
top_score: z.number(),
|
||||
zones: z.string().array(),
|
||||
retain_indefinitely: z.boolean().optional(),
|
||||
});
|
||||
export const frigateEventsSchema = eventSchema.array();
|
||||
|
||||
export type FrigateEvent = z.infer<typeof eventSchema>;
|
||||
|
||||
const recordingSummaryHourSchema = z.object({
|
||||
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
|
||||
duration: z.number().min(0),
|
||||
events: z.number().min(0),
|
||||
});
|
||||
|
||||
export const recordingSummarySchema = z
|
||||
.object({
|
||||
day: z.preprocess(dayStringToDate, z.date()),
|
||||
events: z.number(),
|
||||
hours: recordingSummaryHourSchema.array(),
|
||||
})
|
||||
.array();
|
||||
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
|
||||
|
||||
const recordingSegmentSchema = z.object({
|
||||
start_time: z.number(),
|
||||
end_time: z.number(),
|
||||
id: z.string(),
|
||||
});
|
||||
export const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||
|
||||
export const retainResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
export type RetainResult = z.infer<typeof retainResultSchema>;
|
||||
|
||||
export interface FrigateRecording {
|
||||
cameraID: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
events: number;
|
||||
}
|
||||
|
||||
export const eventSummarySchema = z
|
||||
.object({
|
||||
camera: z.string(),
|
||||
day: z.string(),
|
||||
label: z.string(),
|
||||
zones: z.string().array(),
|
||||
})
|
||||
.array();
|
||||
export type EventSummary = z.infer<typeof eventSummarySchema>;
|
||||
@@ -1,97 +0,0 @@
|
||||
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
||||
import { CameraConfig, ClipsOrSnapshots } from '../../types';
|
||||
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
|
||||
/**
|
||||
* Given an event generate a title.
|
||||
* @param event
|
||||
*/
|
||||
export const getEventTitle = (event: FrigateEvent): string => {
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const durationSeconds = Math.round(
|
||||
event.end_time
|
||||
? event.end_time - event.start_time
|
||||
: Date.now() / 1000 - event.start_time,
|
||||
);
|
||||
return `${formatDateAndTime(
|
||||
utcToZonedTime(event.start_time * 1000, localTimezone),
|
||||
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
|
||||
event.top_score * 100,
|
||||
)}%]`;
|
||||
};
|
||||
|
||||
export const getRecordingTitle = (
|
||||
cameraTitle: string,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param event The event.
|
||||
* @returns A string URL.
|
||||
*/
|
||||
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
|
||||
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a media content ID for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param cameraName The Frigate camera name.
|
||||
* @param event The Frigate event.
|
||||
* @param mediaType The media type required.
|
||||
* @returns A string media content id.
|
||||
*/
|
||||
export const getEventMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
event: FrigateEvent,
|
||||
mediaType: ClipsOrSnapshots,
|
||||
): 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.
|
||||
*/
|
||||
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('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a recording ID for internal de-duping.
|
||||
*/
|
||||
export const getRecordingID = (
|
||||
cameraConfig: CameraConfig,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
// ID name is derived from the real camera name (not CameraID) since the
|
||||
// recordings for the same camera across multiple zones will be the same and
|
||||
// can be dedup'd from this id.
|
||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||
cameraConfig.frigate.camera_name ?? ''
|
||||
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
|
||||
};
|
||||
@@ -1,554 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, CardWideConfig } from '../types.js';
|
||||
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
MediaQuery,
|
||||
PartialDataQuery,
|
||||
PartialEventQuery,
|
||||
PartialQueryConcreteType,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResults,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResults,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap,
|
||||
} from './types.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { MediaQueries } from '../view/media-queries.js';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import sum from 'lodash-es/sum';
|
||||
import add from 'date-fns/add';
|
||||
import { log } from '../utils/debug.js';
|
||||
|
||||
export class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
return query.type === QueryType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingQuery {
|
||||
return query.type === QueryType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingSegmentsQuery {
|
||||
return query.type === QueryType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryResultClassifier {
|
||||
public static isEventQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is EventQueryResults {
|
||||
return queryResults.type === QueryResultsType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingQueryResults {
|
||||
return queryResults.type === QueryResultsType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingSegmentsQueryResults {
|
||||
return queryResults.type === QueryResultsType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
queries: T[];
|
||||
results: ViewMedia[];
|
||||
}
|
||||
|
||||
export class CameraManager {
|
||||
protected _engineFactory: CameraManagerEngineFactory;
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
protected _cardWideConfig?: CardWideConfig;
|
||||
|
||||
constructor(
|
||||
engineFactory: CameraManagerEngineFactory,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cardWideConfig?: CardWideConfig,
|
||||
) {
|
||||
this._engineFactory = engineFactory;
|
||||
this._cameras = cameras;
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
}
|
||||
|
||||
public generateDefaultEventQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.Event,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialRecordingQuery,
|
||||
): RecordingQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.Recording,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.RecordingSegments,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const processMetadata = async (engine: CameraManagerEngine): Promise<void> => {
|
||||
const engineMetadata = await engine.getMediaMetadata(hass, this._cameras);
|
||||
if (engineMetadata) {
|
||||
if (engineMetadata.what) {
|
||||
engineMetadata.what.forEach(what.add, what);
|
||||
}
|
||||
if (engineMetadata.where) {
|
||||
engineMetadata.where.forEach(where.add, where);
|
||||
}
|
||||
if (engineMetadata.days) {
|
||||
engineMetadata.days.forEach(days.add, days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await allPromises(engines, (engine) => processMetadata(engine));
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
}
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
): PartialQueryConcreteType<PQT>[] | null {
|
||||
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||
const _cameraIDs = setify(cameraIDs);
|
||||
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
_cameraIDs,
|
||||
);
|
||||
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [engine, cameraIDs] of engines) {
|
||||
let queries: DataQuery[] | null = null;
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultEventQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingSegmentsQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
}
|
||||
|
||||
for (const query of queries ?? []) {
|
||||
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||
}
|
||||
}
|
||||
return concreteQueries;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
query: EventQuery | EventQuery[],
|
||||
): Promise<EventQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingQuery | RecordingQuery[],
|
||||
): Promise<RecordingQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||
): Promise<RecordingSegmentsQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async executeMediaQueries(
|
||||
hass: HomeAssistant,
|
||||
mediaQueries: MediaQueries,
|
||||
): Promise<MediaQueriesResults | null> {
|
||||
const queries: (RecordingQuery | EventQuery)[] | null = mediaQueries.getQueries();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
const mediaArray = this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, queries),
|
||||
);
|
||||
|
||||
return new MediaQueriesResults(
|
||||
mediaArray,
|
||||
|
||||
// Select the first (most-recent) item.
|
||||
mediaArray.length ? 0 : null,
|
||||
);
|
||||
}
|
||||
|
||||
public async extendMediaQueries<T extends MediaQuery>(
|
||||
hass: HomeAssistant,
|
||||
queries: T[],
|
||||
results: ViewMedia[],
|
||||
direction: 'earlier' | 'later',
|
||||
chunkSize: number,
|
||||
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||
let output: Date | null = null;
|
||||
for (const result of results) {
|
||||
const startTime = result.getStartTime();
|
||||
if (
|
||||
startTime &&
|
||||
(!output ||
|
||||
(want === 'earliest' && startTime < output) ||
|
||||
(want === 'latest' && startTime > output))
|
||||
) {
|
||||
output = startTime;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// The queries associated with the chunk to fetch.
|
||||
const newChunkQueries: T[] = [];
|
||||
|
||||
// The re-constituted combined query.
|
||||
const extendedQueries: T[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
const newChunkQuery = { ...query };
|
||||
|
||||
if (direction === 'later') {
|
||||
newChunkQuery.start = getTimeFromResults('latest') ?? undefined;
|
||||
} else if (direction === 'earlier') {
|
||||
newChunkQuery.end = getTimeFromResults('earliest') ?? undefined;
|
||||
}
|
||||
newChunkQuery.limit = chunkSize;
|
||||
|
||||
extendedQueries.push({
|
||||
...query,
|
||||
limit: (query.limit ?? 0) + chunkSize,
|
||||
});
|
||||
newChunkQueries.push(newChunkQuery);
|
||||
}
|
||||
|
||||
const newChunkMedia = this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, newChunkQueries),
|
||||
);
|
||||
|
||||
if (!newChunkMedia.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
queries: extendedQueries,
|
||||
results: this._sortMedia(results.concat(newChunkMedia)),
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const engine = cameraConfig
|
||||
? this._engineFactory.getEngineForCamera(cameraConfig)
|
||||
: null;
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaDownloadPath(cameraConfig, media);
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerCapabilities | null {
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
canFavoriteEvents: engines.some((engine) => engine.getCapabilities()?.canFavoriteEvents),
|
||||
canFavoriteRecordings: engines.some((engine) => engine.getCapabilities()?.canFavoriteRecordings),
|
||||
}
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||
const engine = this._engineFactory.getEngineForMedia(this._cameras, media);
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaCapabilities(media);
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (engine) {
|
||||
const queryStartTime = new Date();
|
||||
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager favorite request (',
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Media:',
|
||||
media.getID(),
|
||||
', Favorite:',
|
||||
favorite,
|
||||
')',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh(
|
||||
queries: MediaQueries,
|
||||
results: MediaQueriesResults,
|
||||
): boolean {
|
||||
const now = new Date();
|
||||
const resultsTimestamp = results.getResultsTimestamp();
|
||||
|
||||
if (!resultsTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const query of queries.getQueries() ?? []) {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
for (const [engine, cameraIDs] of engines ?? []) {
|
||||
const maxAgeSeconds = engine.getQueryResultMaxAge({
|
||||
...query,
|
||||
cameraIDs: cameraIDs,
|
||||
});
|
||||
if (
|
||||
maxAgeSeconds !== null &&
|
||||
add(resultsTimestamp, { seconds: maxAgeSeconds }) < now
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null> {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
if (
|
||||
!cameraConfig ||
|
||||
!startTime ||
|
||||
!endTime ||
|
||||
target < startTime ||
|
||||
target > endTime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
|
||||
}
|
||||
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
query: QT | QT[],
|
||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||
const _queries = arrayify(query);
|
||||
const results = new Map<QT, QueryReturnType<QT>>();
|
||||
const queryStartTime = new Date();
|
||||
|
||||
const processEngineQuery = async (
|
||||
engine: CameraManagerEngine,
|
||||
query?: QT,
|
||||
): Promise<void> => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
engineResult = (await engine.getRecordingSegments(
|
||||
hass,
|
||||
this._cameras,
|
||||
query,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
}
|
||||
|
||||
engineResult?.forEach((value, key) => results.set(key, value));
|
||||
};
|
||||
|
||||
const processQuery = async (query: QT): Promise<void> => {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
if (!engines) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(engines.keys()).map((engine) =>
|
||||
processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||
|
||||
const cachedOutputQueries = sum(
|
||||
Array.from(results.values()).map((result) => Number(result.cached)),
|
||||
);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager request [Input queries:',
|
||||
_queries.length,
|
||||
', Cached output queries:',
|
||||
cachedOutputQueries,
|
||||
', Total output queries:',
|
||||
results.size,
|
||||
', Duration:',
|
||||
`${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
', Queries:',
|
||||
_queries,
|
||||
', Results:',
|
||||
results,
|
||||
']',
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
protected _convertQueryResultsToMedia<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
results: ResultsMap<QT>,
|
||||
): ViewMedia[] {
|
||||
const mediaArray: ViewMedia[] = [];
|
||||
for (const [query, result] of results.entries()) {
|
||||
const engine = this._engineFactory.getEngine(result.engine);
|
||||
|
||||
if (engine) {
|
||||
let media: ViewMedia[] | null = null;
|
||||
if (
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromEvents(hass, this._cameras, query, result);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQuery(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._cameras, query, result);
|
||||
}
|
||||
if (media) {
|
||||
mediaArray.push(...media);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._sortMedia(mediaArray);
|
||||
}
|
||||
|
||||
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading with the most recent.
|
||||
(media) => media.getStartTime(),
|
||||
'desc',
|
||||
);
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig?: CameraConfig,
|
||||
): CameraManagerCameraMetadata | null {
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
return engine.getCameraMetadata(hass, cameraConfig);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
|
||||
interface Range<T extends Date | number> {
|
||||
start: T;
|
||||
end: T;
|
||||
}
|
||||
|
||||
export type DateRange = Range<Date>;
|
||||
|
||||
interface MemoryRangeSetInterface<T> {
|
||||
hasCoverage(range: T): boolean;
|
||||
add(range: T): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export class MemoryRangeSet implements MemoryRangeSetInterface<DateRange> {
|
||||
protected _ranges: DateRange[];
|
||||
|
||||
constructor(ranges?: DateRange[]) {
|
||||
this._ranges = ranges ?? [];
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.some((cachedRange) =>
|
||||
rangeIsEntirelyContained(cachedRange, range),
|
||||
);
|
||||
}
|
||||
|
||||
public add(range: DateRange): void {
|
||||
this._ranges.push(range);
|
||||
this._ranges = compressRanges(this._ranges);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._ranges = [];
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpiringRange<T extends Date | number> extends Range<T> {
|
||||
expires: Date;
|
||||
}
|
||||
|
||||
export class ExpiringMemoryRangeSet
|
||||
implements MemoryRangeSetInterface<ExpiringRange<Date>>
|
||||
{
|
||||
protected _ranges: ExpiringRange<Date>[];
|
||||
|
||||
constructor(ranges?: ExpiringRange<Date>[]) {
|
||||
this._ranges = ranges ?? [];
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
const now = new Date();
|
||||
return this._ranges.some(
|
||||
(cachedRange) =>
|
||||
now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range),
|
||||
);
|
||||
}
|
||||
|
||||
public add(range: ExpiringRange<Date>): void {
|
||||
this._expireOldRanges();
|
||||
this._ranges.push(range);
|
||||
}
|
||||
|
||||
protected _expireOldRanges(): void {
|
||||
const now = new Date();
|
||||
this._ranges = this._ranges.filter((range) => now < range.expires);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._ranges = [];
|
||||
}
|
||||
}
|
||||
|
||||
const rangeIsEntirelyContained = (bigger: DateRange, smaller: DateRange): boolean => {
|
||||
return smaller.start >= bigger.start && smaller.end <= bigger.end;
|
||||
};
|
||||
|
||||
export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
||||
return (
|
||||
// a starts within the range of b.
|
||||
(a.start >= b.start && a.start <= b.end) ||
|
||||
// a events within the range of b.
|
||||
(a.end >= b.start && a.end <= b.end) ||
|
||||
// a encompasses the entire range of b.
|
||||
(a.start <= b.start && a.end >= b.end)
|
||||
);
|
||||
};
|
||||
|
||||
export const compressRanges = <T extends Date | number>(
|
||||
ranges: Range<T>[],
|
||||
toleranceSeconds = 0,
|
||||
): Range<T>[] => {
|
||||
const compressedRanges: Range<T>[] = [];
|
||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||
|
||||
let current: Range<T> | null = null;
|
||||
for (let i = 0; i < ranges.length; ++i) {
|
||||
const item = ranges[i];
|
||||
const itemStartSeconds =
|
||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEndSeconds =
|
||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||
if (item.end > current.end) {
|
||||
current.end = item.end;
|
||||
}
|
||||
} else {
|
||||
compressedRanges.push(current);
|
||||
current = { ...item };
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
compressedRanges.push(current);
|
||||
}
|
||||
|
||||
return compressedRanges;
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
import { RecordingSegment } from '../types';
|
||||
import { FrigateEvent, FrigateRecording } from './frigate/types';
|
||||
|
||||
// ====
|
||||
// Base
|
||||
// ====
|
||||
|
||||
export enum QueryType {
|
||||
Event = 'event-query',
|
||||
Recording = 'recording-query',
|
||||
RecordingSegments = 'recording-segments-query',
|
||||
}
|
||||
|
||||
export enum QueryResultsType {
|
||||
Event = 'event-results',
|
||||
Recording = 'recording-results',
|
||||
RecordingSegments = 'recording-segments-results',
|
||||
}
|
||||
|
||||
export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
type: QueryType;
|
||||
cameraIDs: Set<string>;
|
||||
}
|
||||
export type PartialDataQuery = Partial<DataQuery>;
|
||||
|
||||
export interface TimeBasedDataQuery {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface LimitedDataQuery {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface MediaQuery
|
||||
extends DataQuery,
|
||||
Partial<TimeBasedDataQuery>,
|
||||
Partial<LimitedDataQuery> {}
|
||||
|
||||
export interface QueryResults {
|
||||
type: QueryResultsType;
|
||||
engine: Engine;
|
||||
expiry?: Date;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export type QueryReturnType<QT> = QT extends EventQuery
|
||||
? EventQueryResults
|
||||
: QT extends RecordingQuery
|
||||
? RecordingQueryResults
|
||||
: QT extends RecordingSegmentsQuery
|
||||
? RecordingSegmentsQueryResults
|
||||
: never;
|
||||
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||
? EventQuery
|
||||
: PQT extends PartialRecordingQuery
|
||||
? RecordingQuery
|
||||
: PQT extends PartialRecordingSegmentsQuery
|
||||
? RecordingSegmentsQuery
|
||||
: never;
|
||||
|
||||
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
||||
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
|
||||
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
|
||||
|
||||
export interface MediaMetadata {
|
||||
where?: Set<string>;
|
||||
what?: Set<string>;
|
||||
days?: Set<string>;
|
||||
}
|
||||
|
||||
interface BaseCapabilities {
|
||||
canFavoriteEvents: boolean;
|
||||
canFavoriteRecordings: boolean;
|
||||
}
|
||||
|
||||
export type CameraManagerCapabilities = BaseCapabilities;
|
||||
export type CameraManagerEngineCapabilities = BaseCapabilities;
|
||||
export interface CameraManagerMediaCapabilities {
|
||||
canFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface CameraManagerCameraMetadata {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
export interface EventQuery extends MediaQuery {
|
||||
type: QueryType.Event;
|
||||
|
||||
// Frigate equivalent: has_snapshot
|
||||
hasSnapshot?: boolean;
|
||||
|
||||
// Frigate equivalent: has_clip
|
||||
hasClip?: boolean;
|
||||
|
||||
// Frigate equivalent: label
|
||||
what?: Set<string>;
|
||||
|
||||
// Frigate equivalent: zone
|
||||
where?: Set<string>;
|
||||
|
||||
favorite?: boolean;
|
||||
}
|
||||
export type PartialEventQuery = Partial<EventQuery>;
|
||||
|
||||
export interface EventQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Event;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Recording Query
|
||||
// ===============
|
||||
|
||||
export interface RecordingQuery extends MediaQuery {
|
||||
type: QueryType.Recording;
|
||||
|
||||
favorite?: boolean;
|
||||
}
|
||||
export type PartialRecordingQuery = Partial<RecordingQuery>;
|
||||
|
||||
export interface RecordingQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Recording;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Recording Segments Query
|
||||
// ========================
|
||||
|
||||
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||
type: QueryType.RecordingSegments;
|
||||
}
|
||||
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||
|
||||
export interface RecordingSegmentsQueryResults extends QueryResults {
|
||||
type: QueryResultsType.RecordingSegments;
|
||||
segments: RecordingSegment[];
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Frigate concrete results
|
||||
// ========================
|
||||
|
||||
export interface FrigateEventQueryResults extends EventQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
events: FrigateEvent[];
|
||||
}
|
||||
|
||||
export interface FrigateRecordingQueryResults extends RecordingQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
recordings: FrigateRecording[];
|
||||
}
|
||||
|
||||
export interface FrigateRecordingSegmentsQueryResults
|
||||
extends RecordingSegmentsQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import { DateRange } from './range';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
options?: {
|
||||
endCap?: boolean;
|
||||
},
|
||||
): DateRange => {
|
||||
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||
let cacheableStart: Date;
|
||||
let cacheableEnd: Date;
|
||||
|
||||
if (widthSeconds <= 60 * 60) {
|
||||
cacheableStart = startOfHour(range.start);
|
||||
cacheableEnd = endOfHour(range.end);
|
||||
} else {
|
||||
cacheableStart = startOfDay(range.start);
|
||||
cacheableEnd = endOfDay(range.end);
|
||||
}
|
||||
|
||||
if (options?.endCap) {
|
||||
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||
}
|
||||
|
||||
return {
|
||||
start: cacheableStart,
|
||||
end: cacheableEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
+2
-2
@@ -91,9 +91,9 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||
import { View } from './view/view.js';
|
||||
import pkg from '../package.json';
|
||||
import { ViewContext } from 'view';
|
||||
import { CameraManager } from './camera/manager.js';
|
||||
import { CameraManager } from './camera-manager/manager.js';
|
||||
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
|
||||
import { CameraManagerEngineFactory } from './camera/engine-factory.js';
|
||||
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
||||
import { log } from './utils/debug.js';
|
||||
|
||||
/** A note on media callbacks:
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
} from '../utils/media-to-view.js';
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera/manager.js';
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
@@ -30,7 +30,7 @@ import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
import { createRef, Ref } from 'lit/directives/ref.js';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import './media-filter';
|
||||
|
||||
@@ -50,7 +50,7 @@ import '../surround.js';
|
||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { CameraManager } from '../../camera/manager.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { DateRange } from '../camera/range';
|
||||
import { DateRange } from '../camera-manager/range';
|
||||
import { localize } from '../localize/localize';
|
||||
import mediaFilterCoreStyle from '../scss/media-filter-core.scss';
|
||||
import { ExtendedHomeAssistant } from '../types';
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { CameraManager } from '../camera/manager';
|
||||
import { DateRange } from '../camera/range';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { DateRange } from '../camera-manager/range';
|
||||
import { CameraConfig, ExtendedHomeAssistant } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import {
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from './media-filter-core';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera/types';
|
||||
import { EventQuery, MediaMetadata, QueryType, RecordingQuery } from '../camera-manager/types';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
frigateCardHasAction,
|
||||
getActionConfigGivenAction
|
||||
} from '../utils/action.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from '../camera/frigate/icon.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
|
||||
import { refreshDynamicStateParameters } from '../utils/ha';
|
||||
import './submenu.js';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { CameraManager } from '../camera/manager.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
import './surround-basic.js';
|
||||
|
||||
@@ -25,7 +25,7 @@ import { FrigateCardCarousel } from './carousel.js';
|
||||
import './thumbnail.js';
|
||||
import './carousel.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../camera/manager.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
|
||||
export interface ThumbnailCarouselTap {
|
||||
queryResults: MediaQueriesResults;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Task, TaskStatus } from '@lit-labs/task';
|
||||
|
||||
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
|
||||
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
|
||||
import { CameraManager } from '../camera/manager.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier.js';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
createViewForRecordings,
|
||||
findClosestMediaIndex,
|
||||
} from '../utils/media-to-view';
|
||||
import { CameraManager } from '../camera/manager';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { dispatchMessageEvent } from './message.js';
|
||||
@@ -57,7 +57,7 @@ import './thumbnail.js';
|
||||
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { rangesOverlap } from '../camera/range';
|
||||
import { rangesOverlap } from '../camera-manager/range';
|
||||
import { View } from '../view/view';
|
||||
|
||||
interface FrigateCardGroupData {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import timelineStyle from '../scss/timeline.scss';
|
||||
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { CameraManager } from '../camera/manager';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { View } from '../view/view';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
|
||||
@@ -47,7 +47,7 @@ import '../patches/ha-hls-player';
|
||||
import './surround.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { CameraManager } from '../camera/manager.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ import {
|
||||
} from './types.js';
|
||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
||||
import { getCameraID } from './utils/camera.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from './camera/frigate/icon.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
|
||||
import {
|
||||
getEntitiesFromHASS,
|
||||
getEntityTitle,
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
} from 'custom-card-helpers';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { z } from 'zod';
|
||||
import { eventSchema, FrigateEvent, FrigateRecording } from './camera/frigate/types.js';
|
||||
import { eventSchema, FrigateEvent, FrigateRecording } from './camera-manager/frigate/types.js';
|
||||
import { deepRemoveDefaults } from './utils/zod.js';
|
||||
|
||||
// The min allowed size of buttons.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import { FrigateEvent, FrigateRecording } from '../../camera/frigate/types';
|
||||
import { FrigateEvent, FrigateRecording } from '../../camera-manager/frigate/types';
|
||||
import { dispatchErrorMessageEvent } from '../../components/message.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MediaQueries,
|
||||
RecordingMediaQueries,
|
||||
} from '../view/media-queries';
|
||||
import { CameraManager } from '../camera/manager';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { getAllDependentCameras } from './camera.js';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
@@ -4,12 +4,12 @@ import sub from 'date-fns/sub';
|
||||
import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { ClipsOrSnapshotsOrAll, RecordingSegment } from '../types';
|
||||
import { CameraManager } from '../camera/manager';
|
||||
import { EventQuery } from '../camera/types';
|
||||
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera/util';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { EventQuery } from '../camera-manager/types';
|
||||
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
|
||||
import { EventMediaQueries } from '../view/media-queries';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera/range';
|
||||
import { compressRanges, ExpiringMemoryRangeSet, MemoryRangeSet } from '../camera-manager/range';
|
||||
import { errorToConsole, ModifyInterface } from './basic.js';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import cloneDeep from 'lodash-es/cloneDeep.js';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera/types.js';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js';
|
||||
|
||||
export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user