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
+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;