403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import { fromUnixTime } from 'date-fns';
|
|
import type { MessageBase } from 'home-assistant-js-websocket';
|
|
|
|
import type { NativeFrigateEventQuery } from '../../src/camera-manager/frigate/requests';
|
|
import type { EventSummary, FrigateEvent } from '../../src/camera-manager/frigate/types';
|
|
import type { PartialAdvancedCameraCardConfig } from '../../src/config/types';
|
|
import type { ResolvedMedia } from '../../src/ha/types';
|
|
import { createFrigateEvent } from '../test-utils';
|
|
import type { FakeHASS, WSCommandHandler } from './fake-hass';
|
|
import {
|
|
CLIP_FIXTURE_FILENAME,
|
|
createFixtureURL,
|
|
SNAPSHOT_FIXTURE_FILENAME,
|
|
} from './fixtures';
|
|
import { MountedCardFactory, type MountedCard } from './mounted-card';
|
|
import {
|
|
CAMERA_ENTITY,
|
|
createCameraHASS,
|
|
createStillImageCardConfig,
|
|
type FakeCameraDescription,
|
|
} from './test-utils';
|
|
|
|
export const FRIGATE_CLIENT_ID = 'frigate';
|
|
export type FrigateMediaType = 'clips' | 'snapshots';
|
|
const FRIGATE_CONFIG_ENTRY_ID = 'frigate-config-entry';
|
|
|
|
/**
|
|
* Frigate's name for a test camera, which this harness keeps equal to the
|
|
* entity's object ID.
|
|
*/
|
|
export const getTestFrigateCameraName = (cameraEntity: string): string =>
|
|
cameraEntity.split('.')[1];
|
|
|
|
/**
|
|
* A camera belonging to Frigate.
|
|
*/
|
|
export const createFrigateCameraDescription = (
|
|
entityID: string = CAMERA_ENTITY,
|
|
): FakeCameraDescription => ({
|
|
entityID,
|
|
entity: { state: 'idle', attributes: { client_id: FRIGATE_CLIENT_ID } },
|
|
registry: {
|
|
platform: 'frigate',
|
|
unique_id: `${FRIGATE_CLIENT_ID}:camera:${getTestFrigateCameraName(entityID)}`,
|
|
config_entry_id: FRIGATE_CONFIG_ENTRY_ID,
|
|
},
|
|
});
|
|
|
|
// Frigate timestamps are UNIX seconds. Two of them, so a test can have an event
|
|
// on each side of the other.
|
|
export const EVENT_TIME_OLDER = 1754300000;
|
|
export const EVENT_TIME_NEWER = 1754310000;
|
|
|
|
const EVENT_DURATION_SECONDS = 10;
|
|
|
|
export const createTestFrigateEvent = (
|
|
id: string,
|
|
startTime: number,
|
|
event?: Partial<FrigateEvent>,
|
|
): FrigateEvent =>
|
|
createFrigateEvent({
|
|
camera: getTestFrigateCameraName(CAMERA_ENTITY),
|
|
id,
|
|
start_time: startTime,
|
|
end_time: startTime + EVENT_DURATION_SECONDS,
|
|
...event,
|
|
});
|
|
|
|
export interface CardWithFrigate {
|
|
card: MountedCard;
|
|
frigate: FakeFrigate;
|
|
}
|
|
|
|
/**
|
|
* A card whose camera is Frigate's, with the given events already detected.
|
|
*/
|
|
export const mountCardWithFrigate = async (
|
|
events: FrigateEvent[],
|
|
config?: PartialAdvancedCameraCardConfig,
|
|
): Promise<CardWithFrigate> => {
|
|
const hass = createCameraHASS([createFrigateCameraDescription()]);
|
|
const frigate = new FakeFrigate(hass);
|
|
frigate.setEvents(events);
|
|
|
|
const card = await MountedCardFactory.createFromSource(
|
|
createStillImageCardConfig(config),
|
|
hass,
|
|
);
|
|
|
|
return { card, frigate };
|
|
};
|
|
|
|
// An event's media content ID, as `getEventMediaContentID` builds it:
|
|
// media-source://frigate/<client>/event/<clips|snapshots>/<camera>/<id>
|
|
const EVENT_CONTENT_ID =
|
|
/^media-source:\/\/frigate\/(?<clientID>[^/]+)\/event\/(?<mediaType>[^/]+)\/(?<camera>[^/]+)\/(?<eventID>[^/]+)$/;
|
|
|
|
// An event's thumbnail, as `getEventThumbnailURL` asks for it:
|
|
// /api/frigate/<client>/thumbnail/<id>
|
|
const THUMBNAIL_PATH =
|
|
/^\/api\/frigate\/(?<clientID>[^/]+)\/thumbnail\/(?<eventID>[^/]+)$/;
|
|
|
|
const isStringArray = (value: unknown): value is string[] =>
|
|
Array.isArray(value) && value.every((entry) => typeof entry === 'string');
|
|
|
|
const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean';
|
|
|
|
const isNumber = (value: unknown): value is number => typeof value === 'number';
|
|
|
|
// Read a request parameter, refusing a value of the wrong type.
|
|
const readParameter = <T>(
|
|
message: MessageBase,
|
|
name: string,
|
|
isExpected: (value: unknown) => value is T,
|
|
expected: string,
|
|
): T | undefined => {
|
|
const value: unknown = message[name];
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (!isExpected(value)) {
|
|
throw new Error(
|
|
`FakeFrigate was sent a '${name}' that is not ${expected}: ` +
|
|
JSON.stringify(value),
|
|
);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const EVERY_REQUEST_PARAMETERS = ['type'];
|
|
|
|
// Refuse a request carrying a parameter this Frigate does not recognize.
|
|
const requireKnownParameters = (message: MessageBase, parameters: string[]): void => {
|
|
const known = [...EVERY_REQUEST_PARAMETERS, ...parameters];
|
|
const unknown = Object.keys(message).filter((name) => !known.includes(name));
|
|
|
|
if (unknown.length) {
|
|
throw new Error(`FakeFrigate was sent unknown parameters: ${unknown}`);
|
|
}
|
|
};
|
|
|
|
// Frigate keeps every sub label an event has in one comma separated string.
|
|
const getSubLabels = (event: FrigateEvent): string[] =>
|
|
event.sub_label?.split(',').map((subLabel) => subLabel.trim()) ?? [];
|
|
|
|
// The day an event falls on. Always UTC, the only time zone a `FakeHASS` reports,
|
|
// so `formatDate` cannot be used: it renders in local time.
|
|
const getEventDay = (event: FrigateEvent): string =>
|
|
fromUnixTime(event.start_time).toISOString().slice(0, 'YYYY-MM-DD'.length);
|
|
|
|
interface FrigateMediaReference {
|
|
clientID: string;
|
|
mediaType: FrigateMediaType;
|
|
camera: string;
|
|
eventID: string;
|
|
}
|
|
|
|
// The event media a request is asking for, read out of its media content ID.
|
|
const parseContentID = (contentID: string): FrigateMediaReference | null => {
|
|
const groups = EVENT_CONTENT_ID.exec(contentID)?.groups;
|
|
if (!groups) {
|
|
return null;
|
|
}
|
|
|
|
const mediaType = groups['mediaType'];
|
|
if (mediaType !== 'clips' && mediaType !== 'snapshots') {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
clientID: groups['clientID'],
|
|
mediaType,
|
|
camera: groups['camera'],
|
|
eventID: groups['eventID'],
|
|
};
|
|
};
|
|
|
|
// The instance is separate from the actual query.
|
|
type EventQuery = Omit<NativeFrigateEventQuery, 'instance_id'>;
|
|
|
|
// Read the whole query before applying any of it. A request carrying nonsense is
|
|
// then refused even when no event would have matched anyway.
|
|
const readEventQuery = (message: MessageBase): EventQuery => {
|
|
const getList = (name: string): string[] | undefined =>
|
|
readParameter(message, name, isStringArray, 'a list of strings');
|
|
const getBoolean = (name: string): boolean | undefined =>
|
|
readParameter(message, name, isBoolean, 'true or false');
|
|
const getCount = (name: string): number | undefined =>
|
|
readParameter(message, name, isNumber, 'a number');
|
|
|
|
return {
|
|
cameras: getList('cameras'),
|
|
labels: getList('labels'),
|
|
sub_labels: getList('sub_labels'),
|
|
zones: getList('zones'),
|
|
after: getCount('after'),
|
|
before: getCount('before'),
|
|
favorites: getBoolean('favorites'),
|
|
has_clip: getBoolean('has_clip'),
|
|
has_snapshot: getBoolean('has_snapshot'),
|
|
limit: getCount('limit'),
|
|
};
|
|
};
|
|
|
|
// Whether an event is one the request asked for.
|
|
const matchesEventQuery = (event: FrigateEvent, query: EventQuery): boolean =>
|
|
(!query.cameras || query.cameras.includes(event.camera)) &&
|
|
(!query.labels || query.labels.includes(event.label)) &&
|
|
(!query.sub_labels ||
|
|
getSubLabels(event).some((subLabel) => query.sub_labels?.includes(subLabel))) &&
|
|
(!query.zones || query.zones.some((zone) => event.zones.includes(zone))) &&
|
|
// An event that began before the period and was still running when it started
|
|
// counts as falling within it.
|
|
(query.after === undefined || (event.end_time ?? Infinity) >= query.after) &&
|
|
(query.before === undefined || event.start_time <= query.before) &&
|
|
(!query.favorites || !!event.retain_indefinitely) &&
|
|
(!query.has_clip || event.has_clip) &&
|
|
(!query.has_snapshot || event.has_snapshot);
|
|
|
|
/**
|
|
* A Frigate instance behind a `FakeHASS`. Holds the events a test wants a camera
|
|
* to have detected, and answers what the card asks about them.
|
|
*
|
|
* Any missing functionality returns an error.
|
|
*/
|
|
export class FakeFrigate {
|
|
private _events: FrigateEvent[] = [];
|
|
private _mediaURLs = new Map<string, string>();
|
|
|
|
constructor(hass: FakeHASS) {
|
|
hass.registerCommand(
|
|
'frigate/events/get',
|
|
this._answerAsFrigate(
|
|
[
|
|
'after',
|
|
'before',
|
|
'cameras',
|
|
'favorites',
|
|
'has_clip',
|
|
'has_snapshot',
|
|
'labels',
|
|
'limit',
|
|
'sub_labels',
|
|
'zones',
|
|
],
|
|
(message) => this._queryEvents(message),
|
|
),
|
|
);
|
|
hass.registerCommand(
|
|
'frigate/events/summary',
|
|
this._answerAsFrigate(['timezone'], () => this._summariseEvents()),
|
|
);
|
|
|
|
// This Frigate keeps no recordings and cannot be given any. The media filter
|
|
// and the viewer's seek still ask, and an unanswered request is an error.
|
|
hass.registerCommand(
|
|
'frigate/recordings/summary',
|
|
this._answerAsFrigate(['camera', 'timezone'], () => []),
|
|
);
|
|
hass.registerCommand(
|
|
'frigate/recordings/get',
|
|
this._answerAsFrigate(['after', 'before', 'camera'], () => []),
|
|
);
|
|
|
|
// Fake Frigate has no review items and cannot be given any. The live view
|
|
// asks for them on startup, so leaving this unanswered would raise a media
|
|
// query issue on every card.
|
|
hass.registerCommand(
|
|
'frigate/reviews/get',
|
|
this._answerAsFrigate(
|
|
[
|
|
'after',
|
|
'before',
|
|
'cameras',
|
|
'labels',
|
|
'limit',
|
|
'reviewed',
|
|
'severity',
|
|
'zones',
|
|
],
|
|
() => [],
|
|
),
|
|
);
|
|
hass.registerCommand(
|
|
'frigate/ptz/info',
|
|
this._answerAsFrigate(['camera'], () => ({})),
|
|
);
|
|
hass.registerPath(THUMBNAIL_PATH, (path) => this._serveThumbnail(path));
|
|
|
|
hass.registerMediaSource(EVENT_CONTENT_ID, (contentID) =>
|
|
this._resolveMedia(contentID),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The events this Frigate instance has. Held newest first, as Frigate returns them.
|
|
*/
|
|
public setEvents(events: FrigateEvent[]): void {
|
|
this._events = [...events].sort((a, b) => b.start_time - a.start_time);
|
|
}
|
|
|
|
/**
|
|
* Set the URL for a media item.
|
|
*/
|
|
public setMediaURL(eventID: string, mediaType: FrigateMediaType, url: string): void {
|
|
this._mediaURLs.set(this._getMediaKey(eventID, mediaType), url);
|
|
}
|
|
|
|
// Answer a command addressed to this instance, refusing one meant for
|
|
// another. Frigate answers with JSON.
|
|
private _answerAsFrigate(
|
|
parameters: string[],
|
|
handler: (message: MessageBase) => unknown,
|
|
): WSCommandHandler {
|
|
return (message: MessageBase): string => {
|
|
const instanceID: unknown = message['instance_id'];
|
|
if (instanceID !== FRIGATE_CLIENT_ID) {
|
|
throw new Error(
|
|
`FakeFrigate was asked for another instance: ${String(instanceID)}`,
|
|
);
|
|
}
|
|
requireKnownParameters(message, ['instance_id', ...parameters]);
|
|
|
|
return JSON.stringify(handler(message));
|
|
};
|
|
}
|
|
|
|
private _getMediaKey(eventID: string, mediaType: FrigateMediaType): string {
|
|
return `${eventID}/${mediaType}`;
|
|
}
|
|
|
|
private _getEvent(eventID: string): FrigateEvent | null {
|
|
return this._events.find((event) => event.id === eventID) ?? null;
|
|
}
|
|
|
|
private _queryEvents(message: MessageBase): FrigateEvent[] {
|
|
const query = readEventQuery(message);
|
|
const matching = this._events.filter((event) => matchesEventQuery(event, query));
|
|
|
|
return query.limit === undefined ? matching : matching.slice(0, query.limit);
|
|
}
|
|
|
|
// What the media filter offers to filter by: every camera, day, label and zone
|
|
// combination the events cover.
|
|
private _summariseEvents(): EventSummary {
|
|
const summaries = new Map<string, EventSummary[number]>();
|
|
|
|
for (const event of this._events) {
|
|
const summary = {
|
|
camera: event.camera,
|
|
day: getEventDay(event),
|
|
label: event.label,
|
|
sub_label: event.sub_label,
|
|
zones: event.zones,
|
|
};
|
|
summaries.set(JSON.stringify(summary), summary);
|
|
}
|
|
|
|
return [...summaries.values()];
|
|
}
|
|
|
|
// Where an event's media is served from. The URL names the event, so a test can
|
|
// tell which media is on screen without looking at the picture.
|
|
private _resolveMedia(contentID: string): ResolvedMedia {
|
|
const media = parseContentID(contentID);
|
|
const event = media ? this._getEvent(media.eventID) : null;
|
|
const isClip = media?.mediaType === 'clips';
|
|
|
|
if (
|
|
!media ||
|
|
!event ||
|
|
media.clientID !== FRIGATE_CLIENT_ID ||
|
|
media.camera !== event.camera ||
|
|
!(isClip ? event.has_clip : event.has_snapshot)
|
|
) {
|
|
throw new Error(`FakeFrigate has no such media: ${contentID}`);
|
|
}
|
|
|
|
const filename = isClip ? CLIP_FIXTURE_FILENAME : SNAPSHOT_FIXTURE_FILENAME;
|
|
|
|
return {
|
|
url:
|
|
this._mediaURLs.get(this._getMediaKey(media.eventID, media.mediaType)) ??
|
|
`${createFixtureURL(filename)}?event=${media.eventID}`,
|
|
mime_type: isClip ? 'video/webm' : 'image/png',
|
|
};
|
|
}
|
|
|
|
private async _serveThumbnail(path: string): Promise<Response> {
|
|
const groups = THUMBNAIL_PATH.exec(path)?.groups;
|
|
|
|
if (
|
|
!groups ||
|
|
groups['clientID'] !== FRIGATE_CLIENT_ID ||
|
|
!this._getEvent(groups['eventID'])
|
|
) {
|
|
throw new Error(`FakeFrigate has no thumbnail at: ${path}`);
|
|
}
|
|
|
|
return await fetch(createFixtureURL(SNAPSHOT_FIXTURE_FILENAME));
|
|
}
|
|
}
|