Files
advanced-camera-card/src/utils/ha/index.ts
T

410 lines
12 KiB
TypeScript

import {
computeDomain,
computeStateDomain,
HomeAssistant,
} from '@dermotduffy/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,
FrigateCardError,
LovelaceCardWithEditor,
SignedPath,
signedPathSchema,
StateParameters,
} from '../../types.js';
import { domainIcon } from '../icons/domain-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,
passthrough = false,
): Promise<T> {
let response;
try {
response = await hass.callWS<T>(request);
} catch (e) {
if (!(e instanceof Error)) {
throw new FrigateCardError(localize('error.failed_response'), {
request: request,
response: e,
});
}
throw e;
}
if (!response) {
throw new FrigateCardError(localize('error.empty_response'), {
request: request,
});
}
// Some endpoints on the integration pass through JSON directly from Frigate
// These end up wrapped in a string and must be unwrapped first
const parseResult = passthrough
? schema.safeParse(JSON.parse(response))
: schema.safeParse(response);
if (!parseResult.success) {
throw new FrigateCardError(localize('error.invalid_response'), {
request: request,
response: response,
invalid_keys: getParseErrorKeys<T>(parseResult.error),
});
}
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);
}
export interface HassStateDifference {
entityID: string;
oldState?: HassEntity;
newState: HassEntity;
}
/**
* Get the difference between two hass objects.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @param options An options object. stateOnly: whether or not to compare state
* strings only, firstOnly: whether or not to get the first difference only.
* @returns An array of HassStateDifference objects.
*/
export function getHassDifferences(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
options?: {
firstOnly?: boolean;
stateOnly?: boolean;
},
): HassStateDifference[] {
if (!newHass || !entities || !entities.length) {
return [];
}
const differences: HassStateDifference[] = [];
for (const entity of entities) {
const oldState: HassEntity | undefined = oldHass?.states[entity];
const newState: HassEntity | undefined = newHass.states[entity];
if (
(options?.stateOnly && oldState?.state !== newState?.state) ||
(!options?.stateOnly && oldState !== newState)
) {
differences.push({
entityID: entity,
oldState: oldState,
newState: newState,
});
if (options?.firstOnly) {
break;
}
}
}
return differences;
}
/**
* Determine if two hass objects are different for a list of entities.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @param entities The entities to examine for changes.
* @param options An options object. stateOnly: whether or not to compare state strings only.
* @returns An array of HassStateDifference objects.
*/
export function isHassDifferent(
newHass: HomeAssistant | undefined | null,
oldHass: HomeAssistant | undefined | null,
entities: string[] | null,
options?: {
stateOnly?: boolean;
},
): boolean {
return !!getHassDifferences(newHass, oldHass, entities, {
...options,
firstOnly: true,
}).length;
}
/**
* 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'.
*/
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 ?? getEntityIcon(hass, params.entity);
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 entityID The entity id.
* @param hass The Home Assistant object.
* @returns The icon or undefined.
*/
export function getEntityIcon(
hass: HomeAssistant,
entityID: string,
defaultIcon?: string,
): string {
const entityState = hass.states[entityID];
if (entityState && entityState.attributes.icon) {
return entityState.attributes.icon;
}
return domainIcon(
computeDomain(entityID),
entityState,
entityState?.state,
defaultIcon,
);
}
/**
* Side loads the HA elements this card needs. This trickery is unfortunate
* necessary, see:
* - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements
* @returns `true` if the load is successful, `false` otherwise.
*/
export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
const neededElements = [
'ha-button-menu',
'ha-button',
'ha-camera-stream',
'ha-card',
'ha-circular-progress',
'ha-hls-player',
'ha-icon-button',
'ha-icon',
'ha-menu-button',
'ha-selector',
'ha-web-rtc-player',
];
if (neededElements.every((element) => customElements.get(element))) {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const helpers: CardHelpers = await (window as any).loadCardHelpers();
// This bizarre combination of hacks creates a dummy picture glance card, then
// waits for it to be fully loaded/upgraded as a custom element, so it will
// have the getConfigElement() method which is necessary to load all the
// elements this card requires.
await helpers.createCardElement({
type: 'picture-glance',
entities: [],
camera_image: 'dummy-to-load-editor-components',
});
// Some cast devices have a bug that causes whenDefined to return
// undefined instead of a constructor.
// See related: https://issues.chromium.org/issues/40846966
await customElements.whenDefined('hui-picture-glance-card');
const pgcConstructor = customElements.get('hui-picture-glance-card');
if (!pgcConstructor) {
return false;
}
const pgc = new pgcConstructor() as LovelaceCardWithEditor;
await pgc.constructor.getConfigElement();
return true;
};
/**
* Determine if a given state qualifies as 'triggered'.
* @param state The HA entity state string.
* @returns `true` if triggered, `false` otherwise.
*/
export const isTriggeredState = (state?: string): boolean => {
return !!state && ['on', 'open'].includes(state);
};
/**
* Get entities from the HASS object.
* @param hass
* @param domain
* @returns A list of entities ids.
*/
export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => {
if (!hass) {
return [];
}
const entities = Object.keys(hass.states).filter(
(eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain,
);
entities.sort();
return entities;
};
/**
* Determine if a card is in panel mode.
*/
export const isCardInPanel = (card: HTMLElement): boolean => {
const parent = card.getRootNode();
return !!(
parent &&
parent instanceof ShadowRoot &&
parent.host.tagName === 'HUI-PANEL-VIEW'
);
};
/**
* 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: ExtendedHomeAssistant, url: string): string;
export function canonicalizeHAURL(
hass: ExtendedHomeAssistant,
url?: string,
): string | null {
if (hass && url && url.startsWith('/')) {
return hass.hassUrl(url);
}
return url ?? null;
}
/**
* Determine if HA connection state has changed.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @returns `true` if the connection state has changed.
*/
export const hasHAConnectionStateChanged = (
oldHass: HomeAssistant | undefined | null,
newHass: HomeAssistant | undefined | null,
): boolean => {
return oldHass?.connected !== newHass?.connected;
};