Initial draft of recordings support.
This commit is contained in:
@@ -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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user