Initial mini-timeline commit.

This commit is contained in:
Dermot Duffy
2022-09-22 17:44:43 -07:00
parent 1244cfc925
commit eb1b44eb27
19 changed files with 1461 additions and 819 deletions
+35
View File
@@ -72,3 +72,38 @@ export function getCameraIcon(
): string {
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
}
/**
* Get all cameras that depend on a given camera.
* @param cameras Cameras map.
* @param camera Name of the target camera.
* @returns A set of query parameters.
*/
export const getAllDependentCameras = (
cameras: Map<string, CameraConfig>,
camera?: string,
): Set<string> => {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
const dependentCameras: Set<string> = new Set();
(cameraConfig.dependencies.cameras || []).forEach((item) =>
dependentCameras.add(item),
);
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
if (camera) {
getDependentCameras(camera);
}
return cameraIDs;
};
+3 -27
View File
@@ -74,9 +74,9 @@ export const getRecordingsSummary = async (
hass,
recordingSummarySchema,
{
type: "frigate/recordings/summary",
type: 'frigate/recordings/summary',
instance_id: client_id,
camera: camera_name
camera: camera_name,
},
true,
);
@@ -102,7 +102,7 @@ export const getRecordingSegments = async (
hass,
recordingSegmentsSchema,
{
type: "frigate/recordings/get",
type: 'frigate/recordings/get',
instance_id: client_id,
camera: camera_name,
before: Math.floor(before.getTime() / 1000),
@@ -144,27 +144,3 @@ export async function retainEvent(
});
}
}
/**
* Get an id that unique identifies a particular camera (not zone, object, etc)
* within a particular Frigate instance. ID will not (necessarily) be unique
* within the card.
* @param cameraConfig The camera config.
*/
export const getUniqueFrigateCameraID = (config: CameraConfig): string => {
return [config.frigate.client_id, config.frigate.camera_name].join('/');
};
/**
* Get an id that unique identifies a source of Frigate events. ID will not
* (necessarily) be unique within the card.
* @param cameraConfig The camera config.
*/
export const getUniqueFrigateCameraEventsID = (config: CameraConfig): string => {
return [
config.frigate.client_id,
config.frigate.camera_name,
config.frigate.label,
config.frigate.zone,
].join('/');
};
+27 -33
View File
@@ -27,7 +27,7 @@ import {
MEDIA_TYPE_VIDEO,
} from '../../types.js';
import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js';
import { getAllDependentCameras, getCameraTitle } from '../camera.js';
/**
* Return the Frigate event_id given a FrigateBrowseMediaSource object.
@@ -78,7 +78,7 @@ export const getFirstTrueMediaChildIndex = (
* @param media_content_id The media content id to browse.
* @returns A FrigateBrowseMediaSource object or null on malformed.
*/
export const browseMedia = async (
const browseMedia = async (
hass: HomeAssistant,
media_content_id: string,
): Promise<FrigateBrowseMediaSource> => {
@@ -95,11 +95,11 @@ export const browseMedia = async (
* @param params The search parameters to use to search for media.
* @returns A FrigateBrowseMediaSource object or null on malformed.
*/
export const browseMediaQuery = async (
const browseMediaQuery = async (
hass: HomeAssistant,
params: BrowseMediaQueryParameters,
): Promise<FrigateBrowseMediaSource> => {
return browseMedia(
const result = await browseMedia(
hass,
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
@@ -118,6 +118,14 @@ export const browseMediaQuery = async (
params.zone,
].join('/'),
);
// If a cameraID was specified, imprint each child with that id for
// traceability.
if (params.cameraID) {
result.children?.forEach((child: FrigateBrowseMediaSource) => {
(child.frigate ??= {}).cameraID = params.cameraID;
})
}
return result;
};
/**
@@ -259,27 +267,7 @@ export const getFullDependentBrowseMediaQueryParameters = (
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null => {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
const dependentCameras: Set<string> = new Set();
(cameraConfig.dependencies.cameras || []).forEach((item) =>
dependentCameras.add(item),
);
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(camera);
const cameraIDs = getAllDependentCameras(cameras, camera);
const params: BrowseMediaQueryParameters[] = [];
for (const cameraID of cameraIDs) {
const param = getBrowseMediaQueryParameters(
@@ -435,9 +423,10 @@ export const createVideoChild = (
options?: {
thumbnail?: string;
recording?: FrigateRecording;
cameraID?: string,
},
): FrigateBrowseMediaSource => {
return {
const result: FrigateBrowseMediaSource = {
title: title,
media_class: MEDIA_CLASS_VIDEO,
media_content_type: MEDIA_TYPE_VIDEO,
@@ -445,13 +434,18 @@ export const createVideoChild = (
can_play: true,
can_expand: false,
thumbnail: options?.thumbnail ?? null,
children: null,
...(options?.recording && {
frigate: {
recording: options.recording,
},
}),
};
children: null
}
if (options?.recording || options?.cameraID) {
result.frigate = {}
if (options?.recording) {
result.frigate.recording = options.recording;
}
if (options?.cameraID) {
result.frigate.cameraID = options.cameraID;
}
}
return result;
};
/**
+510
View File
@@ -0,0 +1,510 @@
import { HomeAssistant } from 'custom-card-helpers';
import { DataSet, DataView } from 'vis-data/esnext';
import { IdType, TimelineItem } from 'vis-timeline/esnext';
import { CAMERA_BIRDSEYE } from '../const.js';
import {
BrowseMediaQueryParameters,
CameraConfig,
ExtendedHomeAssistant,
FrigateBrowseMediaSource,
FrigateCardError,
FrigateEvent,
} from '../types.js';
import { errorToConsole } from '../utils/basic.js';
import {
getRecordingSegments,
getRecordingsSummary,
RecordingSegments,
RecordingSummary,
} from './frigate.js';
import {
getBrowseMediaQueryParameters,
isTrueMedia,
multipleBrowseMediaQuery,
} from './ha/browse-media.js';
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
const RECORDING_SEGMENT_TOLERANCE = 60;
const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10;
export interface FrigateCardTimelineItem extends TimelineItem {
// DataView has issues using datasets with Date objects, so avoid them and use
// numbers instead.
start: number;
end?: number;
event?: FrigateEvent;
source?: FrigateBrowseMediaSource;
}
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
export interface RecordingSegmentsItem {
id: string;
cameraID: string;
start: number;
end: number;
}
/**
* Sort the timeline items most recent to least recent.
* @param a The first item.
* @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration).
*/
export const sortTimelineItemsYoungestToOldest = (
a: FrigateCardTimelineItem,
b: FrigateCardTimelineItem,
): number => {
if (a.start < b.start) {
return 1;
}
if (a.start > b.start) {
return -1;
}
return 0;
};
/**
* Sort the segments least recent to most recent.
* @param a The first item.
* @param b The second item.
* @returns -1, 0, 1 (standard array sort function configuration).
*/
export const sortSegmentsOldestToYoungest = (
a: RecordingSegmentsItem,
b: RecordingSegmentsItem,
): number => {
if (a.start < b.start) {
return -1;
}
if (a.start > b.start) {
return 1;
}
return 0;
};
/**
* A manager to maintain/fetch timeline events.
*/
export class TimelineDataManager {
protected _recordingSummary: Map<string, RecordingSummary | null> = new Map();
protected _recordingSegments = new DataSet<RecordingSegmentsItem>();
protected _dataset = new DataSet<FrigateCardTimelineItem>();
// The earliest date managed.
protected _dateStart: Date | null = null;
// The latest date managed.
protected _dateEnd: Date | null = null;
// The last fetch date.
protected _dateFetch: Date | null = null;
// The maximum allowable age of fetch data (will not fetch more frequently
// than this).
protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS;
protected _cameras: Map<string, CameraConfig>;
protected _mediaType: TimelineMediaType;
protected _recordings: boolean;
constructor(
cameras: Map<string, CameraConfig>,
mediaType: TimelineMediaType,
recordings: boolean,
) {
this._cameras = cameras;
this._mediaType = mediaType;
this._recordings = recordings;
}
// Get the last event fetch date.
get lastFetchDate(): Date | null {
return this._dateFetch ?? null;
}
public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null {
return this._recordingSummary.get(cameraID) ?? null;
}
public createDataView(cameraIDs: Set<string>): DataView<FrigateCardTimelineItem> {
return new DataView(this._dataset, {
filter: (item: FrigateCardTimelineItem) =>
!!item.group && cameraIDs.has(String(item.group)),
});
}
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
return new DataView(this._recordingSegments);
}
get recordingSegments(): DataSet<RecordingSegmentsItem> {
return this._recordingSegments;
}
/**
* Rewrite an item as-is. May be useful in cases where clustering may need to
* be recalculated.
* @param id The id to rewrite.
*/
public rewriteItem(id: IdType): void {
// Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included
// in a cluster.
const item = this._dataset.get(id);
if (item) {
this._dataset.updateOnly(item);
}
}
/**
* Add a FrigateBrowseMediaSource object to the managed timeline.
* @param cameraID The id the camera this object is from.
* @param target The FrigateBrowseMediaSource to add.
*/
protected _addMediaSource(target: FrigateBrowseMediaSource): void {
const items: FrigateCardTimelineItem[] = [];
target.children?.forEach((child) => {
const event = child.frigate?.event;
const cameraID = child.frigate?.cameraID;
if (
cameraID &&
event &&
isTrueMedia(child) &&
['video', 'image'].includes(child.media_content_type)
) {
let item = this._dataset.get(event.id);
//const st = fromUnixTime();
if (!item) {
item = {
id: event.id,
group: cameraID,
content: '',
start: event.start_time * 1000,
event: event,
};
}
if (
(child.media_content_type === 'video' &&
['all', 'clips'].includes(this._mediaType)) ||
(!item.source &&
child.media_content_type === 'image' &&
['all', 'snapshots'].includes(this._mediaType))
) {
item.source = child;
}
if (event.end_time) {
item['end'] = event.end_time * 1000;
item['type'] = 'range';
} else {
item['type'] = 'point';
}
items.push(item);
}
});
this._dataset.update(items);
}
/**
* Determine if the timeline has coverage for a given range of dates.
* @param start The start of the date range.
* @param end An optional end of the date range.
* @returns
*/
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
// Never fetched: no coverage.
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
return false;
}
// If the most recent fetch is older than maxAgeSeconds: no coverage.
if (
this._maxAgeSeconds &&
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
) {
return false;
}
// If the most requested data is earlier than the earliest stored: no
// coverage.
if (start < this._dateStart) {
return false;
}
// If there's no end time specified: there IS coverage.
if (!end) {
return true;
}
// If the requested end time is older than the oldest requested: there IS
// coverage.
if (end.getTime() < this._dateEnd.getTime()) {
return true;
}
// If there's no maxAgeSeconds specified: no coverage.
if (!this._maxAgeSeconds) {
return false;
}
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
return false;
}
// End time is within `_maxAgeSeconds` of the latest data: there IS
// coverage.
return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
}
/**
* Fetch events if no coverage in given range.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @param start Fetch events that start later than this date.
* @param end Fetch events that start earlier than this date.
* @returns `true` if events were fetched, `false` otherwise.
*/
public async fetchIfNecessary(
element: HTMLElement,
hass: ExtendedHomeAssistant,
start: Date,
end: Date,
): Promise<boolean> {
// Cannot fetch the future, always clip the end date to now so as to avoid
// checking for coverage that could not possibly exist yet.
const now = new Date();
end = end > now ? now : end;
if (this.hasCoverage(now, start, end)) {
return false;
}
const oldStart = this._dateStart;
const oldEnd = this._dateEnd;
let segmentStart: Date | null = null;
let segmentEnd: Date | null = null;
if (!this._dateStart || start < this._dateStart) {
this._dateStart = start;
segmentStart = start;
} else {
segmentStart = oldEnd ?? end;
}
if (!this._dateEnd || end > this._dateEnd) {
this._dateEnd = end;
segmentEnd = end;
} else {
segmentEnd = oldStart ?? start;
}
this._dateFetch = new Date();
await Promise.all([
// Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time
// (e.g. a long-running event that ends).
this._fetchEvents(element, hass, this._dateStart, this._dateEnd),
...(this._recordings ? [this._fetchRecordingSummary(hass)] : []),
...(this._recordings && segmentEnd > segmentStart
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
: []),
]);
return true;
}
/**
* Fetch recording segments for cameras.
* @param hass The HomeAssistant object.
* @param start Fetch segments that start later than this date.
* @param end Fetch segments that start earlier than this date.
*/
protected async _fetchRecordingSegments(
hass: ExtendedHomeAssistant,
start: Date,
end: Date,
): Promise<void> {
const results: Map<string, RecordingSegments> = new Map();
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
if (!config || !config.frigate.camera_name || !hass) {
return;
}
try {
const cameraResults = await getRecordingSegments(
hass,
config.frigate.client_id,
config.frigate.camera_name,
end,
start,
);
results.set(camera, cameraResults);
} catch (e) {
errorToConsole(e as Error);
}
};
await Promise.all(
Array.from(this._cameras.keys()).map((camera) =>
fetch(camera, this._cameras.get(camera)),
),
);
const items: RecordingSegmentsItem[] = [];
results.forEach((segments, cameraID) => {
segments.forEach((segment) => {
items.push({
id: `${cameraID}/${segment.id}`,
cameraID: cameraID,
start: segment.start_time * 1000,
end: segment.end_time * 1000,
});
});
});
this._recordingSegments.update(items);
this._compressRecordingSegmentsOntoTimeline();
}
/**
* Compress recording segments into recordings shown on the timeline
* background.
*/
protected _compressRecordingSegmentsOntoTimeline(): void {
if (!this._recordingSegments.length) {
return;
}
// Delete all the existing background.
this._dataset.remove(
this._dataset.get({
filter: (item) => item.type === 'background',
}),
);
const convertToRecording = (
segment: RecordingSegmentsItem,
): FrigateCardTimelineItem => {
return {
id: `recording-${segment.cameraID}-${segment.id}`,
group: segment.cameraID,
start: segment.start,
end: segment.end,
content: ' ',
type: 'background',
};
};
// Iterate through the segments least to most recent, effectively joining
// segments together that are within a certain tolerance to create large
// blocks that are visualized on the timeline as recordings.
const recordings: FrigateCardTimelineItem[] = [];
this._cameras.forEach((_, cameraID) => {
const segments = this._recordingSegments.get({
filter: (item) => item.cameraID === cameraID,
order: sortSegmentsOldestToYoungest,
});
let current: RecordingSegmentsItem | null = null;
for (let i = 0; i < segments.length; ++i) {
const item = segments[i];
if (!current) {
current = { ...item };
} else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) {
current.end = item.end;
} else {
recordings.push(convertToRecording(current));
current = null;
}
if (i === segments.length - 1 && current) {
recordings.push(convertToRecording(current));
}
}
});
this._dataset.update(recordings);
}
/**
* Fetch recording summary.
* @param hass The HomeAssistant object.
*/
protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise<void> {
const storeRecordingSummary = async (
cameraID: string,
cameraConfig: CameraConfig,
): Promise<void> => {
if (!cameraConfig.frigate.camera_name) {
return;
}
try {
this._recordingSummary.set(
cameraID,
await getRecordingsSummary(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
),
);
} catch (e) {
// Recording failure should not disrupt the rest of the timeline
// experience.
errorToConsole(e as Error);
}
};
await Promise.all(
Array.from(this._cameras.keys()).map(async (cameraID) => {
const cameraConfig = this._cameras.get(cameraID);
if (cameraConfig) {
await storeRecordingSummary(cameraID, cameraConfig);
}
}),
);
}
/**
* Fetch events for the timeline.
* @param element The element to send error events from.
* @param hass The HomeAssistant object.
* @param start Fetch events that start later than this date.
* @param end Fetch events that start earlier than this date.
*/
protected async _fetchEvents(
element: HTMLElement,
hass: HomeAssistant,
start: Date,
end: Date,
): Promise<void> {
const params: BrowseMediaQueryParameters[] = [];
this._cameras.forEach((cameraConfig, cameraID) => {
(this._mediaType === 'all' ? ['clips', 'snapshots'] : [this._mediaType]).forEach(
(mediaType) => {
if (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE) {
const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, {
before: end.getTime() / 1000,
after: start.getTime() / 1000,
unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots',
});
if (param) {
params.push(param);
}
}
},
);
});
if (!params.length) {
return;
}
let results: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>;
try {
results = await multipleBrowseMediaQuery(hass, params);
} catch (e) {
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
for (const result of results.values()) {
this._addMediaSource(result);
}
}
}