feat: Implement basic general folder support (#2051)

- Related: #1748
This commit is contained in:
Dermot Duffy
2025-05-21 19:59:21 -07:00
committed by GitHub
parent 2eb0d9e35e
commit c6a4c8aea2
350 changed files with 12837 additions and 4509 deletions
+14 -1
View File
@@ -2,6 +2,7 @@ import { CardActionsAPI } from '../card-controller/types.js';
import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js';
import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js';
import { FolderActionConfig } from '../config/schema/actions/custom/folder.js';
import {
AdvancedCameraCardGeneralAction,
GeneralActionConfig,
@@ -46,7 +47,7 @@ export function createGeneralAction(
}
export function createViewAction(
action: AdvancedCameraCardUserSpecifiedView,
action: Exclude<AdvancedCameraCardUserSpecifiedView, 'folder'>,
options?: {
cardID?: string;
},
@@ -73,6 +74,18 @@ export function createCameraAction(
};
}
export function createFolderAction(options?: {
cardID?: string;
folderID?: string;
}): FolderActionConfig {
return {
action: 'fire-dom-event',
advanced_camera_card_action: 'folder',
...(options?.folderID && { folder: options.folderID }),
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createMediaPlayerAction(
mediaPlayer: string,
mediaPlayerAction: 'play' | 'stop',
+1 -9
View File
@@ -5,10 +5,7 @@ import {
format,
} from 'date-fns';
import { StyleInfo } from 'lit/directives/style-map';
import isEqualWith from 'lodash-es/isEqualWith';
import mergeWith from 'lodash-es/mergeWith';
import round from 'lodash-es/round';
import uniq from 'lodash-es/uniq';
import { isEqualWith, mergeWith, round, uniq } from 'lodash-es';
import { AdvancedCameraCardError } from '../types';
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
@@ -188,11 +185,6 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
return true;
};
// Usage of this function needs to be justified with a comment.
export const sleep = async (seconds: number) => {
await new Promise((r) => setTimeout(r, seconds * 1000));
};
export const isValidDate = (date: Date): boolean => {
return !isNaN(date.getTime());
};
+5 -4
View File
@@ -1,11 +1,11 @@
import pkg from '../../package.json';
import { RawAdvancedCameraCardConfig } from '../config/types';
import { getIntegrationManifest } from '../ha/integration';
import { IntegrationManifest } from '../ha/integration/types';
import { DeviceRegistryManager } from '../ha/registry/device';
import { HomeAssistant } from '../ha/types';
import { HASS_WEB_PROXY_DOMAIN } from '../ha/web-proxy';
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 FrigateDevices = Record<string, string>;
@@ -64,6 +64,7 @@ const getIntegrationDiagnostics = async (
if (hass) {
try {
manifest = await getIntegrationManifest(hass, integration);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// Silently ignore integrations not being found.
}
-52
View File
@@ -1,12 +1,3 @@
import { format } from 'date-fns';
import { CameraManager } from '../camera-manager/manager.js';
import { HomeAssistant } from '../ha/types.js';
import { localize } from '../localize/localize.js';
import { AdvancedCameraCardError } from '../types.js';
import { ViewMedia } from '../view/media.js';
import { errorToConsole } from './basic.js';
import { homeAssistantSignPath } from './ha/index.js';
export const downloadURL = (url: string, filename = 'download'): void => {
// The download attribute only works on the same origin.
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
@@ -26,46 +17,3 @@ export const downloadURL = (url: string, filename = 'download'): void => {
link.click();
link.remove();
};
export const downloadMedia = async (
hass: HomeAssistant,
cameraManager: CameraManager,
media: ViewMedia,
): Promise<void> => {
const download = await cameraManager.getMediaDownloadPath(media);
if (!download) {
throw new AdvancedCameraCardError(localize('error.download_no_media'));
}
let finalURL = download.endpoint;
if (download.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, download.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.download_sign_failed'));
}
finalURL = response;
}
downloadURL(finalURL, generateDownloadFilename(media));
};
const generateDownloadFilename = (media: ViewMedia): string => {
const toFilename = (input: string): string => {
return input.toLowerCase().replaceAll(/(\.|\s)+/g, '-');
};
const id = media.getID();
const startTime = media.getStartTime();
return (
toFilename(media.getCameraID()) +
(id ? `_${toFilename(id)}` : '') +
(startTime ? `_${format(startTime, `yyyy-MM-dd-HH-mm-ss`)}` : '')
);
};
+1 -1
View File
@@ -1,7 +1,7 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import isEqual from 'lodash-es/isEqual';
import { isEqual } from 'lodash-es';
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
import { getChildrenFromElement } from '../basic.js';
import { fireAdvancedCameraCardEvent } from '../fire-advanced-camera-card-event';
@@ -1,7 +1,7 @@
import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import debounce from 'lodash-es/debounce';
import { debounce } from 'lodash-es';
import { EmblaReInitController } from '../../reinit-controller';
declare module 'embla-carousel/components/Plugins' {
+1 -1
View File
@@ -1,5 +1,5 @@
import { EmblaCarouselType } from 'embla-carousel';
import debounce from 'lodash-es/debounce';
import { debounce } from 'lodash-es';
/**
* This class takes care of "safe re-initializing": Only re-initializing the
+3 -3
View File
@@ -1,11 +1,11 @@
import { CameraEndpoint } from '../camera-manager/types';
import { homeAssistantSignPath } from '../ha/sign-path';
import { HomeAssistant } from '../ha/types';
import { Endpoint } from '../types';
import { errorToConsole } from './basic';
import { homeAssistantSignPath } from './ha';
export const convertEndpointAddressToSignedWebsocket = async (
hass: HomeAssistant,
endpoint: CameraEndpoint,
endpoint: Endpoint,
expires?: number,
): Promise<string | null> => {
if (!endpoint.sign) {
@@ -1,4 +1,5 @@
import { ViewMedia } from '../view/media';
import { ViewItem } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier';
/**
* Find the longest matching media object that contains a given targetTime.
@@ -7,8 +8,8 @@ import { ViewMedia } from '../view/media';
* @param targetTime The target time used to find the relevant child.
* @returns The childindex or null if no matching child is found.
*/
export const findBestMediaIndex = (
mediaArray: ViewMedia[],
export const findBestMediaTimeIndex = (
mediaArray: ViewItem[],
targetTime: Date,
favorCameraID?: string,
): number | null => {
@@ -16,11 +17,14 @@ export const findBestMediaIndex = (
| {
index: number;
duration: number;
cameraID: string;
cameraID: string | null;
}
| undefined;
for (const [i, media] of mediaArray.entries()) {
if (!ViewItemClassifier.isMedia(media)) {
continue;
}
const start = media.getStartTime();
const end = media.getUsableEndTime();
@@ -1,166 +0,0 @@
import { add } from 'date-fns';
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 { HomeAssistant } from '../../../ha/types';
import { allPromises } from '../../basic';
import { homeAssistantWSRequest } from '../ws-request';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
browseMediaSchema,
RichBrowseMedia,
} from './types';
type BrowseMediaCache<M> = MemoryRequestCache<string, RichBrowseMedia<M>>;
type RichMetadataGenerator<M> = (
media: BrowseMedia,
parent?: RichBrowseMedia<M>,
) => M | null;
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>;
// If those children pass this matcher, then they will be included in the
// 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>;
}
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
export class BrowseMediaManager {
// Walk down a browse media tree according to instructions included in `steps`.
public async walkBrowseMedias<M>(
hass: HomeAssistant,
steps: BrowseMediaStep<M>[] | null,
options?: {
cache?: BrowseMediaCache<M>;
},
): Promise<RichBrowseMedia<M>[]> {
if (!steps || !steps.length) {
return [];
}
return (
await allPromises(
steps,
async (step) => await this._walkBrowseMedia(hass, step, options),
)
).flat();
}
protected async _walkBrowseMedia<M>(
hass: HomeAssistant,
step: BrowseMediaStep<M>,
options?: {
cache?: BrowseMediaCache<M>;
},
): Promise<RichBrowseMedia<M>[]> {
let output: RichBrowseMedia<M>[] = [];
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,
metadataGenerator: step.metadataGenerator,
}),
);
for (const parent of mediaChunk) {
for (const child of parent.children ?? []) {
if (step.matcher(child)) {
output.push(child);
}
}
}
if (step.sorter) {
output = step.sorter(output);
}
if (step.earlyExit && step.earlyExit(output)) {
break;
}
}
const nextSteps = step.advance ? step.advance(output) : null;
if (!nextSteps?.length) {
return output;
}
return await this.walkBrowseMedias(hass, nextSteps, options);
}
protected async _browseMedia<M>(
hass: HomeAssistant,
target: string | RichBrowseMedia<M>,
options?: {
cache?: BrowseMediaCache<M>;
metadataGenerator?: RichMetadataGenerator<M>;
},
): Promise<RichBrowseMedia<M>> {
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
const cachedResult = options?.cache ? options.cache.get(mediaContentID) : null;
if (cachedResult) {
return cachedResult;
}
const request = {
type: 'media_source/browse_media',
media_content_id: mediaContentID,
};
const browseMedia = await homeAssistantWSRequest<RichBrowseMedia<M>>(
hass,
browseMediaSchema,
request,
);
if (options?.metadataGenerator) {
for (const child of browseMedia.children ?? []) {
child._metadata =
options.metadataGenerator(
child,
typeof target === 'object' ? target : undefined,
) ?? undefined;
}
}
if (options?.cache) {
options.cache.set(
mediaContentID,
browseMedia,
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
);
}
return browseMedia;
}
}
-41
View File
@@ -1,41 +0,0 @@
import { z } from 'zod';
// Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types
//
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90
export interface BrowseMedia {
title: string;
media_class: string;
media_content_type: string;
media_content_id: string;
can_play: boolean;
can_expand: boolean;
children_media_class?: string | null;
thumbnail: string | null;
children?: BrowseMedia[] | null;
}
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = z.lazy(() =>
z.object({
title: z.string(),
media_class: z.string(),
media_content_type: z.string(),
media_content_id: z.string(),
can_play: z.boolean(),
can_expand: z.boolean(),
children_media_class: z.string().nullable().optional(),
thumbnail: z.string().nullable(),
children: z.array(browseMediaSchema).nullable().optional(),
}),
);
export interface RichBrowseMedia<M> extends BrowseMedia {
_metadata?: M;
children?: RichBrowseMedia<M>[] | null;
}
export const MEDIA_CLASS_VIDEO = 'video' as const;
export const MEDIA_CLASS_IMAGE = 'image' as const;
export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const;
-50
View File
@@ -1,50 +0,0 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { computeDomain } from '../../ha/compute-domain.js';
import { HomeAssistant } from '../../ha/types.js';
import { Entity } from './registry/entity/types.js';
/**
* Get the translation of an entity state. Inspired by:
* https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_state_display.ts#L204-L218
*
* This may no longer be necessary to custom implement if `custom-card-helpers`
* is updated to reflect how the Home Assistant frontend now [as of 2023-03-04]
* computes state display (e.g. supports usage of `translation_key`).
*
* https://github.com/custom-cards/custom-card-helpers/blob/master/src/compute-state-display.ts
*
*/
export const getEntityStateTranslation = (
hass: HomeAssistant,
entityID: string,
options?: {
entity?: Entity;
state?: string;
},
): string | null => {
const stateObj: HassEntity | undefined = hass.states[entityID];
const state = options?.state ? options.state : stateObj ? stateObj.state : null;
if (!state) {
return null;
}
const domain = computeDomain(entityID);
const attributes = stateObj ? stateObj.attributes : null;
return (
// Return the translation_key translation.
(options?.entity?.translation_key &&
hass.localize(
`component.${options.entity.platform}.entity.${domain}` +
`.${options.entity.translation_key}.state.${state}`,
)) ||
// Return device class translation
(attributes?.device_class &&
hass.localize(`component.${domain}.state.${attributes.device_class}.${state}`)) ||
// Return default translation
hass.localize(`component.${domain}.state._.${state}`) ||
// We don't know! Return the raw state.
state
);
};
-256
View File
@@ -1,256 +0,0 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { STATES_ON } from '../../ha/const.js';
import { HomeAssistant } from '../../ha/types.js';
import {
CardHelpers,
LovelaceCardWithEditor,
SignedPath,
signedPathSchema,
} from '../../types.js';
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 (by default
* HA will sign for 30 seconds).
* @returns The signed URL, or null if the response was malformed.
*/
export async function homeAssistantSignPath(
hass: HomeAssistant,
path: string,
expires?: number,
): Promise<string | null> {
const request = {
type: 'auth/sign_path',
path: path,
expires: expires,
};
const response = await homeAssistantWSRequest<SignedPath>(
hass,
signedPathSchema,
request,
);
if (!response) {
return null;
}
return hass.hassUrl(response.path);
}
export interface HassStateDifference {
entityID: string;
oldState?: HassEntity;
newState: HassEntity;
}
/**
* Get the difference between two hass objects.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @param options An options object. stateOnly: whether or not to compare state
* strings only, firstOnly: whether or not to get the first difference only.
* @returns An array of HassStateDifference objects.
*/
export function getHassDifferences(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
options?: {
firstOnly?: boolean;
stateOnly?: boolean;
},
): HassStateDifference[] {
if (!newHass || !entities || !entities.length) {
return [];
}
const differences: HassStateDifference[] = [];
for (const entity of entities) {
const oldState: HassEntity | undefined = oldHass?.states[entity];
const newState: HassEntity | undefined = newHass.states[entity];
if (
(options?.stateOnly && oldState?.state !== newState?.state) ||
(!options?.stateOnly && oldState !== newState)
) {
differences.push({
entityID: entity,
oldState: oldState,
newState: newState,
});
if (options?.firstOnly) {
break;
}
}
}
return differences;
}
/**
* Determine if two hass objects are different for a list of entities.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @param options An options object. stateOnly: whether or not to compare state strings only.
* @returns An array of HassStateDifference objects.
*/
export function isHassDifferent(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
options?: {
stateOnly?: boolean;
},
): boolean {
return !!getHassDifferences(newHass, oldHass, entities, {
...options,
firstOnly: true,
}).length;
}
/**
* Get the title of an entity.
* @param entity The entity id.
* @param hass The Home Assistant object.
* @returns The title or undefined.
*/
export function getEntityTitle(hass?: HomeAssistant, entity?: string): string | null {
return entity ? hass?.states[entity]?.attributes?.friendly_name ?? null : null;
}
/**
* Side loads the HA elements this card needs. This trickery is unfortunate
* necessary, see:
* - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements
* @returns `true` if the load is successful, `false` otherwise.
*/
export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
const neededElements = [
'ha-button-menu',
'ha-button',
'ha-camera-stream',
'ha-card',
'ha-circular-progress',
'ha-combo-box',
'ha-hls-player',
'ha-icon-button',
'ha-icon',
'ha-menu-button',
'ha-selector',
'ha-state-icon',
'ha-web-rtc-player',
'mwc-button',
'mwc-list-item',
'state-badge',
];
if (neededElements.every((element) => customElements.get(element))) {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const helpers: CardHelpers = await (window as any).loadCardHelpers();
// This bizarre combination of hacks creates a dummy picture glance card, then
// waits for it to be fully loaded/upgraded as a custom element, so it will
// have the getConfigElement() method which is necessary to load all the
// elements this card requires.
await helpers.createCardElement({
type: 'picture-glance',
entities: [],
camera_image: 'dummy-to-load-editor-components',
});
// Some cast devices have a bug that causes whenDefined to return
// undefined instead of a constructor.
// See related: https://issues.chromium.org/issues/40846966
await customElements.whenDefined('hui-picture-glance-card');
const pgcConstructor = customElements.get('hui-picture-glance-card');
if (!pgcConstructor) {
return false;
}
const pgc = new pgcConstructor() as LovelaceCardWithEditor;
await pgc.constructor.getConfigElement();
return true;
};
/**
* Determine if a given state qualifies as 'triggered'.
* @param state The HA entity state string.
* @returns `true` if triggered, `false` otherwise.
*/
export const isTriggeredState = (state?: string): boolean => {
return !!state && STATES_ON.includes(state);
};
/**
* Get entities from the HASS object.
* @param hass
* @param domain
* @returns A list of entities ids.
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
return [];
}
const entities = Object.keys(hass.states).filter(
(eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain,
);
entities.sort();
return entities;
};
/**
* Determine if a card is in panel mode.
*/
export const isCardInPanel = (card: HTMLElement): boolean => {
const parent = card.getRootNode();
return !!(
parent &&
parent instanceof ShadowRoot &&
parent.host.tagName === 'HUI-PANEL-VIEW'
);
};
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: HomeAssistant, url: string): string;
export function canonicalizeHAURL(hass: HomeAssistant, url?: string): string | null;
export function canonicalizeHAURL(hass: HomeAssistant, url?: string): string | null {
if (isHARelativeURL(url)) {
return hass.hassUrl(url);
}
return url ?? null;
}
/**
* Determine if HA connection state has changed.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @returns `true` if the connection state has changed.
*/
export const hasHAConnectionStateChanged = (
oldHass: HomeAssistant | undefined | null,
newHass: HomeAssistant | undefined | null,
): boolean => {
return oldHass?.connected !== newHass?.connected;
};
/**
* Determine if a state object supports a given feature.
* @param stateObj The state object.
* @param feature The feature to check.
* @returns `true` if the feature is supported, `false` otherwise.
*/
export const supportsFeature = (stateObj: HassEntity, feature: number): boolean =>
((stateObj.attributes.supported_features ?? 0) & feature) !== 0;
-13
View File
@@ -1,13 +0,0 @@
import { HomeAssistant } from '../../../ha/types.js';
import { homeAssistantWSRequest } from '../ws-request.js';
import { IntegrationManifest, integrationManifestSchema } from './types.js';
export const getIntegrationManifest = async (
hass: HomeAssistant,
integration: string,
): Promise<IntegrationManifest> => {
return await homeAssistantWSRequest(hass, integrationManifestSchema, {
type: 'manifest/get',
integration: integration,
});
};
-9
View File
@@ -1,9 +0,0 @@
import { z } from 'zod';
export const integrationManifestSchema = z
.object({
domain: z.string(),
version: z.string().optional(),
})
.passthrough();
export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
-49
View File
@@ -1,49 +0,0 @@
export class RegistryCache<T> {
protected _cache: Map<string, T> = new Map();
protected _keyCallback: (_data: T) => string;
constructor(keyCallback: (_data: T) => string) {
this._keyCallback = keyCallback;
}
/**
* Determine if the cache has a given entity_id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean {
return this._cache.has(id);
}
/**
* Get the first value that returns true for the given predicate.
* @param func A callback function that returns a boolean.
* @returns The first matching value.
*/
public getMatches(func: (arg: T) => boolean): T[] {
return [...this._cache.values()].filter(func);
}
/**
* Get entity information given an id.
* @param id The entity id.
* @returns The entity for this id.
*/
public get(id: string): T | null {
return this._cache.get(id) ?? null;
}
/**
* Add a given entity to the cache.
* @param input The entity.
*/
public add(input: T | T[]): void {
const _set = (arg: T) => this._cache.set(this._keyCallback(arg), arg);
if (Array.isArray(input)) {
input.forEach(_set);
} else {
_set(input);
}
}
}
-54
View File
@@ -1,54 +0,0 @@
import { HomeAssistant } from '../../../../ha/types';
import { errorToConsole } from '../../../basic';
import { homeAssistantWSRequest } from '../../ws-request';
import { RegistryCache } from '../cache';
import { Device, DeviceList, deviceListSchema } from './types';
export const createDeviceRegistryCache = (): RegistryCache<Device> => {
return new RegistryCache<Device>((device) => device.id);
};
export class DeviceRegistryManager {
protected _cache: RegistryCache<Device>;
protected _fetchedDeviceList = false;
constructor(cache: RegistryCache<Device>) {
this._cache = cache;
}
public async getDevice(hass: HomeAssistant, deviceID: string): Promise<Device | null> {
if (this._cache.has(deviceID)) {
return this._cache.get(deviceID);
}
// There is currently no way to fetch a single device.
await this._fetchDeviceList(hass);
return this._cache.get(deviceID) ?? null;
}
public async getMatchingDevices(
hass: HomeAssistant,
func: (arg: Device) => boolean,
): Promise<Device[]> {
await this._fetchDeviceList(hass);
return this._cache.getMatches(func);
}
protected async _fetchDeviceList(hass: HomeAssistant): Promise<void> {
if (this._fetchedDeviceList) {
return;
}
let deviceList: DeviceList | null = null;
try {
deviceList = await homeAssistantWSRequest<DeviceList>(hass, deviceListSchema, {
type: 'config/device_registry/list',
});
} catch (e) {
errorToConsole(e as Error);
return;
}
this._cache.add(deviceList);
this._fetchedDeviceList = true;
}
}
-12
View File
@@ -1,12 +0,0 @@
import { z } from 'zod';
const deviceSchema = z.object({
id: z.string(),
model: z.string().nullable(),
config_entries: z.string().array(),
manufacturer: z.string().nullable(),
});
export type Device = z.infer<typeof deviceSchema>;
export const deviceListSchema = deviceSchema.array();
export type DeviceList = z.infer<typeof deviceListSchema>;
-92
View File
@@ -1,92 +0,0 @@
import { HomeAssistant } from '../../../../ha/types.js';
import { errorToConsole } from '../../../basic.js';
import { homeAssistantWSRequest } from '../../ws-request.js';
import { RegistryCache } from '../cache.js';
import {
Entity,
EntityList,
entityListSchema,
EntityRegistryManager,
entitySchema,
} from './types.js';
export const createEntityRegistryCache = (): RegistryCache<Entity> => {
return new RegistryCache<Entity>((entity) => entity.entity_id);
};
// This class manages interactions with entities, caching results and fetching
// as necessary. Some calls require every entity to be fetched, which may be
// non-trivial in size (after which they are cached forever).
export class EntityRegistryManagerLive implements EntityRegistryManager {
protected _cache: RegistryCache<Entity>;
protected _fetchedEntityList = false;
constructor(cache: RegistryCache<Entity>) {
this._cache = cache;
}
public async getEntity(hass: HomeAssistant, entityID: string): Promise<Entity | null> {
const cachedEntity = this._cache.get(entityID);
if (cachedEntity) {
return cachedEntity;
}
let entity: Entity | null = null;
try {
entity = await homeAssistantWSRequest<Entity>(hass, entitySchema, {
type: 'config/entity_registry/get',
entity_id: entityID,
});
} catch (e) {
errorToConsole(e as Error);
return null;
}
this._cache.add(entity);
return entity;
}
public async getMatchingEntities(
hass: HomeAssistant,
func: (arg: Entity) => boolean,
): Promise<Entity[]> {
await this.fetchEntityList(hass);
return this._cache.getMatches(func);
}
public async getEntities(
hass: HomeAssistant,
entityIDs: string[],
): Promise<Map<string, Entity>> {
const output: Map<string, Entity> = new Map();
const _storeEntity = async (entityID: string): Promise<void> => {
const entity = await this.getEntity(hass, entityID);
if (entity) {
// When asked to fetch multiple entities, ignore missing entities (they
// will just not feature in the output).
output.set(entityID, entity);
}
};
await Promise.all(entityIDs.map(_storeEntity));
return output;
}
public async fetchEntityList(hass: HomeAssistant): Promise<void> {
if (this._fetchedEntityList) {
return;
}
let entityList: EntityList | null = null;
try {
entityList = await homeAssistantWSRequest<EntityList>(hass, entityListSchema, {
type: 'config/entity_registry/list',
});
} catch (e) {
errorToConsole(e as Error);
return;
}
this._cache.add(entityList);
this._fetchedEntityList = true;
}
}
-30
View File
@@ -1,30 +0,0 @@
import { z } from 'zod';
import { HomeAssistant } from '../../../../ha/types';
export const entitySchema = z.object({
config_entry_id: z.string().nullable(),
device_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(),
hidden_by: z.string().nullable(),
platform: z.string(),
translation_key: z.string().nullable(),
// Technically the unique_id should be a string, but we want to tolerate
// numeric unique_ids also in case they are used. See:
// https://github.com/dermotduffy/advanced-camera-card/issues/1016
unique_id: z.string().or(z.number()).optional(),
});
export type Entity = z.infer<typeof entitySchema>;
export const entityListSchema = entitySchema.array();
export type EntityList = z.infer<typeof entityListSchema>;
export interface EntityRegistryManager {
getEntity(hass: HomeAssistant, entityID: string): Promise<Entity | null>;
getEntities(hass: HomeAssistant, entityIDs: string[]): Promise<Map<string, Entity>>;
getMatchingEntities(
hass: HomeAssistant,
func: (arg: Entity) => boolean,
): Promise<Entity[]>;
fetchEntityList(hass: HomeAssistant): Promise<void>;
}
-79
View File
@@ -1,79 +0,0 @@
import QuickLRU from 'quick-lru';
import { HomeAssistant } from '../../ha/types';
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
import { errorToConsole } from '../basic';
import { homeAssistantWSRequest } from './ws-request';
// It's important the cache size be at least as large as the largest likely
// media query or media items will from a given query will be evicted for other
// items in the same query (which would result in only partial results being
// returned to the user).
// Note: Each entry is about 400 bytes.
const RESOLVED_MEDIA_CACHE_SIZE = 1000;
export class ResolvedMediaCache {
protected _cache: QuickLRU<string, ResolvedMedia>;
constructor() {
this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE });
}
/**
* Determine if the cache has a given id.
* @param id
* @returns `true` if the id is in the cache, `false` otherwise.
*/
public has(id: string): boolean {
return this._cache.has(id);
}
/**
* Get resolved media information given an id.
* @param id The id.
* @returns The `ResolvedMedia` for this id.
*/
public get(id: string): ResolvedMedia | undefined {
return this._cache.get(id);
}
/**
* Add a given ResolvedMedia to the cache.
* @param id The id for the object.
* @param resolvedMedia The `ResolvedMedia` object.
*/
public set(id: string, resolvedMedia: ResolvedMedia): void {
this._cache.set(id, resolvedMedia);
}
}
/**
* Resolve a given media source item.
* @param hass The Home Assistant object.
* @param mediaContentID The media content ID.
* @param cache An optional ResolvedMediaCache object.
* @returns The resolved media or `null`.
*/
export const resolveMedia = async (
hass: HomeAssistant,
mediaContentID: string,
cache?: ResolvedMediaCache,
): Promise<ResolvedMedia | null> => {
const cachedValue = cache ? cache.get(mediaContentID) : undefined;
if (cachedValue) {
return cachedValue;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaContentID,
};
let resolvedMedia: ResolvedMedia | null = null;
try {
resolvedMedia = await homeAssistantWSRequest(hass, resolvedMediaSchema, request);
} catch (e) {
errorToConsole(e as Error);
}
if (cache && resolvedMedia) {
cache.set(mediaContentID, resolvedMedia);
}
return resolvedMedia;
};
-53
View File
@@ -1,53 +0,0 @@
import { CameraProxyConfig } from '../../camera-manager/types';
import { HomeAssistant } from '../../ha/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: HomeAssistant,
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
@@ -1,53 +0,0 @@
import { MessageBase } from 'home-assistant-js-websocket';
import { ZodSchema } from 'zod';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { AdvancedCameraCardError } 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 AdvancedCameraCardError(localize('error.failed_response'), {
request: request,
response: e,
});
}
throw e;
}
if (!response) {
throw new AdvancedCameraCardError(localize('error.empty_response'), {
request: request,
});
}
// Some endpoints in Home Assistant pass JSON directly though, 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 AdvancedCameraCardError(localize('error.invalid_response'), {
request: request,
response: response,
invalid_keys: getParseErrorKeys<T>(parseResult.error),
});
}
return parseResult.data;
}
+4
View File
@@ -0,0 +1,4 @@
// Usage of this function needs to be justified with a comment.
export const sleep = async (seconds: number) => {
await new Promise((r) => setTimeout(r, seconds * 1000));
};
+2 -4
View File
@@ -18,7 +18,7 @@ export const renderTask = <R>(
options?: {
cardWideConfig?: CardWideConfig;
inProgressFunc?: () => TemplateResult | void;
errorFunc?: (e: Error) => void;
errorFunc?: (e: Error) => TemplateResult | void;
},
): TemplateResult => {
const progressConfig = {
@@ -31,9 +31,7 @@ export const renderTask = <R>(
options?.inProgressFunc?.() ?? renderProgressIndicator(progressConfig),
error: (e: unknown) => {
errorToConsole(e as Error);
if (options?.errorFunc) {
options.errorFunc(e as Error);
}
return options?.errorFunc?.(e as Error);
},
complete: completeFunc,
})}`;
+7 -9
View File
@@ -17,24 +17,22 @@ const fetchThumbnail = async (
hass: HomeAssistant,
thumbnailURL: string,
): Promise<string | null> => {
if (!hass || !thumbnailURL) {
return null;
}
if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
return thumbnailURL;
}
return new Promise((resolve, reject) => {
if (!hass) {
reject();
return;
}
hass
.fetchWithAuth(thumbnailURL)
// Since we are fetching with an authorization header, we cannot just put the
// URL directly into the document; we need to embed the image. We could do this
// using blob URLs, but then we would need to keep track of them in order to
// release them properly. Instead, we embed the thumbnail using base64.
.then((response) => response.blob())
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.blob();
})
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
@@ -73,7 +71,7 @@ export const createFetchThumbnailTask = (
if (!haveHASS || !hass || !thumbnailURL) {
return null;
}
return fetchThumbnail(hass, thumbnailURL);
return await fetchThumbnail(hass, thumbnailURL);
},
autoRun: autoRun,
});