+14
-1
@@ -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
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})}`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user