Add initial support for automations.

This commit is contained in:
Dermot Duffy
2023-05-04 19:54:41 -07:00
parent b048524d84
commit fd74c1c855
17 changed files with 1279 additions and 470 deletions
+50
View File
@@ -0,0 +1,50 @@
import { HomeAssistant } from 'custom-card-helpers';
import { AutomationActions, FrigateCardError } from './types.js';
import { ConditionController } from './conditions.js';
import { Automation, Automations } from './types.js';
import { frigateCardHandleAction } from './utils/action.js';
import { localize } from './localize/localize.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsControllerError extends FrigateCardError {}
export class AutomationsController {
protected _automations: Automations;
protected _priorEvaluations: Map<Automation, boolean> = new Map();
// A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete.
protected _nestedAutomationExecutions = 0;
constructor(automations: Automations) {
this._automations = automations;
}
public execute(
element: HTMLElement,
hass: HomeAssistant,
conditionController: ConditionController,
): void {
const actionsToRun: AutomationActions[] = [];
for (const automation of this._automations ?? []) {
const shouldExecute = conditionController.evaluateCondition(automation.conditions);
const actions = shouldExecute ? automation.actions : automation.actions_not;
const priorEvaluation = this._priorEvaluations.get(automation);
this._priorEvaluations.set(automation, shouldExecute);
if (shouldExecute !== priorEvaluation && actions) {
actionsToRun.push(actions);
}
}
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
throw new AutomationsControllerError(localize('error.too_many_automations'));
}
actionsToRun.forEach((actions) => {
frigateCardHandleAction(element, hass, {}, actions);
});
--this._nestedAutomationExecutions;
}
}
-230
View File
@@ -1,230 +0,0 @@
import {
FrigateCardCondition,
FrigateCardConfig,
frigateConditionalSchema,
OverrideConfigurationKey,
RawFrigateCardConfig,
} from './types';
import { HassEntities } from 'home-assistant-js-websocket';
import merge from 'lodash-es/merge';
import { copyConfig } from './config-mgmt';
export interface ConditionState {
view?: string;
fullscreen?: boolean;
expand?: boolean;
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
}
class ConditionStateRequestEvent extends Event {
public conditionState?: ConditionState;
}
function evaluateCondition(
condition?: Readonly<FrigateCardCondition>,
state?: Readonly<ConditionState>,
): boolean {
if (!state) {
return false;
}
let result = true;
if (condition?.view?.length) {
result &&= !!state.view && condition.view.includes(state.view);
}
if (condition?.fullscreen !== undefined) {
result &&=
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
}
if (condition?.expand !== undefined) {
result &&=
state.expand !== undefined && condition.expand == state.expand;
}
if (condition?.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera);
}
if (condition?.state?.length) {
for (const stateTest of condition?.state) {
result &&=
!!state.state &&
((!stateTest.state && !stateTest.state_not) ||
(stateTest.entity in state.state &&
(!stateTest.state ||
state.state[stateTest.entity].state === stateTest.state) &&
(!stateTest.state_not ||
state.state[stateTest.entity].state !== stateTest.state_not)));
}
}
if (condition?.media_loaded !== undefined) {
result &&=
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
}
if (condition?.media_query) {
result &&= window.matchMedia(condition.media_query).matches;
}
return result;
}
/**
* Evaluate whether a frigateCardCondition is met using an event to fetch state.
* @returns A boolean indicating whether the condition is met.
*/
export function fetchStateAndEvaluateCondition(
element: HTMLElement,
condition?: FrigateCardCondition,
): boolean {
if (!condition) {
return true;
}
const stateEvent = new ConditionStateRequestEvent(
`frigate-card:condition-state-request`,
{
bubbles: true,
composed: true,
},
);
/* Special note on what's going on here:
*
* Some parts of the card (e.g. <frigate-card-elements>) may have arbitrary
* complexity and layers (that this card doesn't control) between that master
* element and the element that needs to evaluate the condition. In these
* cases there's no clean way to pass state from the rest of card down
* through these layers. Instead, an event is dispatched as a "request for
* state" (StateRequestEvent) upwards which is caught by the outer card
* and state added to the event object. Because event propagation is handled
* synchronously, the state will be added to the event before the flow
* proceeds.
*/
element.dispatchEvent(stateEvent);
return evaluateCondition(condition, stateEvent.conditionState);
}
export function conditionStateRequestHandler(
ev: ConditionStateRequestEvent,
conditionState?: ConditionState,
): void {
ev.conditionState = conditionState;
}
type RawOverrides = {
conditions: FrigateCardCondition;
overrides: RawFrigateCardConfig;
}[];
export function getOverriddenConfig(
config: Readonly<RawFrigateCardConfig>,
overrides: Readonly<RawOverrides> | undefined,
conditionState?: Readonly<ConditionState>,
): RawFrigateCardConfig {
const output = copyConfig(config);
let overridden = false;
if (overrides) {
for (const override of overrides) {
if (evaluateCondition(override.conditions, conditionState)) {
merge(output, override.overrides);
overridden = true;
}
}
}
// Attempt to return the same configuration object if it has not been
// overridden (to reduce re-renders for a configuration that has not changed).
return overridden ? output : config;
}
export function getOverridesByKey(
overrides: Readonly<RawOverrides> | undefined,
key: OverrideConfigurationKey,
): RawOverrides {
return (
overrides
?.filter((o) => key in o.overrides)
.map((o) => ({
conditions: o.conditions,
overrides: o.overrides[key] as RawFrigateCardConfig,
})) ?? []
);
}
export class CardConditionManager {
// Whether or not to include HA state in ConditionState. Doing so increases
// CPU usage as HA state is pumped out very fast, so this is only enabled if
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _callback: () => void;
protected _mediaQueries: MediaQueryList[] = [];
protected _boundTriggerChange = this._triggerChange.bind(this);
constructor(config: FrigateCardConfig, callback: () => void) {
this._initConditions(config);
this._callback = callback;
}
/**
* Destroy the object.
*/
public destroy(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._boundTriggerChange),
);
this._mediaQueries = [];
}
/**
* Determine if the conditions have state conditions.
*/
get hasHAStateConditions(): boolean {
return this._hasHAStateConditions;
}
/**
* Trigger the callback.
* @param _ Ignored parameter.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected _triggerChange(_: MediaQueryListEvent): void {
this._callback();
}
/**
* Init the conditions.
* @param config The card configuration.
*/
protected _initConditions(config: FrigateCardConfig): void {
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
const conditions: FrigateCardCondition[] = [];
config.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions(config);
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._boundTriggerChange);
this._mediaQueries.push(mql);
}
});
}
}
+133 -87
View File
@@ -23,21 +23,20 @@ import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { CameraManager } from './camera-manager/manager.js';
import {
CardConditionManager,
ConditionState,
conditionStateRequestHandler,
getOverriddenConfig,
} from './card-condition.js';
import './components/elements.js';
import { FrigateCardElements } from './components/elements.js';
import './components/menu.js';
import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON } from './components/menu.js';
import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js';
import './components/message.js';
import { renderMessage, renderProgressIndicator } from './components/message.js';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { FrigateCardViews } from './components/views.js';
import {
ConditionController,
ConditionEvaluateRequestEvent,
getOverriddenConfig,
} from './conditions.js';
import { isConfigUpgradeable } from './config-mgmt.js';
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
import { getLanguage, loadLanguages, localize } from './localize/localize.js';
@@ -45,17 +44,17 @@ import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance
import cardStyle from './scss/card.scss';
import {
Actions,
ActionType,
ActionsConfig,
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FRIGATE_CARD_VIEW_DEFAULT,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
frigateCardConfigSchema,
FrigateCardCustomAction,
FrigateCardError,
FrigateCardView,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FRIGATE_CARD_VIEW_DEFAULT,
MediaLoadedInfo,
MenuButton,
Message,
@@ -65,7 +64,7 @@ import {
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
} from './utils/action.js';
@@ -93,6 +92,7 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { MicrophoneController } from './utils/microphone';
import { getActionsFromQueryString } from './utils/querystring.js';
import { View } from './view/view.js';
import { AutomationsController } from './automations';
/** A note on media callbacks:
*
@@ -113,11 +113,11 @@ import { View } from './view/view.js';
/** A note on action/menu/ll-custom events:
*
* The card supports actions being configured in a number of places (e.g. tap on an
* element, double_tap on a menu item, hold on the live view). These actions are
* handled frigateCardHandleAction(). For Frigate-card specific actions,
* frigateCardHandleAction() call will result in an ll-custom DOM event being
* fired, which needs to be caught at the card level to handle.
* The card supports actions being configured in a number of places (e.g. tap on
* an element, double_tap on a menu item, hold on the live view). These actions
* are handled by frigateCardHandleActionConfig(). For Frigate-card specific
* actions, the frigateCardHandleActionConfig() call will result in an ll-custom
* DOM event being fired, which needs to be caught at the card level to handle.
*/
/* eslint no-console: 0 */
@@ -180,10 +180,9 @@ class FrigateCard extends LitElement {
@state()
protected _expand?: boolean = false;
// null implies the user refused microphone access.
protected _microphoneController?: MicrophoneController;
protected _conditionState?: ConditionState;
protected _conditionController?: ConditionController;
protected _automationsController?: AutomationsController;
protected _refMenu: Ref<FrigateCardMenu> = createRef();
protected _refMain: Ref<HTMLElement> = createRef();
@@ -217,12 +216,12 @@ class FrigateCard extends LitElement {
// The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons.
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
protected _boundCardActionEventHandler = this._cardActionEventHandler.bind(this);
protected _boundFullscreenHandler = this._fullscreenHandler.bind(this);
protected _triggers: Map<string, Date> = new Map();
protected _untriggerTimerID: number | null = null;
protected _conditionManager: CardConditionManager | null = null;
protected _mediaPlayers?: string[];
protected _initializer = new FrigateCardInitializer();
@@ -253,9 +252,8 @@ class FrigateCard extends LitElement {
}
}
if (this._conditionManager?.hasHAStateConditions) {
// HA entity state is part of the condition state.
this._generateConditionState();
if (this._conditionController?.hasHAStateConditions) {
this._conditionController.setState({ state: this._hass.states });
}
// Dark mode may depend on HASS.
@@ -294,36 +292,30 @@ class FrigateCard extends LitElement {
} as unknown as FrigateCardConfig;
}
/**
* Generate the state used to evaluate conditions.
*/
protected _generateConditionState(): void {
this._conditionState = {
view: this._view?.view,
fullscreen: screenfull.isEnabled && screenfull.isFullscreen,
expand: this._expand,
camera: this._view?.camera,
media_loaded: !!this._currentMediaLoadedInfo,
...(this._conditionManager?.hasHAStateConditions && {
state: this._hass?.states,
}),
};
// Update the components that need the new condition state. Passed directly
// to them to avoid the performance hit of a entire card re-render (esp.
// when using card-mod).
protected _requestUpdateForComponentsThatUseConditions(): void {
// Update the components that need to know about condition changes. Trigger
// updates directly on them to them to avoid the performance hit of a entire
// card re-render (esp. when using card-mod).
// https://github.com/dermotduffy/frigate-hass-card/issues/678
if (this._refViews.value) {
this._refViews.value.conditionState = this._conditionState;
this._refViews.value.conditionControllerEpoch =
this._conditionController?.getEpoch();
}
if (this._refElements.value) {
this._refElements.value.conditionState = this._conditionState;
this._refElements.value.conditionControllerEpoch =
this._conditionController?.getEpoch();
}
}
protected _overrideConfig(): void {
if (!this._conditionController) {
return;
}
const overriddenConfig = getOverriddenConfig(
this._conditionController,
this._config,
this._config.overrides,
this._conditionState,
) as FrigateCardConfig;
// Save on Lit re-rendering costs by only updating the configuration if it
@@ -341,6 +333,14 @@ class FrigateCard extends LitElement {
}
}
// protected _runAutomations(): void {
// // this._getConfig().automations?.forEach((automation) => {
// // if (this._hass && evaluateCondition(automation.conditions, this._conditionState)) {
// // frigateCardHandleAction(this, this._hass, {}, automation.actions);
// // }
// // });
// }
/**
* Get the style of emphasized menu items.
* @returns A StyleInfo.
@@ -858,18 +858,44 @@ class FrigateCard extends LitElement {
this._view = undefined;
this._message = null;
this._conditionManager?.destroy();
this._conditionManager = new CardConditionManager(
config,
this._generateConditionState.bind(this),
);
this._setupConditionController();
this._conditionController?.setState({
view: undefined,
camera: undefined,
});
this._generateConditionState();
this._automationsController = new AutomationsController(this._config.automations);
this._setLightOrDarkMode();
this._setPropertiesForMinMaxHeight();
this._untrigger();
}
protected _setupConditionController(): void {
this._conditionController?.destroy();
this._conditionController = new ConditionController(this._config);
this._conditionController.addStateListener(this._overrideConfig.bind(this));
this._conditionController.addStateListener(
this._requestUpdateForComponentsThatUseConditions.bind(this),
);
this._conditionController.addStateListener(this._executeAutomations.bind(this));
}
protected _executeAutomations(): void {
// Never execute automations if there's an error (as our automation loop
// avoidance -- which shows as an error -- does not work).
if (this._message?.type !== 'error' && this._hass && this._conditionController) {
try {
this._automationsController?.execute(
this,
this._hass,
this._conditionController,
);
} catch (e: unknown) {
this._handleThrownError(e);
}
}
}
/**
* Card the card config, prioritizing the overriden config if present.
* @returns A FrigateCardConfig.
@@ -891,7 +917,10 @@ class FrigateCard extends LitElement {
View.adoptFromViewIfAppropriate(view, this._view);
this._view = view;
this._generateConditionState();
this._conditionController?.setState({
view: this._view.view,
camera: this._view.camera,
});
};
if (args?.resetMessage ?? true) {
@@ -1435,12 +1464,18 @@ class FrigateCard extends LitElement {
}
}
protected _cardActionEventHandler(ev: CustomEvent<ActionType>): void {
protected _cardActionEventHandler(ev: Event): void {
// The event may not actually be a CustomEvent object, but may still have a
// detail field (see:
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
// )
if ('detail' in ev) {
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
if (frigateCardAction) {
this._cardActionHandler(frigateCardAction);
}
}
}
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
if (!this._view) {
@@ -1490,7 +1525,7 @@ class FrigateCard extends LitElement {
this._setExpand(!this._expand);
break;
case 'fullscreen':
this._toggleFullscreen();
screenfull.toggle(this);
break;
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
@@ -1542,7 +1577,7 @@ class FrigateCard extends LitElement {
const unmuteAndUpdate = () => {
this._microphoneController?.unmute();
this.requestUpdate();
}
};
if (
!this._microphoneController?.isConnected() &&
!this._microphoneController?.isForbidden()
@@ -1656,23 +1691,26 @@ class FrigateCard extends LitElement {
* Handle an action called on an element.
* @param ev The actionHandler event.
*/
protected _actionHandler(ev: CustomEvent, config?: Actions): void {
protected _actionHandler(ev: CustomEvent, config?: ActionsConfig): void {
const interaction = ev.detail.action;
const node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
const actionConfig = getActionConfigGivenAction(interaction, config);
if (
this._hass &&
config &&
node &&
interaction &&
// Don't call frigateCardHandleAction() unless there is explicitly an
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
// action defined (as it uses a default that is unhelpful for views that
// have default tap/click actions).
getActionConfigGivenAction(interaction, config)
actionConfig
) {
frigateCardHandleAction(
frigateCardHandleActionConfig(
node,
this._hass as HomeAssistant,
this._hass,
config,
ev.detail.action,
actionConfig,
);
}
@@ -1828,8 +1866,10 @@ class FrigateCard extends LitElement {
this._setPropertiesForExpandedMode();
// An update may be required to draw elements.
this._generateConditionState();
this._conditionController?.setState({
media_loaded: !!this._currentMediaLoadedInfo,
});
this.requestUpdate();
}
@@ -1860,18 +1900,7 @@ class FrigateCard extends LitElement {
*/
protected _mediaUnloadedHandler(): void {
this._currentMediaLoadedInfo = null;
this._generateConditionState();
}
/**
* Handler called when fullscreen is toggled.
*/
protected _fullscreenHandler(): void {
this._generateConditionState();
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this.requestUpdate();
this._conditionController?.setState({ media_loaded: false });
}
/**
@@ -1880,9 +1909,10 @@ class FrigateCard extends LitElement {
connectedCallback(): void {
super.connectedCallback();
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler.bind(this));
screenfull.on('change', this._boundFullscreenHandler);
}
this.addEventListener('mousemove', this._boundMouseHandler);
this.addEventListener('ll-custom', this._boundCardActionEventHandler);
this._panel = isCardInPanel(this);
}
@@ -1894,9 +1924,10 @@ class FrigateCard extends LitElement {
this._mediaUnloadedHandler();
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler.bind(this));
screenfull.off('change', this._boundFullscreenHandler);
}
this.removeEventListener('mousemove', this._boundMouseHandler);
this.removeEventListener('ll-custom', this._boundCardActionEventHandler);
super.disconnectedCallback();
}
@@ -1983,22 +2014,38 @@ class FrigateCard extends LitElement {
return { ...this._getConfig().view.actions, ...specificActions };
}
protected _isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
protected _setExpand(expand: boolean): void {
if (screenfull.isEnabled && screenfull.isFullscreen) {
if (expand && this._isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
screenfull.exit();
}
this._expand = expand;
this._generateConditionState();
this._conditionController?.setState({
expand: this._expand,
});
}
protected _toggleFullscreen(): void {
if (screenfull.isEnabled) {
// Fullscreen and expanded mode are mutually exclusive.
protected _fullscreenHandler(): void {
const inFullscreen = screenfull.isEnabled && screenfull.isFullscreen;
if (inFullscreen) {
this._expand = false;
screenfull.toggle(this);
}
this._conditionController?.setState({
fullscreen: inFullscreen,
expand: this._expand,
});
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this.requestUpdate();
}
protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void {
@@ -2059,7 +2106,6 @@ class FrigateCard extends LitElement {
class="${classMap(cardClasses)}"
style="${styleMap(cardStyle)}"
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
@ll-custom=${this._cardActionEventHandler.bind(this)}
@frigate-card:message=${this._messageHandler.bind(this)}
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
@@ -2082,7 +2128,7 @@ class FrigateCard extends LitElement {
.resolvedMediaCache=${this._resolvedMediaCache}
.config=${this._getConfig()}
.nonOverriddenConfig=${this._config}
.conditionState=${this._conditionState}
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
.hide=${!!this._message}
.microphoneStream=${this._microphoneController?.getStream()}
></frigate-card-views>`}
@@ -2100,15 +2146,15 @@ class FrigateCard extends LitElement {
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._getConfig().elements}
.conditionState=${this._conditionState}
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
@frigate-card:menu-add=${(e) => {
this._addDynamicMenuButton(e.detail);
}}
@frigate-card:menu-remove=${(e) => {
this._removeDynamicMenuButton(e.detail);
}}
@frigate-card:condition-state-request=${(ev) => {
conditionStateRequestHandler(ev, this._conditionState);
@frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
ev.evaluation = this._conditionController?.evaluateCondition(ev.condition);
}}
>
</frigate-card-elements>`
+9 -20
View File
@@ -8,7 +8,6 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
import ptzStyle from '../scss/elements-ptz.scss';
@@ -34,6 +33,7 @@ import {
getActionConfigGivenAction,
} from '../utils/action.js';
import { classMap } from 'lit/directives/class-map.js';
import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js';
/* A note on picture element rendering:
*
@@ -81,11 +81,11 @@ export class FrigateCardElementsCore extends LitElement {
public elements: PictureElements;
/**
* Need to ensure card re-renders when conditionState changes, hence having it
* as a property even though it is not currently directly used by this class.
* Need to ensure card re-renders when conditions change, hence having it as a
* property even though it is not currently directly used by this class.
*/
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
protected _root: HuiConditionalElement | null = null;
@@ -167,7 +167,7 @@ export class FrigateCardElements extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public elements: PictureElements;
@@ -210,9 +210,6 @@ export class FrigateCardElements extends LitElement {
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
/**
* Connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
@@ -221,30 +218,20 @@ export class FrigateCardElements extends LitElement {
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
}
/**
* Disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
super.disconnectedCallback();
}
/**
* Render the template.
* @returns A rendered template.
*/
protected render(): TemplateResult {
return html`<frigate-card-elements-core
.hass=${this.hass}
.conditionState=${this.conditionState}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.elements=${this.elements}
>
</frigate-card-elements-core>`;
}
/**
* Get styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(elementsStyle);
}
@@ -259,6 +246,8 @@ export class FrigateCardElements extends LitElement {
export class FrigateCardElementsConditional extends LitElement {
protected _config?: FrigateConditional;
// A note on hass as an update mechanism:
//
// Every set of hass is treated as a reason to re-evaluate. Given that this
// node may be buried down the DOM (as a descendent of non-Frigate card
// elements), the hass object is used as the (only) trigger for condition
@@ -298,7 +287,7 @@ export class FrigateCardElementsConditional extends LitElement {
* Render the card.
*/
protected render(): TemplateResult | void {
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
if (evaluateConditionViaEvent(this, this._config.conditions)) {
return html` <frigate-card-elements-core
.hass=${this.hass}
.elements=${this._config.elements}
+28 -37
View File
@@ -1,5 +1,7 @@
import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { HassEntity } from 'home-assistant-js-websocket';
import {
CSSResultGroup,
html,
@@ -9,14 +11,17 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { keyed } from 'lit/directives/keyed.js';
import { ConditionState, getOverriddenConfig } from '../../card-condition.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
import { localize } from '../../localize/localize.js';
import liveStyle from '../../scss/live.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss';
import liveStyle from '../../scss/live.scss';
import {
CameraConfig,
CardWideConfig,
@@ -36,27 +41,22 @@ import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
wrapMediaUnloadedEventForCarousel,
} from '../media-carousel.js';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
import '../next-prev-control.js';
import '../title-control.js';
import '../surround.js';
import '../title-control.js';
import '../zoomer.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
import { Lazyload } from './../embla-plugins/lazyload.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
@@ -118,7 +118,7 @@ export const getStateObjOrDispatchError = (
@customElement('frigate-card-live')
export class FrigateCardLive extends LitElement {
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -250,7 +250,7 @@ export class FrigateCardLive extends LitElement {
.view=${this.view}
.liveConfig=${this.liveConfig}
.inBackground=${this._inBackground}
.conditionState=${this.conditionState}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
@@ -311,7 +311,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public inBackground?: boolean;
@property({ attribute: false })
public conditionState?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -525,21 +525,18 @@ export class FrigateCardLiveCarousel extends LitElement {
cameraConfig: CameraConfig,
slideIndex: number,
): TemplateResult | void {
if (!this.liveConfig || !this.hass || !this.cameraManager) {
if (!this.liveConfig || !this.hass || !this.cameraManager || !this.conditionControllerEpoch) {
return;
}
// The conditionState object contains the currently live camera, which (in
// the carousel for example) is not necessarily the live camera this
// <frigate-card-live-provider> is rendering right now.
const conditionState = {
...this.conditionState,
camera: cameraID,
};
// The condition controller object contains the currently live camera, which
// (in the carousel for example) is not necessarily the live camera *this*
// <frigate-card-live-provider> is rendering right now, so we provide a
// stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
this.conditionControllerEpoch.controller,
this.liveConfig,
this.liveOverrides,
conditionState,
{ camera: cameraID },
) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
@@ -603,12 +600,6 @@ export class FrigateCardLiveCarousel extends LitElement {
return;
}
const config = getOverriddenConfig(
this.liveConfig,
this.liveOverrides,
this.conditionState,
) as LiveConfig;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
@@ -651,7 +642,7 @@ export class FrigateCardLiveCarousel extends LitElement {
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${config.controls.title}
.titlePopupConfig=${this.liveConfig.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@@ -664,7 +655,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${config.controls.next_previous}
.controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@@ -679,7 +670,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${config.controls.next_previous}
.controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
+4 -7
View File
@@ -9,7 +9,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionState, getOverridesByKey } from '../card-condition';
import { ConditionControllerEpoch, getOverridesByKey } from '../conditions';
import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
@@ -40,10 +40,7 @@ export class FrigateCardViews extends LitElement {
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public conditionState?: ConditionState;
@property({ attribute: false })
public cameras?: ConditionState;
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hide?: boolean;
@@ -203,8 +200,8 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
.conditionState=${this.conditionState}
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneStream=${this.microphoneStream}
+251
View File
@@ -0,0 +1,251 @@
import { HassEntities } from 'home-assistant-js-websocket';
import merge from 'lodash-es/merge';
import { copyConfig } from './config-mgmt';
import {
FrigateCardCondition,
FrigateCardConfig,
frigateConditionalSchema,
OverrideConfigurationKey,
RawFrigateCardConfig,
} from './types';
interface ConditionState {
view?: string;
fullscreen?: boolean;
expand?: boolean;
camera?: string;
state?: HassEntities;
media_loaded?: boolean;
}
export class ConditionEvaluateRequestEvent extends Event {
public condition: FrigateCardCondition;
public evaluation?: boolean;
constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
super('frigate-card:condition:evaluate', eventInitDict);
this.condition = condition;
}
}
/**
* Evaluate whether a frigateCardCondition is met using an event to evaluate.
* @returns A boolean indicating whether the condition is met.
*/
export function evaluateConditionViaEvent(
element: HTMLElement,
condition?: FrigateCardCondition,
): boolean {
if (!condition) {
return true;
}
const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
bubbles: true,
composed: true,
});
/* Special note on what's going on here:
*
* Some parts of the card (e.g. <frigate-card-elements>) may have arbitrary
* complexity and layers (that this card doesn't control) between that master
* element and the element that needs to evaluate the condition. In these
* cases there's no clean way to pass state from the rest of card down through
* these layers. Instead, an event is dispatched as a "request for evaluation"
* (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
* and the evaluation result is added to the event object. Because event
* propagation is handled synchronously, the result will be added to the event
* before the flow proceeds.
*/
element.dispatchEvent(evaluateEvent);
return evaluateEvent.evaluation ?? false;
}
type RawOverrides = {
conditions: FrigateCardCondition;
overrides: RawFrigateCardConfig;
}[];
export function getOverriddenConfig(
controller: Readonly<ConditionController>,
config: Readonly<RawFrigateCardConfig>,
configOverrides?: Readonly<RawOverrides>,
stateOverrides?: Partial<ConditionState>,
): RawFrigateCardConfig {
const output = copyConfig(config);
let overridden = false;
if (configOverrides) {
for (const override of configOverrides) {
if (controller.evaluateCondition(override.conditions, stateOverrides)) {
merge(output, override.overrides);
overridden = true;
}
}
}
// Attempt to return the same configuration object if it has not been
// overridden (to reduce re-renders for a configuration that has not changed).
return overridden ? output : config;
}
export function getOverridesByKey(
key: OverrideConfigurationKey,
overrides?: Readonly<RawOverrides>,
): RawOverrides {
return (
overrides
?.filter((o) => key in o.overrides)
.map((o) => ({
conditions: o.conditions,
overrides: o.overrides[key] as RawFrigateCardConfig,
})) ?? []
);
}
// A tiny wrapper interface to allow the same controller to be passed around
// immutably within objects that will not be equal (===). Every state change
// generates a new epoch. This is used for Lit rendering to ensure changes to
// condition state are recognized as changes even though the controller is the
// same.
export interface ConditionControllerEpoch {
controller: Readonly<ConditionController>;
}
export class ConditionController {
protected _state: ConditionState = {};
protected _epoch: ConditionControllerEpoch = this._createEpoch();
protected _stateListeners: (() => void)[] = [];
// Whether or not to include HA state in ConditionState. Doing so increases
// CPU usage as HA state is pumped out very fast, so this is only enabled if
// the configuration needs to consume it.
protected _hasHAStateConditions = false;
protected _mediaQueries: MediaQueryList[] = [];
protected _mediaQueryTrigger = () => this._triggerChange();
constructor(config?: FrigateCardConfig) {
if (config) {
this._initConditions(config);
}
}
public addStateListener(callback: () => void): void {
this._stateListeners.push(callback);
}
public removeStateListener(callback: () => void): void {
this._stateListeners = this._stateListeners.filter(
(listener) => listener != callback,
);
}
public destroy(): void {
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryTrigger),
);
this._mediaQueries = [];
}
public setState(state: Partial<ConditionState>): void {
this._state = {
...this._state,
...state,
};
this._triggerChange();
}
get hasHAStateConditions(): boolean {
return this._hasHAStateConditions;
}
public getEpoch(): ConditionControllerEpoch {
return this._epoch;
}
public evaluateCondition(
condition: Readonly<FrigateCardCondition>,
stateOverrides?: Partial<ConditionState>,
): boolean {
const state = {
...this._state,
...stateOverrides,
};
let result = true;
if (condition.view?.length) {
result &&= !!state?.view && condition.view.includes(state.view);
}
if (condition.fullscreen !== undefined) {
result &&=
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
}
if (condition.expand !== undefined) {
result &&= state.expand !== undefined && condition.expand == state.expand;
}
if (condition.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera);
}
if (condition.state?.length) {
for (const stateTest of condition.state) {
result &&=
!!state.state &&
((!stateTest.state && !stateTest.state_not) ||
(stateTest.entity in state.state &&
(!stateTest.state ||
state.state[stateTest.entity].state === stateTest.state) &&
(!stateTest.state_not ||
state.state[stateTest.entity].state !== stateTest.state_not)));
}
}
if (condition.media_loaded !== undefined) {
result &&=
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
}
if (condition.media_query) {
result &&= window.matchMedia(condition.media_query).matches;
}
return result;
}
protected _createEpoch(): ConditionControllerEpoch {
return { controller: this };
}
protected _triggerChange(): void {
this._epoch = this._createEpoch();
this._stateListeners.forEach((listener) => listener());
}
protected _initConditions(config: FrigateCardConfig): void {
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
const conditions: FrigateCardCondition[] = [];
config.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions(config);
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._mediaQueryTrigger);
this._mediaQueries.push(mql);
}
});
}
}
+1
View File
@@ -423,6 +423,7 @@
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting",
"timeline_no_cameras": "No Frigate cameras to show in timeline",
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"unknown": "Unknown error",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
+1
View File
@@ -416,6 +416,7 @@
"reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi",
"too_many_automations": "",
"unknown": "Errore sconosciuto",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
+1
View File
@@ -422,6 +422,7 @@
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"too_many_automations": "",
"troubleshooting": "Verifique a solução de problemas",
"unknown": "Erro desconhecido",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
+16 -2
View File
@@ -263,7 +263,7 @@ export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSche
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
// transform on the discriminated union key.
const actionSchema = z.union([
export const actionSchema = z.union([
toggleActionSchema,
callServiceActionSchema,
navigateActionSchema,
@@ -632,7 +632,7 @@ export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
const frigateCardConditionSchema = z.object({
export const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
expand: z.boolean().optional(),
@@ -1316,6 +1316,19 @@ const liveOverridesSchema = z
.optional();
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
const automationActionSchema = actionSchema.array().optional();
export type AutomationActions = z.infer<typeof automationActionSchema>;
const automationSchema = z.object({
conditions: frigateCardConditionSchema,
actions: automationActionSchema,
actions_not: automationActionSchema,
});
export type Automation = z.infer<typeof automationSchema>;
export const automationsSchema = automationSchema.array().optional();
export type Automations = z.infer<typeof automationsSchema>;
const performanceConfigDefault = {
profile: 'high' as const,
features: {
@@ -1392,6 +1405,7 @@ export const frigateCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
automations: automationsSchema,
// Configuration overrides.
overrides: overridesSchema,
+21 -35
View File
@@ -6,7 +6,6 @@ import {
} from 'custom-card-helpers';
import {
Actions,
ActionsConfig,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
...(args.cardID && { card_id: args.cardID})
...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'media_player') {
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
...(args.cardID && { card_id: args.cardID})
...(args.cardID && { card_id: args.cardID }),
};
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
...(args?.cardID && { card_id: args.cardID})
...(args?.cardID && { card_id: args.cardID }),
};
}
@@ -107,29 +106,6 @@ export function getActionConfigGivenAction(
* 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')
@@ -143,12 +119,27 @@ export const frigateCardHandleActionConfig = (
entity?: string;
},
action: string,
actionConfig: ActionType | ActionType[] | undefined,
actionConfig?: ActionType | ActionType[],
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
frigateCardHandleAction(node, hass, config, actionConfig);
return true;
}
return false;
};
export const frigateCardHandleAction = (
node: HTMLElement,
hass: HomeAssistant,
config: {
camera_image?: string;
entity?: string;
},
actionConfig: ActionType | ActionType[] | undefined,
): void => {
// ActionConfig vs ActionType:
// There is a slight typing (but not functional) difference between
// * 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.
@@ -159,9 +150,6 @@ export const frigateCardHandleActionConfig = (
} else {
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
}
return true;
}
return false;
};
/**
@@ -170,9 +158,7 @@ export const frigateCardHandleActionConfig = (
* @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 => {
export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is
// necessary and harmless.
if (Array.isArray(config)) {
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { AutomationsController, AutomationsControllerError } from '../src/automations';
import { ConditionController } from '../src/conditions';
import { automationsSchema, FrigateCardError } from '../src/types';
import { frigateCardHandleAction } from '../src/utils/action.js';
import { createHASS } from './test-utils';
vi.mock('../src/utils/action.js');
describe('AutomationsController', () => {
const actions = [
{
action: 'custom:frigate-card-action',
frigate_card_action: 'clips',
},
];
const conditions = { fullscreen: true };
afterEach(() => {
vi.clearAllMocks();
});
it('should do nothing without automations', () => {
const automationController = new AutomationsController(undefined);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).not.toBeCalled();
});
it('should execute actions', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).not.toBeCalled();
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
// Automation will not re-fire when condition continues to evaluate the
// same.
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: false });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(2);
});
it('should execute actions_not', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions_not: actions,
},
]);
const automationController = new AutomationsController(automations);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).toBeCalled();
});
it('should prevent automation loops', () => {
const automations = automationsSchema.parse([
{
conditions: { fullscreen: true },
actions: actions,
},
{
conditions: { fullscreen: false },
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
// Create a setup where one automation action causes another...
let fullscreen = true;
vi.mocked(frigateCardHandleAction).mockImplementation(() => {
fullscreen = !fullscreen;
conditionController.setState({ fullscreen: fullscreen });
automationController.execute(element, hass, conditionController);
});
conditionController.setState({ fullscreen: fullscreen });
expect(() =>
automationController.execute(element, hass, conditionController),
).toThrowError(/Too many nested automation calls/);
expect(frigateCardHandleAction).toBeCalledTimes(10);
});
it('should be able to construct error', () => {
const error = new AutomationsControllerError('message');
expect(error).toBeTruthy();
expect(error instanceof FrigateCardError).toBeTruthy();
});
});
+14 -39
View File
@@ -1,17 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { Engine } from '../../src/camera-manager/types.js';
import { CardWideConfig } from '../../src/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraConfig, cameraConfigSchema, CardWideConfig } from '../../src/types.js';
import { HomeAssistant } from 'custom-card-helpers';
import { Engine } from '../../src/camera-manager/types.js';
import { Entity } from '../../src/utils/ha/entity-registry/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { HassEntities } from 'home-assistant-js-websocket';
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache');
@@ -28,32 +25,6 @@ const createFactory = (options?: {
);
};
const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
return cameraConfigSchema.parse(config);
};
const createHASS = (states?: HassEntities): HomeAssistant => {
const hass = mock<HomeAssistant>();
if (states) {
hass.states = states;
}
return hass;
};
const createEntity = (entity: Partial<Entity>): Entity => {
return {
...entity,
config_entry_id: entity.config_entry_id ?? null,
device_id: entity.device_id ?? null,
disabled_by: entity.disabled_by ?? null,
entity_id: entity.entity_id ?? 'entity_id',
hidden_by: entity.hidden_by ?? null,
platform: entity.platform ?? 'platform',
translation_key: entity.translation_key ?? null,
unique_id: entity.unique_id ?? 'unique_id',
};
};
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
it('should get frigate engine from config', async () => {
const config = createCameraConfig({ engine: 'frigate' });
@@ -79,7 +50,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
);
expect(
await createFactory({
@@ -94,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
);
expect(
@@ -109,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
);
expect(
await createFactory({
+350
View File
@@ -0,0 +1,350 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import {
ConditionController,
ConditionEvaluateRequestEvent,
evaluateConditionViaEvent,
getOverriddenConfig,
getOverridesByKey,
} from '../src/conditions';
import { createCondition, createConfig, createStateEntity } from './test-utils';
// @vitest-environment jsdom
describe('ConditionEvaluateRequestEvent', () => {
it('should construct', () => {
const condition = createCondition({ fullscreen: true });
const event = new ConditionEvaluateRequestEvent(condition, {
bubbles: true,
composed: true,
});
expect(event.type).toBe('frigate-card:condition:evaluate');
expect(event.condition).toBe(condition);
expect(event.bubbles).toBeTruthy();
expect(event.composed).toBeTruthy();
});
});
describe('evaluateConditionViaEvent', () => {
it('should evaluate true without condition', () => {
const element = document.createElement('div');
expect(evaluateConditionViaEvent(element)).toBeTruthy();
});
it('should dispatch event with condition and evaluate true', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
expect(ev.condition).toBe(condition);
ev.evaluation = true;
});
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeTruthy();
expect(handler).toBeCalled();
});
it('should dispatch event with condition and evaluate false', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
expect(ev.condition).toBe(condition);
ev.evaluation = false;
});
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
expect(handler).toBeCalled();
});
it('should dispatch event evaluate false if no evaluation', () => {
const element = document.createElement('div');
const condition = createCondition({ fullscreen: true });
const handler = vi.fn();
element.addEventListener('frigate-card:condition:evaluate', handler);
expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
expect(handler).toBeCalled();
});
});
describe('getOverriddenConfig', () => {
const config = {
menu: {
style: 'none',
},
};
const overrides = [
{
overrides: {
menu: {
style: 'above',
},
},
conditions: {
fullscreen: true,
},
},
];
it('should not override config', () => {
const controller = new ConditionController();
expect(getOverriddenConfig(controller, config, overrides)).toBe(config);
});
it('should override config', () => {
const controller = new ConditionController();
controller.setState({ fullscreen: true });
expect(getOverriddenConfig(controller, config, overrides)).toEqual({
menu: {
style: 'above',
},
});
});
});
describe('getOverridesByKey', () => {
const condition = {
fullscreen: true,
};
const override = {
menu: {
style: 'above',
},
};
const overrides = [
{
overrides: override,
conditions: condition,
},
];
it('should get overrides', () => {
expect(getOverridesByKey('menu', overrides)).toEqual([
{ conditions: condition, overrides: { style: 'above' } },
]);
});
it('should get no overrides', () => {
expect(getOverridesByKey('live', overrides)).toEqual([]);
});
it('should get no overrides when undefined', () => {
expect(getOverridesByKey('live')).toEqual([]);
});
});
describe('ConditionController', () => {
const config = {
type: 'custom:frigate-card',
cameras: [],
elements: [
{
type: 'custom:frigate-card-conditional',
conditions: {
fullscreen: true,
},
elements: [
{
type: 'custom:nested-unknown-object',
unknown_key: {
type: 'custom:frigate-card-conditional',
conditions: {
media_query: 'media query goes here',
},
elements: [],
},
},
],
},
],
overrides: [
{
overrides: {
menu: {
style: 'overlay',
},
},
conditions: {
fullscreen: true,
state: [
{
entity: 'binary_sensor.foo',
state: 'on',
},
],
},
},
],
};
afterEach(() => {
vi.restoreAllMocks();
});
it('should add listener', () => {
const controller = new ConditionController();
const handler = vi.fn();
controller.addStateListener(handler);
controller.setState({ fullscreen: true });
expect(handler).toBeCalled();
});
it('should remove listener', () => {
const controller = new ConditionController();
const handler = vi.fn();
controller.addStateListener(handler);
controller.removeStateListener(handler);
controller.setState({ fullscreen: true });
expect(handler).not.toBeCalled();
});
it('should get wrapper', () => {
const controller = new ConditionController();
const wrapper_1 = controller.getEpoch();
expect(wrapper_1).toEqual({ controller: controller });
controller.setState({ fullscreen: true });
const wrapper_2 = controller.getEpoch();
expect(wrapper_2).toEqual({ controller: controller });
// Since the state was set the wrappers should be different.
expect(wrapper_1).not.toBe(wrapper_2);
});
it('should not return hasHAStateConditions without HA state conditions', () => {
const controller = new ConditionController();
expect(controller.hasHAStateConditions).toBeFalsy();
});
it('should return hasHAStateConditions with HA state conditions', () => {
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
matches: false,
addEventListener: vi.fn(),
} as unknown as MediaQueryList);
const controller = new ConditionController(createConfig(config));
expect(controller.hasHAStateConditions).toBeTruthy();
});
it('should evaluate conditions with a view', () => {
const controller = new ConditionController();
const condition = { view: ['foo'] };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ view: 'foo' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
});
it('should evaluate conditions with fullscreen', () => {
const controller = new ConditionController();
const condition = { fullscreen: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ fullscreen: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ fullscreen: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with expand', () => {
const controller = new ConditionController();
const condition = { expand: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ expand: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ expand: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with camera', () => {
const controller = new ConditionController();
const condition = { camera: ['bar'] };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ camera: 'bar' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ camera: 'will-not-match' });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with ha state positive check', () => {
const controller = new ConditionController();
const condition = {
state: [
{
entity: 'binary_sensor.foo',
state: 'on',
},
],
};
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
});
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with ha state negative check', () => {
const controller = new ConditionController();
const condition = {
state: [
{
entity: 'binary_sensor.foo',
state_not: 'on',
},
],
};
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
});
expect(controller.evaluateCondition(condition)).toBeTruthy();
});
it('should evaluate conditions with media_loaded', () => {
const controller = new ConditionController();
const condition = { media_loaded: true };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ media_loaded: true });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ media_loaded: false });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with media query', () => {
vi.spyOn(window, 'matchMedia')
.mockReturnValueOnce(<MediaQueryList>{ matches: true })
.mockReturnValueOnce(<MediaQueryList>{ matches: false });
const controller = new ConditionController();
const condition = { media_query: 'whatever' };
expect(controller.evaluateCondition(condition)).toBeTruthy();
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
it('should trigger on changes to media query conditions', () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
matches: true,
addEventListener: addEventListener,
removeEventListener: removeEventListener,
} as unknown as MediaQueryList);
const controller = new ConditionController(createConfig(config));
expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
const callback = vi.fn();
controller.addStateListener(callback);
// Call the media query callback and use it to pretend a match happened. The
// callback is the 0th mock innvocation and the 1st argument.
addEventListener.mock.calls[0][1]();
// This should result in a callback to our state listener.
expect(callback).toBeCalled();
// Destroy the controller, which should remove the media query listener.
controller.destroy();
expect(removeEventListener).toBeCalled();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { mock } from 'vitest-mock-extended';
import {
CameraConfig,
FrigateCardCondition,
FrigateCardConfig,
cameraConfigSchema,
frigateCardConditionSchema,
frigateCardConfigSchema,
} from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types';
export const createCameraConfig = (config: Partial<CameraConfig>): CameraConfig => {
return cameraConfigSchema.parse(config);
};
export const createCondition = (
condition?: Partial<FrigateCardCondition>,
): FrigateCardCondition => {
return frigateCardConditionSchema.parse(condition ?? {});
};
export const createConfig = (config?: Partial<FrigateCardConfig>): FrigateCardConfig => {
return frigateCardConfigSchema.parse(config);
};
export const createHASS = (states?: HassEntities): HomeAssistant => {
const hass = mock<HomeAssistant>();
if (states) {
hass.states = states;
}
return hass;
};
export const createRegistryEntity = (entity?: Partial<Entity>): Entity => {
return {
config_entry_id: entity?.config_entry_id ?? null,
device_id: entity?.device_id ?? null,
disabled_by: entity?.disabled_by ?? null,
entity_id: entity?.entity_id ?? 'entity_id',
hidden_by: entity?.hidden_by ?? null,
platform: entity?.platform ?? 'platform',
translation_key: entity?.translation_key ?? null,
unique_id: entity?.unique_id ?? 'unique_id',
};
};
export const createStateEntity = (entity?: Partial<HassEntity>): HassEntity => {
return {
entity_id: entity?.entity_id ?? 'entity_id',
state: entity?.state ?? 'on',
last_changed: entity?.last_changed ?? 'never',
last_updated: entity?.last_updated ?? 'never',
attributes: entity?.attributes ?? {},
context: entity?.context ?? {
id: 'id',
parent_id: 'parent_id',
user_id: 'user_id',
},
};
};
+202
View File
@@ -0,0 +1,202 @@
import { handleActionConfig, hasAction } from 'custom-card-helpers';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { actionSchema } from '../../src/types';
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
stopEventFromActivatingCardWideActions,
} from '../../src/utils/action';
import { createHASS } from '../test-utils';
vi.mock('custom-card-helpers');
describe('convertActionToFrigateCardCustomAction', () => {
it('should skip null action', () => {
expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy();
});
it('should parse valid', () => {
expect(
convertActionToFrigateCardCustomAction({
action: 'custom:frigate-card-action',
frigate_card_action: 'download',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'download',
});
});
it('should not parse invalid', () => {
expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull();
});
});
describe('createFrigateCardCustomAction', () => {
it('should create camera_select', () => {
expect(
createFrigateCardCustomAction('camera_select', {
camera: 'camera',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
camera: 'camera',
frigate_card_action: 'camera_select',
card_id: 'card_id',
});
});
it('should not create camera_select without camera', () => {
expect(createFrigateCardCustomAction('camera_select')).toBeNull();
});
it('should create media_player', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
media_player_action: 'play',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'media_player',
media_player: 'device',
media_player_action: 'play',
card_id: 'card_id',
});
});
it('should not create media_player without player or action', () => {
expect(
createFrigateCardCustomAction('media_player', {
media_player_action: 'play',
}),
).toBeNull();
expect(
createFrigateCardCustomAction('media_player', {
media_player: 'device',
}),
).toBeNull();
});
it('should create general action', () => {
expect(
createFrigateCardCustomAction('clips', {
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card_id',
});
});
});
describe('getActionConfigGivenAction', () => {
const action = actionSchema.parse({
action: 'fire-dom-event',
frigate_card_action: 'clips',
});
it('should not handle undefined arguments', () => {
expect(getActionConfigGivenAction()).toBeUndefined();
});
it('should not handle unknown interactions', () => {
expect(
getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
).toBeUndefined();
});
it('should handle tap actions', () => {
expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
});
it('should handle hold actions', () => {
expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
});
it('should handle double_tap actions', () => {
expect(getActionConfigGivenAction('double_tap', { double_tap_action: action })).toBe(
action,
);
});
it('should handle end_tap actions', () => {
expect(getActionConfigGivenAction('end_tap', { end_tap_action: action })).toBe(
action,
);
});
it('should handle start_tap actions', () => {
expect(getActionConfigGivenAction('start_tap', { start_tap_action: action })).toBe(
action,
);
});
});
// @vitest-environment jsdom
describe('frigateCardHandleActionConfig', () => {
const element = document.createElement('div');
const action = actionSchema.parse({
action: 'none',
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not handle missing arguments', () => {
expect(
frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'),
).toBeFalsy();
});
it('should handle simple case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
expect(handleActionConfig).toBeCalled();
});
it('should handle array case', () => {
frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
action,
action,
action,
]);
expect(handleActionConfig).toBeCalledTimes(3);
});
});
describe('frigateCardHasAction', () => {
const action = actionSchema.parse({
action: 'toggle',
});
afterEach(() => {
vi.clearAllMocks();
});
it('should handle non-array case', () => {
expect(frigateCardHasAction(action)).toBeFalsy();
expect(hasAction).toBeCalledTimes(1);
});
it('should handle array case', () => {
expect(frigateCardHasAction([action, action, action])).toBeFalsy();
expect(hasAction).toBeCalledTimes(3);
});
});
// @vitest-environment jsdom
describe('stopEventFromActivatingCardWideActions', () => {
it('should stop event from propogating', () => {
const event = mock<Event>();
stopEventFromActivatingCardWideActions(event);
expect(event.stopPropagation).toBeCalled();
});
});