Files
advanced-camera-card/src/common.ts
T

724 lines
21 KiB
TypeScript

import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
import {
HomeAssistant,
computeStateDomain,
handleActionConfig,
hasAction,
ActionConfig,
} from 'custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema, z } from 'zod';
import {
differenceInSeconds,
differenceInMinutes,
differenceInHours,
fromUnixTime,
} from 'date-fns';
import { isEqual } from 'lodash-es';
import { localize } from './localize/localize.js';
import {
Actions,
ActionsConfig,
ActionType,
CameraConfig,
CardHelpers,
ExtendedHomeAssistant,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
FrigateEvent,
MediaShowInfo,
Message,
RawFrigateCardConfig,
SignedPath,
signedPathSchema,
StateParameters,
} from './types.js';
import { stateIcon } from './icons/state-icon.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/**
* 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('_'));
}
/**
* 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);
}
/**
* 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,
}),
);
}
/**
* 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);
}
/**
* Dispatch an event with a message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
* @param icon An optional icon to attach to the message.
*/
export function dispatchMessageEvent(
element: HTMLElement,
message: string,
icon?: string,
context?: unknown,
): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: message,
type: 'info',
icon: icon,
context: context,
});
}
/**
* Dispatch an event with an error message to show to the user.
* @param element The element to send the event.
* @param message The message to show.
*/
export function dispatchErrorMessageEvent(
element: HTMLElement,
message: string,
context?: unknown,
): void {
dispatchFrigateCardEvent<Message>(element, 'message', {
message: message,
type: 'error',
context: context,
});
}
/**
* 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;
}
/**
* 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
);
}
/**
* 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;
}
/**
* 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;
}
/**
* Prettify a Frigate name by converting '_' to spaces and capitalizing words.
* @param input The input Frigate (camera/label/zone) name.
* @returns A prettified name.
*/
export function prettifyFrigateName(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(' ');
}
/**
* 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);
}
/**
* 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'
? prettifyFrigateName(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';
}
/**
* 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);
}
/**
* 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();
};
/**
* 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;
}
/**
* 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;
};