Initial common.js refactor.

This commit is contained in:
Dermot Duffy
2022-05-12 20:42:02 -07:00
parent 3cea188c40
commit 6b30487229
48 changed files with 1604 additions and 1657 deletions
+185
View File
@@ -0,0 +1,185 @@
import {
ActionConfig,
handleActionConfig,
hasAction,
HomeAssistant
} from 'custom-card-helpers';
import {
Actions,
ActionsConfig,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema
} from '../types.js';
/**
* Convert a generic Action to a FrigateCardCustomAction if it parses correctly.
* @param action The generic action configuration.
* @returns A FrigateCardCustomAction or null if it cannot be converted.
*/
export function convertActionToFrigateCardCustomAction(
action: ActionType | null,
): FrigateCardCustomAction | null {
if (!action) {
return null;
}
// Parse a custom event as other things could generate ll-custom events that
// are not related to Frigate Card.
const parseResult = frigateCardCustomActionSchema.safeParse(action);
return parseResult.success ? parseResult.data : null;
}
/**
* Create a Frigate card custom action.
* @param action The Frigate card action string (e.g. 'fullscreen')
* @returns A FrigateCardCustomAction for that action string or null.
*/
export function createFrigateCardCustomAction(
action: FrigateCardAction,
args?: {
camera?: string;
media_player?: string;
media_player_action?: 'play' | 'stop';
},
): FrigateCardCustomAction | null {
if (action === 'camera_select') {
if (!args?.camera) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
};
}
if (action === 'media_player') {
if (!args?.media_player || !args.media_player_action) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
};
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
};
}
/**
* Get an action configuration given a config and an interaction (e.g. 'tap').
* @param interaction The interaction: `tap`, `hold` or `double_tap`
* @param config The configuration containing multiple actions.
* @returns The relevant action configuration or null if none found.
*/
export function getActionConfigGivenAction(
interaction?: string,
config?: Actions,
): ActionType | ActionType[] | undefined {
if (!interaction || !config) {
return undefined;
}
if (interaction == 'tap' && config.tap_action) {
return config.tap_action;
} else if (interaction == 'hold' && config.hold_action) {
return config.hold_action;
} else if (interaction == 'double_tap' && config.double_tap_action) {
return config.double_tap_action;
} else if (interaction == 'end_tap' && config.end_tap_action) {
return config.end_tap_action;
} else if (interaction == 'start_tap' && config.start_tap_action) {
return config.start_tap_action;
}
return undefined;
}
/**
* Frigate card custom version of handleAction
* (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts)
* that handles the custom action events the card supports.
* @param node The node that fired the event.
* @param hass The Home Assistant object.
* @param config The multi-action configuration.
* @param action The action string (e.g. 'hold')
* @returns Whether or not an action was executed.
*/
export const frigateCardHandleAction = (
node: HTMLElement,
hass: HomeAssistant,
config: ActionsConfig,
action: string,
): boolean => {
return frigateCardHandleActionConfig(
node,
hass,
config,
action,
getActionConfigGivenAction(action, config),
);
};
/**
* Handle an ActionConfig or array of ActionConfigs.
* @param node The node that fired the event.
* @param hass The Home Assistant object.
* @param actionConfig A single action config, array of action configs or
* undefined for the default action config for 'tap'.
* @param action The action string (e.g. 'hold')
* @returns Whether or not an action was executed.
*/
export const frigateCardHandleActionConfig = (
node: HTMLElement,
hass: HomeAssistant,
config: {
camera_image?: string;
entity?: string;
},
action: string,
actionConfig: ActionType | ActionType[] | undefined,
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
// ActionConfig vs ActionType:
// There is a slight typing (but not functional) difference between
// ActionType in this card and ActionConfig in `custom-card-helpers`. See
// `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
// reason behind this difference.
if (Array.isArray(actionConfig)) {
actionConfig.forEach((action) =>
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
);
} else {
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
}
return true;
}
return false;
};
/**
* Determine if an action config has a real action. A modified version of
* custom-card-helpers hasAction to also work with arrays of action configs.
* @param config The action config in question.
* @returns `true` if there's a real action defined, `false` otherwise.
*/
export const frigateCardHasAction = (
config?: ActionType | ActionType[] | undefined,
): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is
// necessary and harmless.
if (Array.isArray(config)) {
return !!config.find((item) => hasAction(item as ActionConfig | undefined));
}
return hasAction(config as ActionConfig | undefined);
};
/**
* Stop an event from activating card wide actions.
*/
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation();
};
+62
View File
@@ -0,0 +1,62 @@
import { isEqual } from 'lodash-es';
/**
* Dispatch a Frigate Card event.
* @param element The element to send the event.
* @param name The name of the Frigate card event to send.
* @param detail An optional detail object to attach.
*/
export function dispatchFrigateCardEvent<T>(
target: EventTarget,
name: string,
detail?: T,
): void {
target.dispatchEvent(
new CustomEvent<T>(`frigate-card:${name}`, {
bubbles: true,
composed: true,
detail: detail,
}),
);
}
/**
* Prettify a title by converting '_' to spaces and capitalizing words.
* @param input The input Frigate (camera/label/zone) name.
* @returns A prettified name.
*/
export function prettifyTitle(input?: string): string | undefined {
if (!input) {
return undefined;
}
const words = input.split(/[_\s]+/);
return words
.map((word) => {
return word[0].toUpperCase() + word.substring(1);
})
.join(' ');
}
/**
* Move an element within an array.
* @param target Target array.
* @param from From index.
* @param to To index.
*/
export function arrayMove(target: unknown[], from: number, to: number): void {
const element = target[from];
target.splice(from, 1);
target.splice(to, 0, element);
}
/**
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
* in lit web components that may have a value that changes address but not
* contents -- and for which a re-render is expensive/jarring.
* @param n The new value.
* @param o The old value.
* @returns `true` is the contents have changed.
*/
export function contentsChanged(n: unknown, o: unknown): boolean {
return !isEqual(n, o);
}
+66
View File
@@ -0,0 +1,66 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, RawFrigateCardConfig } from '../types.js';
import { prettifyTitle } from './basic.js';
import { getEntityIcon, getEntityTitle } from './ha';
/**
* Get a camera id.
* @param config The camera config (either parsed or raw).
* @returns A camera id.
*/
export function getCameraID(
config?: CameraConfig | RawFrigateCardConfig | null,
): string {
return (
(typeof config?.id === 'string' && config.id) ||
(typeof config?.camera_entity === 'string' && config.camera_entity) ||
(typeof config?.webrtc_card === 'object' &&
config.webrtc_card &&
typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) ||
(typeof config?.camera_name === 'string' && config.camera_name) ||
''
);
}
/**
* Get a camera text title.
* @param hass The Home Assistant object.
* @param config The camera config (either parsed or raw).
* @returns A title string.
*/
export function getCameraTitle(
hass?: HomeAssistant,
config?: CameraConfig | RawFrigateCardConfig | null,
): string {
// Attempt to render a recognizable name for the camera,
// starting with the most likely to be useful and working our
// ways towards the least useful. Extra type checking here since this is also
// used on raw configuration in the editor.
return (
(typeof config?.title === 'string' && config.title) ||
(typeof config?.camera_entity === 'string'
? getEntityTitle(hass, config.camera_entity)
: '') ||
(typeof config?.webrtc_card === 'object' &&
config.webrtc_card &&
typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) ||
(typeof config?.camera_name === 'string' ? prettifyTitle(config.camera_name) : '') ||
(typeof config?.id === 'string' && config.id) ||
''
);
}
/**
* Get a camera icon.
* @param hass The Home Assistant object.
* @param config The camera config.
* @returns An icon string.
*/
export function getCameraIcon(
hass?: HomeAssistant,
config?: CameraConfig | null,
): string {
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
}
+33
View File
@@ -0,0 +1,33 @@
import {
differenceInHours, differenceInMinutes, differenceInSeconds, fromUnixTime
} from 'date-fns';
import { localize } from '../localize/localize.js';
import { FrigateEvent } from '../types.js';
/**
* Convenience function to convert a timestamp to hours, minutes and seconds
* string. Heavily inspired by, and returning the same format as, the Frigate
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
* @param event The Frigate event.
* @returns A duration string.
*/
export function getEventDurationString(event: FrigateEvent): string {
if (!event.end_time) {
return localize('event.in_progress');
}
const start = fromUnixTime(event.start_time);
const end = fromUnixTime(event.end_time);
const hours = differenceInHours(end, start);
const minutes = differenceInMinutes(end, start) - hours * 60;
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
let duration = '';
if (hours) {
duration += `${hours}h `;
}
if (minutes) {
duration += `${minutes}m `;
}
duration += `${seconds}s`;
return duration;
}
+414
View File
@@ -0,0 +1,414 @@
import { HomeAssistant } from 'custom-card-helpers';
import { homeAssistantWSRequest } from '.';
import {
dispatchErrorMessageEvent,
dispatchMessageEvent
} from '../../components/message.js';
import { localize } from '../../localize/localize.js';
import {
BrowseMediaQueryParameters,
CameraConfig,
FrigateBrowseMediaSource,
frigateBrowseMediaSourceSchema,
MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST
} from '../../types.js';
import { View } from '../../view.js';
import { getCameraTitle } from '../camera.js';
export class BrowseMediaUtil {
/**
* Return the Frigate event_id given a FrigateBrowseMediaSource object.
* @param media The event to get the id from.
* @returns The `event_id` or `null` if not successfully parsed.
*/
static getEventID(media: FrigateBrowseMediaSource): string | null {
return media.frigate?.event.id ?? null;
}
/**
* Return the event start time given a FrigateBrowseMediaSource object.
* @param browseMedia The media object to get the start time from.
* @returns The start time in unix/epoch time, or null if it cannot be determined.
*/
static getEventStartTime(media: FrigateBrowseMediaSource): number | null {
return media.frigate?.event.start_time ?? null;
}
/**
* Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder).
* @param media The media object.
* @returns `true` if it's truly a media item, `false` otherwise.
*/
static isTrueMedia(media?: FrigateBrowseMediaSource): boolean {
return !!media && !media.can_expand;
}
/**
* From a FrigateBrowseMediaSource item extract the first true media item from the
* children (i.e. a clip/snapshot, not a folder).
* @param media The media object with children.
* @returns The first true media item found.
*/
static getFirstTrueMediaChildIndex(
media: FrigateBrowseMediaSource | null,
): number | null {
if (!media || !media.children) {
return null;
}
const index = media.children.findIndex((child) => this.isTrueMedia(child));
return index >= 0 ? index : null;
}
/**
* Browse Frigate media with a media content id. May throw.
* @param hass The HomeAssistant object.
* @param media_content_id The media content id to browse.
* @returns A FrigateBrowseMediaSource object or null on malformed.
*/
static async browseMedia(
hass: HomeAssistant,
media_content_id: string,
): Promise<FrigateBrowseMediaSource> {
const request = {
type: 'media_source/browse_media',
media_content_id: media_content_id,
};
return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request);
}
/**
* Browse Frigate media with a media query. May throw.
* @param hass The HomeAssistant object.
* @param params The search parameters to use to search for media.
* @returns A FrigateBrowseMediaSource object or null on malformed.
*/
static async browseMediaQuery(
hass: HomeAssistant,
params: BrowseMediaQueryParameters,
): Promise<FrigateBrowseMediaSource> {
return this.browseMedia(
hass,
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
[
'media-source://frigate',
params.clientId,
'event-search',
params.mediaType,
// If the name field ends in '.all' the integration will return up to 10K events.
params.unlimited ? '.all' : '',
params.after ? String(Math.floor(params.after)) : '',
params.before ? String(Math.ceil(params.before)) : '',
params.cameraName,
params.label,
params.zone,
].join('/'),
);
}
/**
* Browse multiple Frigate media queries. May throw.
* @param hass The HomeAssistant object.
* @param params An array of search parameters to use to search for media.
* @returns A map of FrigateBrowseMediaSource object or null on malformed.
*/
static async multipleBrowseMediaQuery(
hass: HomeAssistant,
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>> {
params = Array.isArray(params) ? params : [params];
const output: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource> = new Map();
await Promise.all(
params.map(async (param: BrowseMediaQueryParameters): Promise<void> => {
output.set(param, await this.browseMediaQuery(hass, param));
}),
);
return output;
}
/**
* Browse multiple Frigate media queries, then merged them. May throw.
* @param hass The HomeAssistant object.
* @param params An array of search parameters to use to search for media.
* @returns A single FrigateBrowseMediaSource object or null on malformed.
*/
static async multipleBrowseMediaQueryMerged(
hass: HomeAssistant,
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<FrigateBrowseMediaSource> {
return this.mergeFrigateBrowseMediaSources(
await this.multipleBrowseMediaQuery(hass, params),
);
}
/**
* Merge multiple FrigateBrowseMediaSource into a single. Note that this may
* use information from the query to differentiate results that may otherwise
* be identical.
* @param input A map of query -> result.
* @returns A single FrigateBrowseMediaSource object.
*/
static mergeFrigateBrowseMediaSources(
input: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>,
): FrigateBrowseMediaSource {
const children: FrigateBrowseMediaSource[] = [];
for (const [query, result] of input.entries()) {
for (const child of result.children || []) {
if (this.isTrueMedia(child)) {
children.push(child);
} else {
// If there are multiple inputs, separate the folder names with the
// query title (if available).
if (query.title && input.size > 1) {
children.push({ ...child, title: `[${query.title}] ${child.title}` });
} else {
children.push(child);
}
}
}
}
const eventSort = (
a: FrigateBrowseMediaSource,
b: FrigateBrowseMediaSource,
): number => {
if (
!a.frigate?.event ||
(b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time)
) {
return 1;
}
if (
!b.frigate?.event ||
(a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time)
) {
return -1;
}
return 0;
};
return this.createEventParentForChildren('Merged events', children.sort(eventSort));
}
/**
* Get the parameters to search for media.
* @returns A BrowseMediaQueryParameters object.
*/
static getBrowseMediaQueryParameters(
hass: HomeAssistant,
cameraID: string,
cameraConfig?: CameraConfig,
overrides?: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters | null {
if (!cameraConfig || !cameraConfig.camera_name) {
return null;
}
return {
clientId: cameraConfig.client_id,
cameraName: cameraConfig.camera_name,
label: cameraConfig.label,
zone: cameraConfig.zone,
title: getCameraTitle(hass, cameraConfig),
cameraID: cameraID,
...overrides,
};
}
/**
* Apply overrides to multiple query parameters.
* @param parameters An array of query parameters.
* @param overrides The overrides to apply.
* @returns The override query parameters.
*/
static overrideMultiBrowseMediaQueryParameters(
parameters: BrowseMediaQueryParameters[],
overrides: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters[] {
const output: BrowseMediaQueryParameters[] = [];
parameters.forEach((param) => {
output.push({ ...param, ...overrides });
});
return output;
}
/**
* Get BrowseMediaQueryParameters for a camera (including its dependencies).
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
static getFullDependentBrowseMediaQueryParameters(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
for (const eventCameraID of cameraConfig.dependent_cameras || []) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(camera);
const params: BrowseMediaQueryParameters[] = [];
for (const cameraID of cameraIDs) {
const param = BrowseMediaUtil.getBrowseMediaQueryParameters(
hass,
cameraID,
cameras.get(cameraID),
mediaType ? { mediaType: mediaType } : {},
);
// Fail on a single bad camera, as it's almost certainly a user error that
// should be fixed rather than hidden.
if (!param) {
return null;
}
params.push(param);
}
return params.length ? params : null;
}
/**
* Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error.
* @param element The element from which to dispatch the error.
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
static getFullDependentBrowseMediaQueryParametersOrDispatchError(
element: HTMLElement,
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null {
const params = this.getFullDependentBrowseMediaQueryParameters(
hass,
cameras,
camera,
mediaType,
);
if (!params) {
dispatchErrorMessageEvent(
element,
localize('error.no_camera_name'),
cameras.get(camera),
);
return null;
}
return params;
}
/**
* Fetch the latest media and dispatch a change view event to reflect the
* results. If no media is found a suitable message event will be triggered
* instead.
* @param element The HTMLElement to dispatch events from.
* @param hass The Home Assistant object.
* @param view The current view to evolve.
* @param browseMediaQueryParameters The media parameters to query with.
* @returns
*/
static async fetchLatestMediaAndDispatchViewChange(
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
browseMediaQueryParameters:
| BrowseMediaQueryParameters
| BrowseMediaQueryParameters[],
): Promise<void> {
let parent: FrigateBrowseMediaSource | null;
try {
parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(
hass,
browseMediaQueryParameters,
);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent(
element,
view.isClipRelatedView()
? localize('common.no_clip')
: localize('common.no_snapshot'),
view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
);
}
view
.evolve({
target: parent,
childIndex: childIndex,
})
.dispatchChangeEvent(element);
}
/**
* Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change
* view event to reflect the results.
* @param node The HTMLElement to dispatch events from.
* @param hass The Home Assistant object.
* @param view The current view to evolve.
* @param child The FrigateBrowseMediaSource child to query for.
* @returns
*/
static async fetchChildMediaAndDispatchViewChange(
element: HTMLElement,
hass: HomeAssistant,
view: Readonly<View>,
child: Readonly<FrigateBrowseMediaSource>,
): Promise<void> {
let parent: FrigateBrowseMediaSource;
try {
parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
view
.evolve({
target: parent,
})
.dispatchChangeEvent(element);
}
/**
* Given an array of media children, create a parent for them.
* @param title The title to use for the parent.
* @param children The children media items.
* @returns A single parent containing the children.
*/
static createEventParentForChildren(
title: string,
children: FrigateBrowseMediaSource[],
): FrigateBrowseMediaSource {
return {
title: title,
media_class: MEDIA_CLASS_PLAYLIST,
media_content_type: MEDIA_TYPE_PLAYLIST,
media_content_id: '',
can_play: false,
can_expand: true,
children_media_class: MEDIA_CLASS_PLAYLIST,
thumbnail: null,
children: children,
};
}
}
+256
View File
@@ -0,0 +1,256 @@
import { computeStateDomain, HomeAssistant } from 'custom-card-helpers';
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod';
import { localize } from '../../localize/localize.js';
import {
CardHelpers,
ExtendedHomeAssistant,
SignedPath,
signedPathSchema,
StateParameters
} from '../../types.js';
import { stateIcon } from '../icons/state-icon.js';
import { getParseErrorKeys } from '../zod.js';
/**
* Make a HomeAssistant websocket request. May throw.
* @param hass The HomeAssistant object to send the request with.
* @param schema The expected Zod schema of the response.
* @param request The request to make.
* @returns The parsed valid response or null on malformed.
*/
export async function homeAssistantWSRequest<T>(
hass: HomeAssistant,
schema: ZodSchema<T>,
request: MessageBase,
): Promise<T> {
const response = await hass.callWS<T>(request);
if (!response) {
const error_message = `${localize('error.empty_response')}: ${JSON.stringify(
request,
)}`;
console.warn(error_message);
throw new Error(error_message);
}
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = getParseErrorKeys<T>(parseResult.error);
const error_message =
`${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` +
localize('error.invalid_keys') +
`: '${keys}'`;
console.warn(error_message);
throw new Error(error_message);
}
return parseResult.data;
}
/**
* 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.
* @returns The signed URL, or null if the response was malformed.
*/
export async function homeAssistantSignPath(
hass: ExtendedHomeAssistant,
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);
}
/**
* Determine whether the card should be updated based on Home Assistant changes.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @returns A boolean indicating whether or not to allow an update.
*/
export function shouldUpdateBasedOnHass(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
): boolean {
if (!newHass || !entities || !entities.length) {
return false;
}
if (!oldHass) {
return true;
}
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (entity && oldHass.states[entity] !== newHass.states[entity]) {
return true;
}
}
return false;
}
/**
* Calculate a style brightness from a hass state.
* Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296
* @param state The hass state object.
* @returns A CSS brightness string.
*/
function computeBrightnessFromState(state: HassEntity): string {
if (state.state === 'off' || !state.attributes.brightness) {
return '';
}
const brightness = state.attributes.brightness;
return `brightness(${(brightness + 245) / 5}%)`;
}
/**
* Calculate a style color from a hass state.
* Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304
* @param state The hass state object.
* @returns A CSS color string.
*/
function computeColorFromState(state: HassEntity): string {
if (state.state === 'off') {
return '';
}
return state.attributes.rgb_color
? `rgb(${state.attributes.rgb_color.join(',')})`
: '';
}
/**
* Get the style of emphasized menu items.
* @returns A StyleInfo.
*/
function computeStyle(state: HassEntity): StyleInfo {
return {
color: computeColorFromState(state),
filter: computeBrightnessFromState(state),
};
}
/**
* Determine the string state of a given stateObj.
* From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts
* @param stateObj The HassEntity object from `hass.states`.
* @returns A string state, e.g. 'on'.
*/
export const computeActiveState = (stateObj: HassEntity): string => {
const domain = stateObj.entity_id.split('.')[0];
let state = stateObj.state;
if (domain === 'climate') {
state = stateObj.attributes.hvac_action;
}
return state;
};
/**
* Use Home Assistant state to refresh state parameters for an item to be rendered.
* @param hass Home Assistant object.
* @param params A StateParameters object to modify in place.
* @returns A StateParameters object updated based on HASS state.
*/
export function refreshDynamicStateParameters(
hass: HomeAssistant,
params: StateParameters,
): StateParameters {
if (!params.entity) {
return params;
}
const state = hass.states[params.entity];
if (!!state && !!params.state_color) {
params.style = { ...computeStyle(state), ...params.style };
}
params.title = params.title ?? (state?.attributes?.friendly_name || params.entity);
params.icon = params.icon ?? stateIcon(state);
const domain = state ? computeStateDomain(state) : undefined;
params.data_domain =
params.state_color || (domain === 'light' && params.state_color !== false)
? domain
: undefined;
if (state) {
params.data_state = computeActiveState(state);
}
return params;
}
/**
* 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 | undefined {
return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined;
}
/**
* Get the icon of an entity.
* @param entity The entity id.
* @param hass The Home Assistant object.
* @returns The icon or undefined.
*/
export function getEntityIcon(hass?: HomeAssistant, entity?: string): string {
return stateIcon(entity ? hass?.states[entity] : 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-selector',
'ha-menu-button',
'ha-camera-stream',
'ha-hls-player',
'ha-web-rtc-player',
'ha-icon',
'ha-circular-progress',
'ha-icon-button',
'ha-card',
'ha-svg-icon',
'ha-button-menu',
];
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();
// The picture-glance editor loads everything this card needs.
const pictureGlance = await helpers.createCardElement({
type: 'picture-glance',
entities: [],
camera_image: 'dummy-to-load-editor-components',
});
if (pictureGlance.constructor.getConfigElement) {
await pictureGlance.constructor.getConfigElement();
return true;
}
return false;
};
+36
View File
@@ -0,0 +1,36 @@
import {
HassEntity,
HassEntityAttributeBase,
HassEntityBase
} from 'home-assistant-js-websocket';
export const UPDATE_SUPPORT_INSTALL = 1;
export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2;
export const UPDATE_SUPPORT_PROGRESS = 4;
export const UPDATE_SUPPORT_BACKUP = 8;
export const UPDATE_SUPPORT_RELEASE_NOTES = 16;
interface UpdateEntityAttributes extends HassEntityAttributeBase {
auto_update: boolean | null;
installed_version: string | null;
in_progress: boolean | number;
latest_version: string | null;
release_summary: string | null;
release_url: string | null;
skipped_version: string | null;
title: string | null;
}
export interface UpdateEntity extends HassEntityBase {
attributes: UpdateEntityAttributes;
}
export const supportsFeature = (stateObj: HassEntity, feature: number): boolean =>
((stateObj.attributes.supported_features ?? 0) & feature) !== 0;
export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) &&
typeof entity.attributes.in_progress === 'number';
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
updateUsesProgress(entity) || !!entity.attributes.in_progress;
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
## Icons
These entity icon mappings are copied (with slight tweaks to work with this
card), from the [Lovelace Mushroom
Card](https://github.com/piitaya/lovelace-mushroom/tree/main/src/utils/icons), which are in turn copied from [the Home Assistant frontend](https://github.com/home-assistant/frontend/tree/dev/src/common/entity) in order to
replace the out of date entity icons in
[custom-card-helpers](https://github.com/custom-cards/custom-card-helpers).
+42
View File
@@ -0,0 +1,42 @@
export const alarmPanelIcon = (state?: string) => {
switch (state) {
case 'armed_away':
return 'mdi:shield-lock';
case 'armed_vacation':
return 'mdi:shield-airplane';
case 'armed_home':
return 'mdi:shield-home';
case 'armed_night':
return 'mdi:shield-moon';
case 'armed_custom_bypass':
return 'mdi:security';
case 'pending':
case 'arming':
return 'mdi:shield-sync';
case 'triggered':
return 'mdi:bell-ring';
case 'disarmed':
return 'mdi:shield-off';
default:
return 'mdi:shield';
}
};
export const alarmPanelIconAction = (state?: string) => {
switch (state) {
case 'armed_away':
return 'mdi:shield-lock-outline';
case 'armed_vacation':
return 'mdi:shield-airplane-outline';
case 'armed_home':
return 'mdi:shield-home-outline';
case 'armed_night':
return 'mdi:shield-moon-outline';
case 'armed_custom_bypass':
return 'mdi:shield-half-full';
case 'disarmed':
return 'mdi:shield-off-outline';
default:
return 'mdi:shield-outline';
}
};
+58
View File
@@ -0,0 +1,58 @@
import { HassEntity } from 'home-assistant-js-websocket';
export const binarySensorIcon = (state?: string, entity?: HassEntity) => {
const isOff = state === 'off';
switch (entity?.attributes.device_class) {
case 'battery':
return isOff ? 'mdi:battery' : 'mdi:battery-outline';
case 'battery_charging':
return isOff ? 'mdi:battery' : 'mdi:battery-charging';
case 'cold':
return isOff ? 'mdi:thermometer' : 'mdi:snowflake';
case 'connectivity':
return isOff ? 'mdi:close-network-outline' : 'mdi:check-network-outline';
case 'door':
return isOff ? 'mdi:door-closed' : 'mdi:door-open';
case 'garage_door':
return isOff ? 'mdi:garage' : 'mdi:garage-open';
case 'power':
return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug';
case 'gas':
case 'problem':
case 'safety':
case 'tamper':
return isOff ? 'mdi:check-circle' : 'mdi:alert-circle';
case 'smoke':
return isOff ? 'mdi:check-circle' : 'mdi:smoke';
case 'heat':
return isOff ? 'mdi:thermometer' : 'mdi:fire';
case 'light':
return isOff ? 'mdi:brightness5' : 'mdi:brightness-7';
case 'lock':
return isOff ? 'mdi:lock' : 'mdi:lock-open';
case 'moisture':
return isOff ? 'mdi:water-off' : 'mdi:water';
case 'motion':
return isOff ? 'mdi:motion-sensor-off' : 'mdi:motion-sensor';
case 'occupancy':
return isOff ? 'mdi:home-outline' : 'mdi:home';
case 'opening':
return isOff ? 'mdi:square' : 'mdi:square-outline';
case 'plug':
return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug';
case 'presence':
return isOff ? 'mdi:home-outline' : 'mdi:home';
case 'running':
return isOff ? 'mdi:stop' : 'mdi:play';
case 'sound':
return isOff ? 'mdi:music-note-off' : 'mdi:music-note';
case 'update':
return isOff ? 'mdi:package' : 'mdi:package-up';
case 'vibration':
return isOff ? 'mdi:crop-portrait' : 'mdi:vibrate';
case 'window':
return isOff ? 'mdi:window-closed' : 'mdi:window-open';
default:
return isOff ? 'mdi:radiobox-blank' : 'mdi:checkbox-marked-circle';
}
};
+113
View File
@@ -0,0 +1,113 @@
import { HassEntity } from 'home-assistant-js-websocket';
export const coverIcon = (state?: string, entity?: HassEntity): string => {
const open = state !== 'closed';
switch (entity?.attributes.device_class) {
case 'garage':
switch (state) {
case 'opening':
return 'mdi:arrow-up-box';
case 'closing':
return 'mdi:arrow-down-box';
case 'closed':
return 'mdi:garage';
default:
return 'mdi:garage-open';
}
case 'gate':
switch (state) {
case 'opening':
case 'closing':
return 'mdi:gate-arrow-right';
case 'closed':
return 'mdi:gate';
default:
return 'mdi:gate-open';
}
case 'door':
return open ? 'mdi:door-open' : 'mdi:door-closed';
case 'damper':
return open ? 'md:circle' : 'mdi:circle-slice-8';
case 'shutter':
switch (state) {
case 'opening':
return 'mdi:arrow-up-box';
case 'closing':
return 'mdi:arrow-down-box';
case 'closed':
return 'mdi:window-shutter';
default:
return 'mdi:window-shutter-open';
}
case 'curtain':
switch (state) {
case 'opening':
return 'mdi:arrow-split-vertical';
case 'closing':
return 'mdi:arrow-collapse-horizontal';
case 'closed':
return 'mdi:curtains-closed';
default:
return 'mdi:curtains';
}
case 'blind':
case 'shade':
switch (state) {
case 'opening':
return 'mdi:arrow-up-box';
case 'closing':
return 'mdi:arrow-down-box';
case 'closed':
return 'mdi:blinds';
default:
return 'mdi:blinds-open';
}
case 'window':
switch (state) {
case 'opening':
return 'mdi:arrow-up-box';
case 'closing':
return 'mdi:arrow-down-box';
case 'closed':
return 'mdi:window-closed';
default:
return 'mdi:window-open';
}
}
switch (state) {
case 'opening':
return 'mdi:arrow-up-box';
case 'closing':
return 'mdi:arrow-down-box';
case 'closed':
return 'mdi:window-closed';
default:
return 'mdi:window-open';
}
};
export const computeOpenIcon = (stateObj: HassEntity): string => {
switch (stateObj.attributes.device_class) {
case 'awning':
case 'curtain':
case 'door':
case 'gate':
return 'mdi:arrow-expand-horizontal';
default:
return 'mdi:arrow-up';
}
};
export const computeCloseIcon = (stateObj: HassEntity): string => {
switch (stateObj.attributes.device_class) {
case 'awning':
case 'curtain':
case 'door':
case 'gate':
return 'mdi:arrow-collapse-horizontal';
default:
return 'mdi:arrow-down';
}
};
+166
View File
@@ -0,0 +1,166 @@
import { HassEntity } from 'home-assistant-js-websocket';
import { UpdateEntity, updateIsInstalling } from '../ha/update.js';
import { alarmPanelIcon } from './alarm-panel-icon';
import { binarySensorIcon } from './binary-sensor-icon';
import { coverIcon } from './cover-icon';
import { sensorIcon } from './sensor-icon';
export const DEFAULT_DOMAIN_ICON = 'mdi:bookmark';
const FIXED_DOMAIN_ICONS = {
alert: 'mdi:alert',
air_quality: 'mdi:air-filter',
automation: 'mdi:robot',
calendar: 'mdi:calendar',
camera: 'mdi:video',
climate: 'mdi:thermostat',
configurator: 'mdi:cog',
conversation: 'mdi:text-to-speech',
counter: 'mdi:counter',
fan: 'mdi:fan',
google_assistant: 'mdi:google-assistant',
group: 'mdi:google-circles-communities',
homeassistant: 'mdi:home-assistant',
homekit: 'mdi:home-automation',
image_processing: 'mdi:image-filter-frames',
input_button: 'mdi:gesture-tap-button',
input_datetime: 'mdi:calendar-clock',
input_number: 'mdi:ray-vertex',
input_select: 'mdi:format-list-bulleted',
input_text: 'mdi:form-textbox',
light: 'mdi:lightbulb',
mailbox: 'mdi:mailbox',
notify: 'mdi:comment-alert',
number: 'mdi:ray-vertex',
persistent_notification: 'mdi:bell',
person: 'mdi:account',
plant: 'mdi:flower',
proximity: 'mdi:apple-safari',
remote: 'mdi:remote',
scene: 'mdi:palette',
script: 'mdi:script-text',
select: 'mdi:format-list-bulleted',
sensor: 'mdi:eye',
siren: 'mdi:bullhorn',
simple_alarm: 'mdi:bell',
sun: 'mdi:white-balance-sunny',
timer: 'mdi:timer-outline',
updater: 'mdi:cloud-upload',
vacuum: 'mdi:robot-vacuum',
water_heater: 'mdi:thermometer',
weather: 'mdi:weather-cloudy',
zone: 'mdi:map-marker-radius',
};
export function domainIcon(domain: string, entity?: HassEntity, state?: string): string {
switch (domain) {
case 'alarm_control_panel':
return alarmPanelIcon(state);
case 'binary_sensor':
return binarySensorIcon(state, entity);
case 'button':
switch (entity?.attributes.device_class) {
case 'restart':
return 'mdi:restart';
case 'update':
return 'mdi:package-up';
default:
return 'mdi:gesture-tap-button';
}
case 'cover':
return coverIcon(state, entity);
case 'device_tracker':
if (entity?.attributes.source_type === 'router') {
return state === 'home' ? 'mdi:lan-connect' : 'mdi:lan-disconnect';
}
if (['bluetooth', 'bluetooth_le'].includes(entity?.attributes.source_type)) {
return state === 'home' ? 'mdi:bluetooth-connect' : 'mdi:bluetooth';
}
return state === 'not_home' ? 'mdi:account-arrow-right' : 'mdi:account';
case 'humidifier':
return state && state === 'off' ? 'mdi:air-humidifier-off' : 'mdi:air-humidifier';
case 'input_boolean':
return state === 'on' ? 'mdi:check-circle-outline' : 'mdi:close-circle-outline';
case 'lock':
switch (state) {
case 'unlocked':
return 'mdi:lock-open';
case 'jammed':
return 'mdi:lock-alert';
case 'locking':
case 'unlocking':
return 'mdi:lock-clock';
default:
return 'mdi:lock';
}
case 'media_player':
return state === 'playing' ? 'mdi:cast-connected' : 'mdi:cast';
case 'switch':
switch (entity?.attributes.device_class) {
case 'outlet':
return state === 'on' ? 'mdi:power-plug' : 'mdi:power-plug-off';
case 'switch':
return state === 'on' ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off';
default:
return 'mdi:flash';
}
case 'zwave':
switch (state) {
case 'dead':
return 'mdi:emoticon-dead';
case 'sleeping':
return 'mdi:sleep';
case 'initializing':
return 'mdi:timer-sand';
default:
return 'mdi:z-wave';
}
case 'sensor': {
const icon = sensorIcon(entity);
if (icon) {
return icon;
}
break;
}
case 'input_datetime':
if (!entity?.attributes.has_date) {
return 'mdi:clock';
}
if (!entity.attributes.has_time) {
return 'mdi:calendar';
}
break;
case 'sun':
return entity?.state === 'above_horizon'
? FIXED_DOMAIN_ICONS[domain]
: 'mdi:weather-night';
case 'update':
return entity?.state === 'on'
? updateIsInstalling(entity as UpdateEntity)
? 'mdi:package-down'
: 'mdi:package-up'
: 'mdi:package';
}
if (domain in FIXED_DOMAIN_ICONS) {
return FIXED_DOMAIN_ICONS[domain];
}
console.warn(`Unable to find icon for domain ${domain}`);
return DEFAULT_DOMAIN_ICON;
}
+118
View File
@@ -0,0 +1,118 @@
import { UNIT_C, UNIT_F } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
const FIXED_DEVICE_CLASS_ICONS = {
apparent_power: 'mdi:flash',
aqi: 'mdi:air-filter',
carbon_dioxide: 'mdi:molecule-co2',
carbon_monoxide: 'mdi:molecule-co',
current: 'mdi:current-ac',
date: 'mdi:calendar',
energy: 'mdi:lightning-bolt',
frequency: 'mdi:sine-wave',
gas: 'mdi:gas-cylinder',
humidity: 'mdi:water-percent',
illuminance: 'mdi:brightness-5',
monetary: 'mdi:cash',
nitrogen_dioxide: 'mdi:molecule',
nitrogen_monoxide: 'mdi:molecule',
nitrous_oxide: 'mdi:molecule',
ozone: 'mdi:molecule',
pm1: 'mdi:molecule',
pm10: 'mdi:molecule',
pm25: 'mdi:molecule',
power: 'mdi:flash',
power_factor: 'mdi:angle-acute',
pressure: 'mdi:gauge',
reactive_power: 'mdi:flash',
signal_strength: 'mdi:wifi',
sulphur_dioxide: 'mdi:molecule',
temperature: 'mdi:thermometer',
timestamp: 'mdi:clock',
volatile_organic_compounds: 'mdi:molecule',
voltage: 'mdi:sine-wave',
};
const SENSOR_DEVICE_CLASS_BATTERY = 'battery';
const BATTERY_ICONS = {
10: 'mdi:battery-10',
20: 'mdi:battery-20',
30: 'mdi:battery-30',
40: 'mdi:battery-40',
50: 'mdi:battery-50',
60: 'mdi:battery-60',
70: 'mdi:battery-70',
80: 'mdi:battery-80',
90: 'mdi:battery-90',
100: 'mdi:battery',
};
const BATTERY_CHARGING_ICONS = {
10: 'mdi:battery-charging-10',
20: 'mdi:battery-charging-20',
30: 'mdi:battery-charging-30',
40: 'mdi:battery-charging-40',
50: 'mdi:battery-charging-50',
60: 'mdi:battery-charging-60',
70: 'mdi:battery-charging-70',
80: 'mdi:battery-charging-80',
90: 'mdi:battery-charging-90',
100: 'mdi:battery-charging',
};
const batteryStateIcon = (
batteryEntity: HassEntity,
batteryChargingEntity?: HassEntity,
) => {
const battery = batteryEntity.state;
const batteryCharging = batteryChargingEntity?.state === 'on';
return batteryIcon(battery, batteryCharging);
};
export const batteryIcon = (
batteryState: number | string,
batteryCharging?: boolean,
) => {
const batteryValue = Number(batteryState);
if (isNaN(batteryValue)) {
if (batteryState === 'off') {
return 'mdi:battery';
}
if (batteryState === 'on') {
return 'mdi:battery-alert';
}
return 'mdi:battery-unknown';
}
const batteryRound = Math.round(batteryValue / 10) * 10;
if (batteryCharging && batteryValue >= 10) {
return BATTERY_CHARGING_ICONS[batteryRound];
}
if (batteryCharging) {
return 'mdi:battery-charging-outline';
}
if (batteryValue <= 5) {
return 'mdi:battery-alert-variant-outline';
}
return BATTERY_ICONS[batteryRound];
};
export const sensorIcon = (entity?: HassEntity): string | undefined => {
const dclass = entity?.attributes.device_class;
if (dclass && dclass in FIXED_DEVICE_CLASS_ICONS) {
return FIXED_DEVICE_CLASS_ICONS[dclass];
}
if (dclass === SENSOR_DEVICE_CLASS_BATTERY) {
return entity ? batteryStateIcon(entity) : 'mdi:battery';
}
const unit = entity?.attributes.unit_of_measurement;
if (unit === UNIT_C || unit === UNIT_F) {
return 'mdi:thermometer';
}
return undefined;
};
+18
View File
@@ -0,0 +1,18 @@
import { computeDomain } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { DEFAULT_DOMAIN_ICON, domainIcon } from './domain-icon';
export function stateIcon(entity?: HassEntity | null): string {
if (!entity) {
return DEFAULT_DOMAIN_ICON;
}
if (entity.attributes.icon) {
return entity.attributes.icon;
}
const domain = computeDomain(entity.entity_id);
const state = entity.state;
return domainIcon(domain, entity, state);
}
+75
View File
@@ -0,0 +1,75 @@
import { MediaShowInfo } from '../types.js';
import { dispatchFrigateCardEvent } from './basic.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/**
* Create a MediaShowInfo object.
* @param source An event or HTMLElement that should be used as a source.
* @returns A new MediaShowInfo object or null if one could not be created.
*/
export function createMediaShowInfo(source: Event | HTMLElement): MediaShowInfo | null {
let target: HTMLElement | EventTarget;
if (source instanceof Event) {
target = source.composedPath()[0];
} else {
target = source;
}
if (target instanceof HTMLImageElement) {
return {
width: (target as HTMLImageElement).naturalWidth,
height: (target as HTMLImageElement).naturalHeight,
};
} else if (target instanceof HTMLVideoElement) {
return {
width: (target as HTMLVideoElement).videoWidth,
height: (target as HTMLVideoElement).videoHeight,
};
} else if (target instanceof HTMLCanvasElement) {
return {
width: (target as HTMLCanvasElement).width,
height: (target as HTMLCanvasElement).height,
};
}
return null;
}
/**
* Dispatch a Frigate card media show event.
* @param element The element to send the event.
* @param source An event or HTMLElement that should be used as a source.
*/
export function dispatchMediaShowEvent(
element: HTMLElement,
source: Event | HTMLElement,
): void {
const mediaShowInfo = createMediaShowInfo(source);
if (mediaShowInfo) {
dispatchExistingMediaShowInfoAsEvent(element, mediaShowInfo);
}
}
/**
* Dispatch a pre-existing MediaShowInfo object as an event.
* @param element The element to send the event.
* @param mediaShowInfo The MediaShowInfo object to send.
*/
export function dispatchExistingMediaShowInfoAsEvent(
element: HTMLElement,
mediaShowInfo: MediaShowInfo,
): void {
dispatchFrigateCardEvent<MediaShowInfo>(element, 'media-show', mediaShowInfo);
}
/**
* Determine if a MediaShowInfo object is valid/acceptable.
* @param info The MediaShowInfo object.
* @returns True if the object is valid, false otherwise.
*/
export function isValidMediaShowInfo(info: MediaShowInfo): boolean {
return (
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
);
}
+64
View File
@@ -0,0 +1,64 @@
import { HomeAssistant } from 'custom-card-helpers';
import QuickLRU from 'quick-lru';
import {
FrigateBrowseMediaSource,
ResolvedMedia,
resolvedMediaSchema
} from '../types.js';
import { homeAssistantWSRequest } from './ha';
// 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 });
}
public has(id: string): boolean {
return this._cache.has(id);
}
public get(id: string): ResolvedMedia | undefined {
return this._cache.get(id);
}
public set(id: string, resolvedMedia: ResolvedMedia): void {
this._cache.set(id, resolvedMedia);
}
}
export class ResolvedMediaUtil {
static async resolveMedia(
hass: HomeAssistant,
mediaSource?: FrigateBrowseMediaSource,
cache?: ResolvedMediaCache,
): Promise<ResolvedMedia | null> {
if (!mediaSource) {
return null;
}
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
if (cachedValue) {
return cachedValue;
}
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
const resolvedMedia = await homeAssistantWSRequest(
hass,
resolvedMediaSchema,
request,
);
if (cache && resolvedMedia) {
cache.set(mediaSource.media_content_id, resolvedMedia);
}
return resolvedMedia;
}
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod';
/**
* Recursively remove defaults from a zod schema.
*
* See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943
*
* @param schema The Zod schema.
* @returns A new Zod schema.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
if (schema instanceof z.ZodDefault) {
return deepRemoveDefaults(schema.removeDefault());
}
if (schema instanceof z.ZodObject) {
const newShape = {};
for (const key in schema.shape) {
const fieldSchema = schema.shape[key];
newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema));
}
return new z.ZodObject({
...schema._def,
shape: () => newShape,
});
}
if (schema instanceof z.ZodArray) {
return z.ZodArray.create(deepRemoveDefaults(schema.element));
}
if (schema instanceof z.ZodOptional) {
return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodNullable) {
return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodTuple) {
return z.ZodTuple.create(
schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)),
);
}
return schema;
}
/**
* Get the keys that didn't parse from a ZodError.
* @param error The zoderror to extract the keys from.
* @returns An array of error keys.
*/
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_'));
}