diff --git a/src/automations.ts b/src/automations.ts new file mode 100644 index 00000000..643b610d --- /dev/null +++ b/src/automations.ts @@ -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 = 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; + } +} diff --git a/src/card-condition.ts b/src/card-condition.ts deleted file mode 100644 index 59dbf0d0..00000000 --- a/src/card-condition.ts +++ /dev/null @@ -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, - state?: Readonly, -): 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. ) 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, - overrides: Readonly | undefined, - conditionState?: Readonly, -): 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 | 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); - } - }); - } -} diff --git a/src/card.ts b/src/card.ts index 2da4122c..e5f72a8e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 = createRef(); protected _refMain: Ref = 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 = 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,10 +1464,16 @@ class FrigateCard extends LitElement { } } - protected _cardActionEventHandler(ev: CustomEvent): void { - const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); - if (frigateCardAction) { - this._cardActionHandler(frigateCardAction); + 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); + } } } @@ -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()} >`} @@ -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); }} > ` diff --git a/src/components/elements.ts b/src/components/elements.ts index 6061e181..66e9b8d6 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -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` `; } - /** - * Get styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(elementsStyle); } @@ -259,7 +246,9 @@ export class FrigateCardElements extends LitElement { export class FrigateCardElementsConditional extends LitElement { protected _config?: FrigateConditional; - // Every set of hass is treated as a reason to re-evaluate. Given that this + // 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 // re-fetch even if hass itself has not changed. @@ -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` 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* + // 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} diff --git a/src/components/views.ts b/src/components/views.ts index da13525e..f7032397 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -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} diff --git a/src/conditions.ts b/src/conditions.ts new file mode 100644 index 00000000..a47e8ed5 --- /dev/null +++ b/src/conditions.ts @@ -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. ) 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, + config: Readonly, + configOverrides?: Readonly, + stateOverrides?: Partial, +): 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 { + 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; +} + +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): void { + this._state = { + ...this._state, + ...state, + }; + this._triggerChange(); + } + + get hasHAStateConditions(): boolean { + return this._hasHAStateConditions; + } + + public getEpoch(): ConditionControllerEpoch { + return this._epoch; + } + + public evaluateCondition( + condition: Readonly, + stateOverrides?: Partial, + ): 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); + } + }); + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1f7322ec..93759c24 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 50c6c8ba..13b7cdca 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index d746f2ec..553dff48 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/types.ts b/src/types.ts index fa1cc018..cc0d4e6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -263,7 +263,7 @@ export type FrigateCardCustomAction = z.infer; 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; +const automationActionSchema = actionSchema.array().optional(); +export type AutomationActions = z.infer; + +const automationSchema = z.object({ + conditions: frigateCardConditionSchema, + actions: automationActionSchema, + actions_not: automationActionSchema, +}); +export type Automation = z.infer; + +export const automationsSchema = automationSchema.array().optional(); +export type Automations = z.infer; + 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, diff --git a/src/utils/action.ts b/src/utils/action.ts index e64c0c37..edc700d1 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -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,36 +119,46 @@ 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') { - // ActionConfig vs ActionType: - // There is a slight typing (but not functional) difference between - // ActionType in this card and ActionConfig in `custom-card-helpers`. See - // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and - // reason behind this difference. - if (Array.isArray(actionConfig)) { - actionConfig.forEach((action) => - handleActionConfig(node, hass, config, action as ActionConfig | undefined), - ); - } else { - handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined); - } + 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 + // ActionType in this card and ActionConfig in `custom-card-helpers`. See + // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and + // reason behind this difference. + if (Array.isArray(actionConfig)) { + actionConfig.forEach((action) => + handleActionConfig(node, hass, config, action as ActionConfig | undefined), + ); + } else { + handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined); + } +}; + /** * Determine if an action config has a real action. A modified version of * custom-card-helpers hasAction to also work with arrays of action configs. * @param config The action config in question. * @returns `true` if there's a real action defined, `false` otherwise. */ -export const frigateCardHasAction = ( - config?: ActionType | ActionType[] | undefined, -): boolean => { +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)) { diff --git a/tests/automations.test.ts b/tests/automations.test.ts new file mode 100644 index 00000000..c5051188 --- /dev/null +++ b/tests/automations.test.ts @@ -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(), + 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(); + 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(), + 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(); + 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(); + }); +}); diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 0768422d..24ebde87 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -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 => { - return cameraConfigSchema.parse(config); -}; - -const createHASS = (states?: HassEntities): HomeAssistant => { - const hass = mock(); - if (states) { - hass.states = states; - } - return hass; -}; - -const createEntity = (entity: Partial): 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({ diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts new file mode 100644 index 00000000..3c98db99 --- /dev/null +++ b/tests/conditions.test.ts @@ -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({ matches: true }) + .mockReturnValueOnce({ 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(); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 00000000..f3fc336c --- /dev/null +++ b/tests/test-utils.ts @@ -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 => { + return cameraConfigSchema.parse(config); +}; + +export const createCondition = ( + condition?: Partial, +): FrigateCardCondition => { + return frigateCardConditionSchema.parse(condition ?? {}); +}; + +export const createConfig = (config?: Partial): FrigateCardConfig => { + return frigateCardConfigSchema.parse(config); +}; + +export const createHASS = (states?: HassEntities): HomeAssistant => { + const hass = mock(); + if (states) { + hass.states = states; + } + return hass; +}; + +export const createRegistryEntity = (entity?: Partial): 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 => { + 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', + }, + }; +}; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts new file mode 100644 index 00000000..264db819 --- /dev/null +++ b/tests/utils/action.test.ts @@ -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(); + stopEventFromActivatingCardWideActions(event); + expect(event.stopPropagation).toBeCalled(); + }); +});