+12
-5
@@ -1,7 +1,14 @@
|
||||
# Background
|
||||
|
||||
Some of these functions are inspired by or modified from the unmaintained
|
||||
https://github.com/custom-cards/custom-card-helpers .
|
||||
|
||||
This directory is intended to contain usage agnostic Home Assistant
|
||||
functionality.
|
||||
|
||||
# Background
|
||||
|
||||
Functions in these files are inspired by or modified from the unmaintained
|
||||
https://github.com/custom-cards/custom-card-helpers and are under CC-LICENSE.
|
||||
|
||||
- `compute-domain.ts`
|
||||
- `const.ts`
|
||||
- `fire-hass-event.ts`
|
||||
- `haptic.ts`
|
||||
|
||||
Other files remain under the Advanced Camera Card license.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Small set of utility functions to transform brand URLs. This is a fairly
|
||||
// hacky approach used by the HA frontend to transform logos (which may be wide
|
||||
// and not fit well in thumbnails, or may not respect the users themes) into
|
||||
// icons. In order to ensure consistency of iconography across the card and Home
|
||||
// Assistant, this is mirrored here.
|
||||
//
|
||||
// See: https://github.com/home-assistant/frontend/blob/dev/src/util/brands-url.ts
|
||||
interface BrandsOptions {
|
||||
domain: string;
|
||||
type: 'icon' | 'logo' | 'icon@2x' | 'logo@2x';
|
||||
useFallback?: boolean;
|
||||
darkOptimized?: boolean;
|
||||
brand?: boolean;
|
||||
}
|
||||
|
||||
export const brandsUrl = (options: BrandsOptions): string =>
|
||||
`https://brands.home-assistant.io/${options.brand ? 'brands/' : ''}${
|
||||
options.useFallback ? '_/' : ''
|
||||
}${options.domain}/${options.darkOptimized ? 'dark_' : ''}${options.type}.png`;
|
||||
|
||||
export const extractDomainFromBrandUrl = (url: string) => url.split('/')[4];
|
||||
|
||||
export const isBrandUrl = (thumbnail?: string | null): boolean =>
|
||||
!!thumbnail?.startsWith('https://brands.home-assistant.io/');
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isTruthy } from '../../utils/basic';
|
||||
import { ViewItem, ViewMedia, ViewMediaSourceOptions } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { BrowseMediaViewItemFactory } from './item-factory';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||
|
||||
export const getViewMediaFromBrowseMediaArray = (
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
options?: ViewMediaSourceOptions,
|
||||
): ViewMedia[] => {
|
||||
return getViewItemsFromBrowseMediaArray(browseMedia, options).filter((item) =>
|
||||
ViewItemClassifier.isMedia(item),
|
||||
);
|
||||
};
|
||||
|
||||
export const getViewItemsFromBrowseMediaArray = <
|
||||
M extends BrowseMediaMetadata | undefined,
|
||||
>(
|
||||
browseMedia: RichBrowseMedia<M>[],
|
||||
options?: ViewMediaSourceOptions,
|
||||
): ViewItem[] => {
|
||||
return browseMedia
|
||||
.map((item) =>
|
||||
BrowseMediaViewItemFactory.create(item, {
|
||||
cameraID: item._metadata?.cameraID,
|
||||
...options,
|
||||
}),
|
||||
)
|
||||
.filter(isTruthy);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ViewItem, ViewMediaSourceOptions, ViewMediaType } from '../../view/item';
|
||||
import { BrowseMediaEventViewMedia, BrowseMediaViewFolder } from './item';
|
||||
import {
|
||||
BrowseMediaMetadata,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from './types';
|
||||
|
||||
export class BrowseMediaViewItemFactory {
|
||||
static create(
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata | undefined>,
|
||||
options?: ViewMediaSourceOptions,
|
||||
): ViewItem | null {
|
||||
if (browseMedia.can_expand) {
|
||||
return options?.folder
|
||||
? new BrowseMediaViewFolder(options.folder, browseMedia)
|
||||
: null;
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
browseMedia.media_class === MEDIA_CLASS_VIDEO
|
||||
? ViewMediaType.Clip
|
||||
: browseMedia.media_class === MEDIA_CLASS_IMAGE
|
||||
? ViewMediaType.Snapshot
|
||||
: null;
|
||||
|
||||
return mediaType
|
||||
? new BrowseMediaEventViewMedia(mediaType, browseMedia, options)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { format } from 'date-fns';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { formatDateAndTime } from '../../utils/basic';
|
||||
import {
|
||||
EventViewMedia,
|
||||
VideoContentType,
|
||||
ViewFolder,
|
||||
ViewMedia,
|
||||
ViewMediaSourceOptions,
|
||||
ViewMediaType,
|
||||
} from '../../view/item';
|
||||
import { BrowseMedia, BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||
|
||||
interface MediaClassBrowserSetting {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const mediaClassBrowserSettings: Record<string, MediaClassBrowserSetting> = {
|
||||
album: { icon: 'mdi:album' },
|
||||
app: { icon: 'mdi:application' },
|
||||
artist: { icon: 'mdi:account-music' },
|
||||
channel: { icon: 'mdi:television-classic' },
|
||||
composer: { icon: 'mdi:account-music-outline' },
|
||||
contributing_artist: { icon: 'mdi:account-music' },
|
||||
directory: { icon: 'mdi:folder' },
|
||||
episode: { icon: 'mdi:television-classic' },
|
||||
game: { icon: 'mdi:gamepad-variant' },
|
||||
genre: { icon: 'mdi:drama-masks' },
|
||||
image: { icon: 'mdi:image' },
|
||||
movie: { icon: 'mdi:movie' },
|
||||
music: { icon: 'mdi:music' },
|
||||
playlist: { icon: 'mdi:playlist-music' },
|
||||
podcast: { icon: 'mdi:podcast' },
|
||||
season: { icon: 'mdi:television-classic' },
|
||||
track: { icon: 'mdi:file-music' },
|
||||
tv_show: { icon: 'mdi:television-classic' },
|
||||
url: { icon: 'mdi:web' },
|
||||
video: { icon: 'mdi:video' },
|
||||
};
|
||||
|
||||
const getIcon = (mediaClass: string): string | null => {
|
||||
return mediaClassBrowserSettings[mediaClass]?.icon ?? null;
|
||||
};
|
||||
|
||||
export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata | undefined>;
|
||||
protected _id: string;
|
||||
protected _icon: string | null;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata | undefined>,
|
||||
options?: ViewMediaSourceOptions,
|
||||
) {
|
||||
super(mediaType, {
|
||||
cameraID: options?.cameraID ?? browseMedia._metadata?.cameraID,
|
||||
...options,
|
||||
});
|
||||
this._browseMedia = browseMedia;
|
||||
this._icon = getIcon(browseMedia.media_class);
|
||||
|
||||
// Generate a custom ID that uses the start date (to allow multiple
|
||||
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
|
||||
this._id =
|
||||
browseMedia._metadata?.startDate && this._cameraID
|
||||
? `${this._cameraID}/${format(
|
||||
browseMedia._metadata.startDate,
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
)}`
|
||||
: browseMedia.media_content_id;
|
||||
}
|
||||
|
||||
public getStartTime(): Date | null {
|
||||
return this._browseMedia._metadata?.startDate ?? null;
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return this._browseMedia._metadata?.endDate ?? null;
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return this._mediaType === ViewMediaType.Clip ? VideoContentType.MP4 : null;
|
||||
}
|
||||
public getID(): string {
|
||||
return this._id;
|
||||
}
|
||||
public getContentID(): string {
|
||||
return this._browseMedia.media_content_id;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
const startTime = this.getStartTime();
|
||||
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._browseMedia.thumbnail;
|
||||
}
|
||||
public getIcon(): string | null {
|
||||
return this._icon;
|
||||
}
|
||||
public getWhat(): string[] | null {
|
||||
return this._browseMedia._metadata?.what ?? null;
|
||||
}
|
||||
public getScore(): number | null {
|
||||
return null;
|
||||
}
|
||||
public getTags(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
public isGroupableWith(that: EventViewMedia): boolean {
|
||||
return (
|
||||
this.getMediaType() === that.getMediaType() &&
|
||||
isEqual(this.getWhat(), that.getWhat())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowseMediaViewFolder extends ViewFolder {
|
||||
constructor(folder: FolderConfig, browseMedia: BrowseMedia) {
|
||||
super(folder, {
|
||||
id: browseMedia.media_content_id,
|
||||
icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class),
|
||||
title: browseMedia.title,
|
||||
thumbnail: browseMedia.thumbnail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||
|
||||
export const sortMediaByStartDate = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
): RichBrowseMedia<BrowseMediaMetadata>[] => {
|
||||
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import { ExpiringEqualityCache } from '../../cache/expiring-cache';
|
||||
|
||||
export interface BrowseMediaMetadata {
|
||||
cameraID: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
what?: string[];
|
||||
}
|
||||
// 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 class BrowseMediaCache<M = undefined> extends ExpiringEqualityCache<
|
||||
string,
|
||||
RichBrowseMedia<M>
|
||||
> {}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { add } from 'date-fns';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { allPromises } from '../../utils/basic';
|
||||
import { HomeAssistant } from '../types';
|
||||
import { homeAssistantWSRequest } from '../ws-request';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
BrowseMediaCache,
|
||||
browseMediaSchema,
|
||||
RichBrowseMedia,
|
||||
} from './types';
|
||||
|
||||
type RichMetadataGenerator<M> = (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<M>,
|
||||
) => M | null;
|
||||
|
||||
export type BrowseMediaTarget<M = undefined> = string | RichBrowseMedia<M>;
|
||||
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||
|
||||
export interface BrowseMediaStep<M = undefined> {
|
||||
// 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 BrowseMediaWalker {
|
||||
// Walk down a browse media tree according to instructions included in `steps`.
|
||||
public async walk<M = undefined>(
|
||||
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 || 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.walk(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { rangesOverlap } from '../../camera-manager/range';
|
||||
import { BrowseMediaMetadata, RichBrowseMedia } from './types';
|
||||
|
||||
/**
|
||||
* A utility method to determine if a browse media object matches against a
|
||||
* start and end date.
|
||||
* @param media The browse media object (with rich metadata).
|
||||
* @param start The optional start date.
|
||||
* @param end The optional end date.
|
||||
* @returns `true` if the media falls within the provided dates.
|
||||
*/
|
||||
|
||||
export const isMediaWithinDates = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
): boolean => {
|
||||
// If there's no metadata, nothing matches.
|
||||
if (!media._metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start && end) {
|
||||
// Determine if:
|
||||
// - The media starts within the query timeframe.
|
||||
// - The media ends within the query timeframe.
|
||||
// - The media entirely encompasses the query timeframe.
|
||||
return rangesOverlap(
|
||||
{
|
||||
start: media._metadata.startDate,
|
||||
end: media._metadata.endDate,
|
||||
},
|
||||
{
|
||||
start: start,
|
||||
end: end,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!start && end) {
|
||||
return media._metadata.startDate <= end;
|
||||
}
|
||||
if (start && !end) {
|
||||
return media._metadata.startDate >= start;
|
||||
}
|
||||
|
||||
// If no date is specified at all, everything matches.
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isHARelativeURL } from './is-ha-relative-url';
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Endpoint } from '../types';
|
||||
import { canonicalizeHAURL } from './canonical-url';
|
||||
import { ResolvedMediaCache, resolveMedia } from './resolved-media';
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
export const getMediaDownloadPath = async (
|
||||
hass: HomeAssistant,
|
||||
contentID?: string | null,
|
||||
resolvedMediaCache?: ResolvedMediaCache | null,
|
||||
): Promise<Endpoint | null> => {
|
||||
if (!contentID) {
|
||||
return null;
|
||||
}
|
||||
const resolvedMedia = await resolveMedia(hass, contentID, resolvedMediaCache);
|
||||
return resolvedMedia ? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) } : null;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { computeDomain } from './compute-domain.js';
|
||||
import { Entity } from './registry/entity/types.js';
|
||||
import { HomeAssistant } from './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
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* Get entities from the HASS object.
|
||||
* @param hass
|
||||
* @param domain
|
||||
* @returns A list of entities ids.
|
||||
*/
|
||||
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
|
||||
const entities = Object.keys(hass.states).filter(
|
||||
(eid) => !domain || eid.substring(0, eid.indexOf('.')) === domain,
|
||||
);
|
||||
return entities.sort();
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { HassStateDifference, HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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?.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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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 | null,
|
||||
newHass?: HomeAssistant | null,
|
||||
): boolean => {
|
||||
return oldHass?.connected !== newHass?.connected;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HomeAssistant } from '../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,
|
||||
});
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isHARelativeURL(url?: string): boolean {
|
||||
return !!url?.startsWith('/');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getHassDifferences } from './get-hass-differences';
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { STATES_ON } from './const';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { errorToConsole } from '../../../utils/basic';
|
||||
import { HomeAssistant } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../ws-request';
|
||||
import { Device, DeviceCache, DeviceList, deviceListSchema } from './types';
|
||||
|
||||
export class DeviceRegistryManager {
|
||||
protected _cache: DeviceCache;
|
||||
protected _fetchedDeviceList = false;
|
||||
|
||||
constructor(cache: DeviceCache) {
|
||||
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;
|
||||
}
|
||||
deviceList.forEach((device) => {
|
||||
this._cache.set(device.id, device);
|
||||
});
|
||||
this._fetchedDeviceList = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { Cache } from '../../../cache/cache';
|
||||
|
||||
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>;
|
||||
|
||||
export class DeviceCache extends Cache<string, Device> {}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { errorToConsole } from '../../../utils/basic.js';
|
||||
import { HomeAssistant } from '../../types.js';
|
||||
import { homeAssistantWSRequest } from '../../ws-request.js';
|
||||
import {
|
||||
Entity,
|
||||
EntityCache,
|
||||
EntityList,
|
||||
entityListSchema,
|
||||
EntityRegistryManager,
|
||||
entitySchema,
|
||||
} from './types.js';
|
||||
|
||||
// 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: EntityCache;
|
||||
protected _fetchedEntityList = false;
|
||||
|
||||
constructor(cache: EntityCache) {
|
||||
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.set(entity.entity_id, 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;
|
||||
}
|
||||
entityList.forEach((entity) => {
|
||||
this._cache.set(entity.entity_id, entity);
|
||||
});
|
||||
this._fetchedEntityList = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { HomeAssistant } from '../../types';
|
||||
import { Cache } from '../../../cache/cache';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export class EntityCache extends Cache<string, Entity> {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { LRUCache } from '../cache/lru';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { HomeAssistant, ResolvedMedia, resolvedMediaSchema } from './types';
|
||||
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 extends LRUCache<string, ResolvedMedia> {
|
||||
constructor() {
|
||||
super(RESOLVED_MEDIA_CACHE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 | null,
|
||||
): Promise<ResolvedMedia | null> => {
|
||||
const cachedValue = cache?.get(mediaContentID) ?? null;
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CardHelpers, LovelaceCardWithEditor } from '../types';
|
||||
|
||||
/**
|
||||
* 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-combo-box',
|
||||
'ha-hls-player',
|
||||
'ha-icon-button',
|
||||
'ha-icon',
|
||||
'ha-menu-button',
|
||||
'ha-selector',
|
||||
'ha-spinner',
|
||||
'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;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SignedPath, signedPathSchema } from '../types';
|
||||
import { HomeAssistant } from './types';
|
||||
import { homeAssistantWSRequest } from './ws-request';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
Connection,
|
||||
HassConfig,
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassServices,
|
||||
HassServiceTarget,
|
||||
MessageBase,
|
||||
} from 'home-assistant-js-websocket';
|
||||
import { z } from 'zod';
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -233,3 +235,21 @@ export interface ActionHandlerOptions {
|
||||
hasHold?: boolean;
|
||||
hasDoubleClick?: boolean;
|
||||
}
|
||||
|
||||
export interface HassStateDifference {
|
||||
entityID: string;
|
||||
oldState?: HassEntity;
|
||||
newState: HassEntity;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
// Home Assistant API types.
|
||||
// *************************************************************************
|
||||
// Server side data-type defined here:
|
||||
// https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
||||
|
||||
export const resolvedMediaSchema = z.object({
|
||||
url: z.string(),
|
||||
mime_type: z.string(),
|
||||
});
|
||||
export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CameraProxyConfig } from '../camera-manager/types';
|
||||
import { HomeAssistant } 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: 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MessageBase } from 'home-assistant-js-websocket';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { localize } from '../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../types';
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* 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: unknown;
|
||||
try {
|
||||
response = await hass.callWS<T>(request);
|
||||
} catch (e) {
|
||||
throw new AdvancedCameraCardError(localize('error.failed_response'), {
|
||||
request: request,
|
||||
response: e,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new AdvancedCameraCardError(localize('error.empty_response'), {
|
||||
request: request,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Some endpoints in Home Assistant pass JSON directly though, these end up
|
||||
// wrapped in a string and must be unwrapped first.
|
||||
return schema.parse(passthrough ? JSON.parse(response as string) : response);
|
||||
} catch (e) {
|
||||
throw new AdvancedCameraCardError(localize('error.invalid_response'), {
|
||||
request: request,
|
||||
response: response,
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user