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
+12 -5
View File
@@ -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.
+24
View File
@@ -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);
};
+32
View File
@@ -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;
}
}
+125
View File
@@ -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');
};
+54
View File
@@ -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;
+157
View File
@@ -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;
}
}
+49
View File
@@ -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;
};
+16
View File
@@ -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;
}
+16
View File
@@ -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;
};
+50
View File
@@ -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
);
};
+14
View File
@@ -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();
};
+11
View File
@@ -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;
}
+46
View File
@@ -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;
}
+14
View File
@@ -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;
};
+13
View File
@@ -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,
});
};
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const integrationManifestSchema = z
.object({
domain: z.string(),
version: z.string().optional(),
})
.passthrough();
export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
+3
View File
@@ -0,0 +1,3 @@
export function isHARelativeURL(url?: string): boolean {
return !!url?.startsWith('/');
}
+24
View File
@@ -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;
}
+10
View File
@@ -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);
};
+11
View File
@@ -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'
);
};
+51
View File
@@ -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;
}
}
+15
View File
@@ -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> {}
+90
View File
@@ -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;
}
}
+33
View File
@@ -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> {}
+49
View File
@@ -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;
};
+60
View File
@@ -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;
};
+33
View File
@@ -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);
}
+11
View File
@@ -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;
+20
View File
@@ -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>;
+53
View File
@@ -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,
}),
});
}
+48
View File
@@ -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,
});
}
}