Initial draft of recordings support.

This commit is contained in:
Dermot Duffy
2022-06-04 11:04:38 -07:00
parent 2e80bdad0b
commit 7ebada9aa5
26 changed files with 1016 additions and 181 deletions
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { ExtendedHomeAssistant } from '../types';
import { homeAssistantHTTPRequest } from './ha';
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),
});
const recordingSummarySchema = z
.object({
day: z.preprocess((arg) => {
// Must provide the hour:minute:second on parsing or Javascript will
// assume UTC midnight.
return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg;
}, 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(),
});
const recordingSegmentsSchema = recordingSegmentSchema.array();
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
/**
* Get the recordings summary.
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @returns A RecordingSummary object.
*/
export const getRecordingsSummary = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
): Promise<RecordingSummary> => {
return await homeAssistantHTTPRequest(
hass,
recordingSummarySchema,
`/api/frigate/${client_id}/${camera_name}/recordings/summary`,
);
};
/**
* Get the recording segments..
* @param hass The Home Assistant object.
* @param client_id The Frigate client_id.
* @param camera_name The Frigate camera name.
* @param before The segment low watermark.
* @param after The segment high watermark.
* @returns A RecordingSegments object.
*/
export const getRecordingSegments = async (
hass: ExtendedHomeAssistant,
client_id: string,
camera_name: string,
before: Date,
after: Date,
): Promise<RecordingSegments> => {
return await homeAssistantHTTPRequest(
hass,
recordingSegmentsSchema,
`/api/frigate/${client_id}/${camera_name}/recordings`,
new URLSearchParams({
before: String(before.getTime() / 1000),
after: String(after.getTime() / 1000),
}),
);
};
+66 -6
View File
@@ -8,15 +8,23 @@ import {
import { homeAssistantWSRequest } from '.';
import {
dispatchErrorMessageEvent,
dispatchFrigateCardErrorEvent,
dispatchMessageEvent
} from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import {
BrowseMediaQueryParameters,
BrowseRecordingQueryParameters,
CameraConfig,
FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST
frigateBrowseMediaSourceSchema,
FrigateCardError,
FrigateEvent,
FrigateRecording,
MEDIA_CLASS_PLAYLIST,
MEDIA_CLASS_VIDEO,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_VIDEO
} from '../../types.js';
import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js';
@@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js';
* @returns The `event_id` or `null` if not successfully parsed.
*/
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
return media.frigate?.event.id ?? null;
return media.frigate?.event?.id ?? null;
};
/**
@@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
return media.frigate?.event.start_time ?? null;
return media.frigate?.event?.start_time ?? null;
};
/**
@@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async (
try {
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
const childIndex = getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
@@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async (
try {
parent = await browseMedia(hass, child.media_content_id);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
}
view
@@ -409,6 +417,38 @@ export const createEventParentForChildren = (
};
};
/**
* Given a media video child with a given media_content_id.
* @param title The title to use for the child.
* @param media_con
* @param children The children media items.
* @returns A single parent containing the children.
*/
export const createVideoChild = (
title: string,
mediaContentID: string,
options?: {
thumbnail?: string;
recording?: FrigateRecording;
},
): FrigateBrowseMediaSource => {
return {
title: title,
media_class: MEDIA_CLASS_VIDEO,
media_content_type: MEDIA_TYPE_VIDEO,
media_content_id: mediaContentID,
can_play: true,
can_expand: false,
thumbnail: options?.thumbnail ?? null,
children: null,
...(options?.recording && {
frigate: {
recording: options.recording,
},
}),
};
};
/**
* Convenience function to convert a timestamp to hours, minutes and seconds
* string. Heavily inspired by, and returning the same format as, the Frigate
@@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string {
duration += `${seconds}s`;
return duration;
}
/**
* Generate a recording identifier.
* @param hass The HomeAssistant object.
* @param params The recording parameters to use in the identifer.
* @returns A recording identifier.
*/
export const generateRecordingIdentifier = (
params: BrowseRecordingQueryParameters,
): string => {
return [
'media-source://frigate',
params.clientId,
'recordings',
`${params.year}-${String(params.month).padStart(2, '0')}`,
String(params.day).padStart(2, '0'),
String(params.hour).padStart(2, '0'),
params.cameraName,
].join('/');
};
+81 -11
View File
@@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js';
import {
CardHelpers, ExtendedHomeAssistant,
CardHelpers,
ExtendedHomeAssistant,
FrigateCardError,
SignedPath,
signedPathSchema,
StateParameters
@@ -36,12 +38,16 @@ export async function homeAssistantWSRequest<T>(
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = getParseErrorKeys<T>(parseResult.error);
const error_message =
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
localize('error.invalid_keys') +
`: '${keys}'`;
console.warn(error_message);
throw new Error(error_message);
const error_message = localize('error.invalid_response');
console.warn(
`${error_message}: ${JSON.stringify(request)}. ${localize(
'error.invalid_keys',
)}: ${keys}`,
);
throw new FrigateCardError(error_message, {
request: request,
invalid_keys: keys,
});
}
return parseResult.data;
}
@@ -74,6 +80,70 @@ export async function homeAssistantSignPath(
return hass.hassUrl(response.path);
}
/**
* Make a HomeAssistant HTTP request. May throw.
* @param hass The HomeAssistant object to send the request with.
* @param schema The expected Zod schema of the response.
* @param request The request to make.
* @returns The parsed valid response or null on malformed.
*/
export async function homeAssistantHTTPRequest<T>(
hass: ExtendedHomeAssistant,
schema: ZodSchema<T>,
url: string,
params?: URLSearchParams,
): Promise<T> {
let signResponse: string | null | undefined;
try {
signResponse = await homeAssistantSignPath(hass, url);
} catch (e) {
console.warn(e);
}
if (!signResponse) {
throw new FrigateCardError(localize('error.failed_sign'), {
url: url.toString(),
});
}
const signedURL = new URL(signResponse);
if (params) {
for (const [key, value] of params.entries()) {
signedURL.searchParams.append(key, value);
}
}
const response = await fetch(signedURL.toString());
if (!response.ok) {
throw new FrigateCardError(localize('error.failed_response'), {
url: signedURL.toString(),
status: response.status,
statusText: response.statusText,
});
}
let raw_json;
try {
raw_json = await response.json();
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.undecodable_response'), {
url: signedURL.toString(),
});
}
try {
return schema.parse(raw_json);
} catch (e) {
console.warn(e);
throw new FrigateCardError(localize('error.invalid_response'), {
url: signedURL.toString(),
response: raw_json,
});
}
}
interface HassStateDifference {
entity: string;
oldState?: HassEntity;
@@ -308,9 +378,9 @@ export const isTriggeredState = (state?: HassEntity): boolean => {
/**
* Get entities from the HASS object.
* @param hass
* @param domain
* @returns
* @param hass
* @param domain
* @returns A list of entities ids.
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
@@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin
);
entities.sort();
return entities;
}
};