feat: Add experimental reolink media support (#1694)

* feat: Add experimental rich reolink support.

* Formatting fix
This commit is contained in:
Dermot Duffy
2024-11-25 20:19:12 -08:00
committed by GitHub
parent e898b73d63
commit dada65e008
63 changed files with 3402 additions and 483 deletions
+3
View File
@@ -1,5 +1,6 @@
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
export const getCustomIconURL = (icon?: string): string | null => {
switch (icon) {
@@ -7,6 +8,8 @@ export const getCustomIconURL = (icon?: string): string | null => {
return frigateSVG;
case 'motioneye':
return motioneyeSVG;
case 'reolink':
return reolinkSVG;
default:
return null;
}
+49 -5
View File
@@ -2,9 +2,12 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import pkg from '../../package.json';
import { RawFrigateCardConfig } from '../config/types';
import { getLanguage } from '../localize/localize';
import { getIntegrationManifest } from './ha/integration';
import { IntegrationManifest } from './ha/integration/types';
import { DeviceRegistryManager } from './ha/registry/device';
import { HASS_WEB_PROXY_DOMAIN } from './ha/web-proxy';
type FrigateVersions = Record<string, string>;
type FrigateDevices = Record<string, string>;
interface GitDiagnostics {
build_version?: string;
@@ -12,6 +15,11 @@ interface GitDiagnostics {
commit_date?: string;
}
interface IntegrationDiagnostics {
detected: boolean;
version?: string;
}
export const getReleaseVersion = (): string => {
const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__';
@@ -36,11 +44,39 @@ export interface Diagnostics {
timezone: string;
git: GitDiagnostics;
frigate_versions?: FrigateVersions;
ha_version?: string;
config?: RawFrigateCardConfig;
integrations: {
frigate: IntegrationDiagnostics & {
devices?: FrigateDevices;
};
hass_web_proxy: IntegrationDiagnostics;
reolink: IntegrationDiagnostics;
motioneye: IntegrationDiagnostics;
};
}
export const getIntegrationDiagnostics = async (
integration: string,
hass?: HomeAssistant,
): Promise<IntegrationDiagnostics> => {
let manifest: IntegrationManifest | null = null;
if (hass) {
try {
manifest = await getIntegrationManifest(hass, integration);
} catch (e) {
// Silently ignore integrations not being found.
}
}
return {
detected: !!manifest,
...(manifest?.version && { version: manifest.version }),
};
};
export const getDiagnostics = async (
hass?: HomeAssistant,
deviceRegistryManager?: DeviceRegistryManager,
@@ -69,9 +105,6 @@ export const getDiagnostics = async (
card_version: getReleaseVersion(),
browser: navigator.userAgent,
date: new Date(),
...(frigateVersionMap.size && {
frigate_versions: Object.fromEntries(frigateVersionMap),
}),
lang: getLanguage(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
git: {
@@ -80,6 +113,17 @@ export const getDiagnostics = async (
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
},
...(hass && { ha_version: hass.config.version }),
integrations: {
reolink: await getIntegrationDiagnostics('reolink', hass),
frigate: {
...(await getIntegrationDiagnostics('frigate', hass)),
...(frigateVersionMap.size && {
devices: Object.fromEntries(frigateVersionMap),
}),
},
hass_web_proxy: await getIntegrationDiagnostics(HASS_WEB_PROXY_DOMAIN, hass),
motioneye: await getIntegrationDiagnostics('motioneye', hass),
},
...(rawConfig && { config: rawConfig }),
};
};
@@ -1,12 +1,15 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { add } from 'date-fns';
import { homeAssistantWSRequest } from '..';
import chunk from 'lodash-es/chunk';
import orderBy from 'lodash-es/orderBy';
import { BrowseMediaMetadata } from '../../../camera-manager/browse-media/types';
import { MemoryRequestCache } from '../../../camera-manager/cache';
import { allPromises } from '../../basic';
import { homeAssistantWSRequest } from '../ws-request';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
browseMediaSchema,
BROWSE_MEDIA_CACHE_SECONDS,
RichBrowseMedia,
} from './types';
@@ -19,10 +22,19 @@ type RichMetadataGenerator<M> = (
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
export const sortMediaByStartDate = (
media: RichBrowseMedia<BrowseMediaMetadata>[],
): RichBrowseMedia<BrowseMediaMetadata>[] => {
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
};
export interface BrowseMediaStep<M> {
// The targets to start the media walk from.
targets: BrowseMediaTarget<M>[];
// How many children to process concurrently. Default is infinite.
concurrency?: number;
// All children of the target have the metadata generator applied to them
// first.
metadataGenerator?: RichMetadataGenerator<M>;
@@ -31,6 +43,12 @@ export interface BrowseMediaStep<M> {
// output.
matcher: RichBrowseMediaPredicate<M>;
// Children (once past the matcher) will be sorted before the next step.
sorter?: (media: RichBrowseMedia<M>[]) => RichBrowseMedia<M>[];
// Whether to exit the walk early with the given output.
earlyExit?: (media: RichBrowseMedia<M>[]) => boolean;
// advance will be called to generate a next step (or null if the child should
// just be included straight through to the output with no further steps).
advance?: BrowseMediaStepAdvancer<M>;
@@ -39,23 +57,18 @@ export interface BrowseMediaStep<M> {
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
export class BrowseMediaManager<M> {
protected _cache: BrowseMediaCache<M>;
constructor(cache: BrowseMediaCache<M>) {
this._cache = cache;
}
// Walk down a browse media tree according to instructions included in `steps`.
public async walkBrowseMedias(
hass: HomeAssistant,
steps: BrowseMediaStep<M>[] | null,
options?: {
useCache?: boolean;
cache?: BrowseMediaCache<M>;
},
): Promise<RichBrowseMedia<M>[]> {
if (!steps || !steps.length) {
return [];
}
return (
await allPromises(
steps,
@@ -68,60 +81,57 @@ export class BrowseMediaManager<M> {
hass: HomeAssistant,
step: BrowseMediaStep<M>,
options?: {
useCache?: boolean;
cache?: BrowseMediaCache<M>;
},
): Promise<RichBrowseMedia<M>[]> {
const media = await allPromises(
step.targets,
async (target) =>
await this._browseMedia(hass, target, {
useCache: options?.useCache,
metadataGenerator: step.metadataGenerator,
}),
);
let output: RichBrowseMedia<M>[] = [];
const newTargets: RichBrowseMedia<M>[] = [];
for (const parent of media) {
for (const child of parent.children ?? []) {
if (step.matcher(child)) {
newTargets.push(child);
for (const targetChunk of chunk(step.targets, step.concurrency ?? Infinity)) {
const mediaChunk = await allPromises(
targetChunk,
async (target) =>
await this._browseMedia(hass, target, {
cache: options?.cache,
matcher: step.matcher,
metadataGenerator: step.metadataGenerator,
}),
);
for (const parent of mediaChunk) {
for (const child of parent.children ?? []) {
if (step.matcher(child)) {
output.push(child);
}
}
}
}
const nextSteps = step.advance ? step.advance(newTargets) : null;
if (!nextSteps || !nextSteps.length) {
return newTargets;
}
if (step.sorter) {
output = step.sorter(output);
}
const targetsIncludedInNextSteps = new Set(
nextSteps.map((nextStep) => nextStep.targets).flat(),
);
const finished: RichBrowseMedia<M>[] = [];
// Any new target that doesn't have a proposed 'next step' is assumed to be
// ready to return.
for (const target of newTargets) {
if (!targetsIncludedInNextSteps.has(target)) {
finished.push(target);
if (step.earlyExit && step.earlyExit(output)) {
break;
}
}
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
return finished.concat(downstream);
const nextSteps = step.advance ? step.advance(output) : null;
if (!nextSteps?.length) {
return output;
}
return await this.walkBrowseMedias(hass, nextSteps, options);
}
protected async _browseMedia(
hass: HomeAssistant,
target: string | RichBrowseMedia<M>,
options?: {
useCache?: boolean;
cache?: BrowseMediaCache<M>;
matcher?: RichBrowseMediaPredicate<M>;
metadataGenerator?: RichMetadataGenerator<M>;
},
): Promise<RichBrowseMedia<M>> {
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
const cachedResult =
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
const cachedResult = options?.cache ? options.cache.get(mediaContentID) : null;
if (cachedResult) {
return cachedResult;
}
@@ -130,11 +140,11 @@ export class BrowseMediaManager<M> {
type: 'media_source/browse_media',
media_content_id: mediaContentID,
};
const browseMedia = (await homeAssistantWSRequest(
const browseMedia = await homeAssistantWSRequest<RichBrowseMedia<M>>(
hass,
browseMediaSchema,
request,
)) as RichBrowseMedia<M>;
);
if (options?.metadataGenerator) {
for (const child of browseMedia.children ?? []) {
@@ -146,8 +156,8 @@ export class BrowseMediaManager<M> {
}
}
if (options?.useCache ?? true) {
this._cache.set(
if (options?.cache) {
options.cache.set(
mediaContentID,
browseMedia,
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
+13 -53
View File
@@ -3,73 +3,25 @@ import {
computeStateDomain,
HomeAssistant,
} from '@dermotduffy/custom-card-helpers';
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
import { HassEntity } from 'home-assistant-js-websocket';
import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js';
import {
CardHelpers,
ExtendedHomeAssistant,
FrigateCardError,
LovelaceCardWithEditor,
SignedPath,
signedPathSchema,
StateParameters,
} from '../../types.js';
import { domainIcon } from '../icons/domain-icon.js';
import { getParseErrorKeys } from '../zod.js';
/**
* Make a HomeAssistant websocket 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 homeAssistantWSRequest<T>(
hass: HomeAssistant,
schema: ZodSchema<T>,
request: MessageBase,
passthrough = false,
): Promise<T> {
let response;
try {
response = await hass.callWS<T>(request);
} catch (e) {
if (!(e instanceof Error)) {
throw new FrigateCardError(localize('error.failed_response'), {
request: request,
response: e,
});
}
throw e;
}
if (!response) {
throw new FrigateCardError(localize('error.empty_response'), {
request: request,
});
}
// Some endpoints on the integration pass through JSON directly from Frigate
// These end up wrapped in a string and must be unwrapped first
const parseResult = passthrough
? schema.safeParse(JSON.parse(response))
: schema.safeParse(response);
if (!parseResult.success) {
throw new FrigateCardError(localize('error.invalid_response'), {
request: request,
response: response,
invalid_keys: getParseErrorKeys<T>(parseResult.error),
});
}
return parseResult.data;
}
import { homeAssistantWSRequest } from './ws-request.js';
/**
* Request that HA sign a path. May throw.
* @param hass The HomeAssistant object used to request the signature.
* @param path The path to sign.
* @param expires An optional number of seconds to sign the path for.
* @param expires An optional number of seconds to sign the path for (by default
* HA will sign for 30 seconds).
* @returns The signed URL, or null if the response was malformed.
*/
export async function homeAssistantSignPath(
@@ -379,17 +331,25 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
);
};
export function isHARelativeURL(url?: string): boolean {
return !!url?.startsWith('/');
}
/**
* Ensure URLs use the correct HA URL (relevant for Chromecast where the default
* location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
export function canonicalizeHAURL(
hass: ExtendedHomeAssistant,
url?: string,
): string | null;
export function canonicalizeHAURL(
hass: ExtendedHomeAssistant,
url?: string,
): string | null {
if (hass && url && url.startsWith('/')) {
if (isHARelativeURL(url)) {
return hass.hassUrl(url);
}
return url ?? null;
+13
View File
@@ -0,0 +1,13 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { homeAssistantWSRequest } from '../ws-request';
import { IntegrationManifest, integrationManifestSchema } from './types';
export const getIntegrationManifest = async (
hass: HomeAssistant,
integration: string,
): Promise<IntegrationManifest> => {
return await homeAssistantWSRequest(hass, integrationManifestSchema, {
type: 'manifest/get',
integration: integration,
});
};
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const integrationManifestSchema = z
.object({
domain: z.string(),
version: z.string().optional(),
})
.passthrough();
export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
+1 -1
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { homeAssistantWSRequest } from '../..';
import { homeAssistantWSRequest } from '../../ws-request';
import { errorToConsole } from '../../../basic';
import { RegistryCache } from '../cache';
import { Device, DeviceList, deviceListSchema } from './types';
+1 -1
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { homeAssistantWSRequest } from '../..';
import { homeAssistantWSRequest } from '../../ws-request';
import { errorToConsole } from '../../../basic';
import { RegistryCache } from '../cache';
import { Entity, EntityList, entityListSchema, entitySchema } from './types.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import QuickLRU from 'quick-lru';
import { homeAssistantWSRequest } from '.';
import { homeAssistantWSRequest } from './ws-request';
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
import { errorToConsole } from '../basic';
+54
View File
@@ -0,0 +1,54 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraProxyConfig } from '../../camera-manager/types';
import { ExtendedHomeAssistant } from '../../types';
export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy';
const hasWebProxyAvailable = (hass: HomeAssistant): boolean => {
return hass.config.components.includes(HASS_WEB_PROXY_DOMAIN);
};
export const getWebProxiedURL = (url: string, v?: number): string => {
return `/api/${HASS_WEB_PROXY_DOMAIN}/v${v ?? 0}/?url=${encodeURIComponent(url)}`;
};
export const shouldUseWebProxy = (
hass: HomeAssistant,
proxyConfig: CameraProxyConfig,
context: 'media' = 'media',
): boolean => {
return hasWebProxyAvailable(hass) && !!proxyConfig[context];
};
/**
* Request that HA sign a path. May throw.
* @param hass The HomeAssistant object used to request the signature.
* @param path The path to sign.
* @param expires An optional number of seconds to sign the path for (by default
* HA will sign for 30 seconds).
* @returns The signed URL, or null if the response was malformed.
*/
export async function addDynamicProxyURL(
hass: ExtendedHomeAssistant,
url_pattern: string,
options?: {
urlID?: string;
sslVerification?: boolean;
sslCiphers?: string;
openLimit?: number;
ttl?: number;
allowUnauthenticated?: boolean;
},
): Promise<void> {
await hass.callService(HASS_WEB_PROXY_DOMAIN, 'create_proxied_url', {
url_pattern: url_pattern,
...(options && {
url_id: options.urlID,
ssl_verification: options.sslVerification,
ssl_ciphers: options.sslCiphers,
open_limit: options.openLimit,
ttl: options.ttl,
allow_unauthenticated: options.allowUnauthenticated,
}),
});
}
+53
View File
@@ -0,0 +1,53 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { MessageBase } from 'home-assistant-js-websocket';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize';
import { FrigateCardError } from '../../types';
import { getParseErrorKeys } from '../zod';
/**
* Make a HomeAssistant websocket 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 homeAssistantWSRequest<T>(
hass: HomeAssistant,
schema: ZodSchema<T>,
request: MessageBase,
passthrough = false,
): Promise<T> {
let response;
try {
response = await hass.callWS<T>(request);
} catch (e) {
if (!(e instanceof Error)) {
throw new FrigateCardError(localize('error.failed_response'), {
request: request,
response: e,
});
}
throw e;
}
if (!response) {
throw new FrigateCardError(localize('error.empty_response'), {
request: request,
});
}
// Some endpoints on the integration pass through JSON directly from Frigate
// These end up wrapped in a string and must be unwrapped first
const parseResult = passthrough
? schema.safeParse(JSON.parse(response))
: schema.safeParse(response);
if (!parseResult.success) {
throw new FrigateCardError(localize('error.invalid_response'), {
request: request,
response: response,
invalid_keys: getParseErrorKeys<T>(parseResult.error),
});
}
return parseResult.data;
}