Add initial support for automations.
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+136
-90
@@ -23,21 +23,20 @@ import pkg from '../package.json';
|
|||||||
import { actionHandler } from './action-handler-directive.js';
|
import { actionHandler } from './action-handler-directive.js';
|
||||||
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
||||||
import { CameraManager } from './camera-manager/manager.js';
|
import { CameraManager } from './camera-manager/manager.js';
|
||||||
import {
|
|
||||||
CardConditionManager,
|
|
||||||
ConditionState,
|
|
||||||
conditionStateRequestHandler,
|
|
||||||
getOverriddenConfig,
|
|
||||||
} from './card-condition.js';
|
|
||||||
import './components/elements.js';
|
import './components/elements.js';
|
||||||
import { FrigateCardElements } from './components/elements.js';
|
import { FrigateCardElements } from './components/elements.js';
|
||||||
import './components/menu.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 './components/message.js';
|
||||||
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
import { renderMessage, renderProgressIndicator } from './components/message.js';
|
||||||
import './components/thumbnail-carousel.js';
|
import './components/thumbnail-carousel.js';
|
||||||
import './components/views.js';
|
import './components/views.js';
|
||||||
import { FrigateCardViews } from './components/views.js';
|
import { FrigateCardViews } from './components/views.js';
|
||||||
|
import {
|
||||||
|
ConditionController,
|
||||||
|
ConditionEvaluateRequestEvent,
|
||||||
|
getOverriddenConfig,
|
||||||
|
} from './conditions.js';
|
||||||
import { isConfigUpgradeable } from './config-mgmt.js';
|
import { isConfigUpgradeable } from './config-mgmt.js';
|
||||||
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
|
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js';
|
||||||
import { getLanguage, loadLanguages, localize } from './localize/localize.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 cardStyle from './scss/card.scss';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
ActionType,
|
ActionsConfig,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
|
FRIGATE_CARD_VIEW_DEFAULT,
|
||||||
|
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
frigateCardConfigSchema,
|
frigateCardConfigSchema,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
FrigateCardError,
|
FrigateCardError,
|
||||||
FrigateCardView,
|
FrigateCardView,
|
||||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
|
||||||
FRIGATE_CARD_VIEW_DEFAULT,
|
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
Message,
|
Message,
|
||||||
@@ -65,7 +64,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
convertActionToFrigateCardCustomAction,
|
convertActionToFrigateCardCustomAction,
|
||||||
createFrigateCardCustomAction,
|
createFrigateCardCustomAction,
|
||||||
frigateCardHandleAction,
|
frigateCardHandleActionConfig,
|
||||||
frigateCardHasAction,
|
frigateCardHasAction,
|
||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
} from './utils/action.js';
|
} from './utils/action.js';
|
||||||
@@ -93,6 +92,7 @@ import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
|||||||
import { MicrophoneController } from './utils/microphone';
|
import { MicrophoneController } from './utils/microphone';
|
||||||
import { getActionsFromQueryString } from './utils/querystring.js';
|
import { getActionsFromQueryString } from './utils/querystring.js';
|
||||||
import { View } from './view/view.js';
|
import { View } from './view/view.js';
|
||||||
|
import { AutomationsController } from './automations';
|
||||||
|
|
||||||
/** A note on media callbacks:
|
/** A note on media callbacks:
|
||||||
*
|
*
|
||||||
@@ -113,11 +113,11 @@ import { View } from './view/view.js';
|
|||||||
|
|
||||||
/** A note on action/menu/ll-custom events:
|
/** A note on action/menu/ll-custom events:
|
||||||
*
|
*
|
||||||
* The card supports actions being configured in a number of places (e.g. tap on an
|
* The card supports actions being configured in a number of places (e.g. tap on
|
||||||
* element, double_tap on a menu item, hold on the live view). These actions are
|
* an element, double_tap on a menu item, hold on the live view). These actions
|
||||||
* handled frigateCardHandleAction(). For Frigate-card specific actions,
|
* are handled by frigateCardHandleActionConfig(). For Frigate-card specific
|
||||||
* frigateCardHandleAction() call will result in an ll-custom DOM event being
|
* actions, the frigateCardHandleActionConfig() call will result in an ll-custom
|
||||||
* fired, which needs to be caught at the card level to handle.
|
* DOM event being fired, which needs to be caught at the card level to handle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
@@ -180,10 +180,9 @@ class FrigateCard extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
protected _expand?: boolean = false;
|
protected _expand?: boolean = false;
|
||||||
|
|
||||||
// null implies the user refused microphone access.
|
|
||||||
protected _microphoneController?: MicrophoneController;
|
protected _microphoneController?: MicrophoneController;
|
||||||
|
protected _conditionController?: ConditionController;
|
||||||
protected _conditionState?: ConditionState;
|
protected _automationsController?: AutomationsController;
|
||||||
|
|
||||||
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
||||||
protected _refMain: Ref<HTMLElement> = 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
|
// The mouse handler may be called continually, throttle it to at most once
|
||||||
// per second for performance reasons.
|
// per second for performance reasons.
|
||||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
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 _triggers: Map<string, Date> = new Map();
|
||||||
protected _untriggerTimerID: number | null = null;
|
protected _untriggerTimerID: number | null = null;
|
||||||
|
|
||||||
protected _conditionManager: CardConditionManager | null = null;
|
|
||||||
|
|
||||||
protected _mediaPlayers?: string[];
|
protected _mediaPlayers?: string[];
|
||||||
|
|
||||||
protected _initializer = new FrigateCardInitializer();
|
protected _initializer = new FrigateCardInitializer();
|
||||||
@@ -253,9 +252,8 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._conditionManager?.hasHAStateConditions) {
|
if (this._conditionController?.hasHAStateConditions) {
|
||||||
// HA entity state is part of the condition state.
|
this._conditionController.setState({ state: this._hass.states });
|
||||||
this._generateConditionState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark mode may depend on HASS.
|
// Dark mode may depend on HASS.
|
||||||
@@ -294,36 +292,30 @@ class FrigateCard extends LitElement {
|
|||||||
} as unknown as FrigateCardConfig;
|
} as unknown as FrigateCardConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected _requestUpdateForComponentsThatUseConditions(): void {
|
||||||
* Generate the state used to evaluate conditions.
|
// 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
|
||||||
protected _generateConditionState(): void {
|
// card re-render (esp. when using card-mod).
|
||||||
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).
|
|
||||||
// https://github.com/dermotduffy/frigate-hass-card/issues/678
|
// https://github.com/dermotduffy/frigate-hass-card/issues/678
|
||||||
if (this._refViews.value) {
|
if (this._refViews.value) {
|
||||||
this._refViews.value.conditionState = this._conditionState;
|
this._refViews.value.conditionControllerEpoch =
|
||||||
|
this._conditionController?.getEpoch();
|
||||||
}
|
}
|
||||||
if (this._refElements.value) {
|
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(
|
const overriddenConfig = getOverriddenConfig(
|
||||||
|
this._conditionController,
|
||||||
this._config,
|
this._config,
|
||||||
this._config.overrides,
|
this._config.overrides,
|
||||||
this._conditionState,
|
|
||||||
) as FrigateCardConfig;
|
) as FrigateCardConfig;
|
||||||
|
|
||||||
// Save on Lit re-rendering costs by only updating the configuration if it
|
// 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.
|
* Get the style of emphasized menu items.
|
||||||
* @returns A StyleInfo.
|
* @returns A StyleInfo.
|
||||||
@@ -858,18 +858,44 @@ class FrigateCard extends LitElement {
|
|||||||
this._view = undefined;
|
this._view = undefined;
|
||||||
this._message = null;
|
this._message = null;
|
||||||
|
|
||||||
this._conditionManager?.destroy();
|
this._setupConditionController();
|
||||||
this._conditionManager = new CardConditionManager(
|
this._conditionController?.setState({
|
||||||
config,
|
view: undefined,
|
||||||
this._generateConditionState.bind(this),
|
camera: undefined,
|
||||||
);
|
});
|
||||||
|
|
||||||
this._generateConditionState();
|
this._automationsController = new AutomationsController(this._config.automations);
|
||||||
this._setLightOrDarkMode();
|
this._setLightOrDarkMode();
|
||||||
this._setPropertiesForMinMaxHeight();
|
this._setPropertiesForMinMaxHeight();
|
||||||
this._untrigger();
|
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.
|
* Card the card config, prioritizing the overriden config if present.
|
||||||
* @returns A FrigateCardConfig.
|
* @returns A FrigateCardConfig.
|
||||||
@@ -891,7 +917,10 @@ class FrigateCard extends LitElement {
|
|||||||
View.adoptFromViewIfAppropriate(view, this._view);
|
View.adoptFromViewIfAppropriate(view, this._view);
|
||||||
|
|
||||||
this._view = view;
|
this._view = view;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({
|
||||||
|
view: this._view.view,
|
||||||
|
camera: this._view.camera,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (args?.resetMessage ?? true) {
|
if (args?.resetMessage ?? true) {
|
||||||
@@ -1435,10 +1464,16 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _cardActionEventHandler(ev: CustomEvent<ActionType>): void {
|
protected _cardActionEventHandler(ev: Event): void {
|
||||||
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
|
// The event may not actually be a CustomEvent object, but may still have a
|
||||||
if (frigateCardAction) {
|
// detail field (see:
|
||||||
this._cardActionHandler(frigateCardAction);
|
// 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);
|
this._setExpand(!this._expand);
|
||||||
break;
|
break;
|
||||||
case 'fullscreen':
|
case 'fullscreen':
|
||||||
this._toggleFullscreen();
|
screenfull.toggle(this);
|
||||||
break;
|
break;
|
||||||
case 'menu_toggle':
|
case 'menu_toggle':
|
||||||
// This is a rare code path: this would only be used if someone has a
|
// 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 = () => {
|
const unmuteAndUpdate = () => {
|
||||||
this._microphoneController?.unmute();
|
this._microphoneController?.unmute();
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
};
|
||||||
if (
|
if (
|
||||||
!this._microphoneController?.isConnected() &&
|
!this._microphoneController?.isConnected() &&
|
||||||
!this._microphoneController?.isForbidden()
|
!this._microphoneController?.isForbidden()
|
||||||
@@ -1656,23 +1691,26 @@ class FrigateCard extends LitElement {
|
|||||||
* Handle an action called on an element.
|
* Handle an action called on an element.
|
||||||
* @param ev The actionHandler event.
|
* @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 interaction = ev.detail.action;
|
||||||
const node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
|
const node: HTMLElement | null = ev.currentTarget as HTMLElement | null;
|
||||||
|
const actionConfig = getActionConfigGivenAction(interaction, config);
|
||||||
if (
|
if (
|
||||||
|
this._hass &&
|
||||||
config &&
|
config &&
|
||||||
node &&
|
node &&
|
||||||
interaction &&
|
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
|
// action defined (as it uses a default that is unhelpful for views that
|
||||||
// have default tap/click actions).
|
// have default tap/click actions).
|
||||||
getActionConfigGivenAction(interaction, config)
|
actionConfig
|
||||||
) {
|
) {
|
||||||
frigateCardHandleAction(
|
frigateCardHandleActionConfig(
|
||||||
node,
|
node,
|
||||||
this._hass as HomeAssistant,
|
this._hass,
|
||||||
config,
|
config,
|
||||||
ev.detail.action,
|
ev.detail.action,
|
||||||
|
actionConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1828,8 +1866,10 @@ class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
this._setPropertiesForExpandedMode();
|
this._setPropertiesForExpandedMode();
|
||||||
|
|
||||||
// An update may be required to draw elements.
|
this._conditionController?.setState({
|
||||||
this._generateConditionState();
|
media_loaded: !!this._currentMediaLoadedInfo,
|
||||||
|
});
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,18 +1900,7 @@ class FrigateCard extends LitElement {
|
|||||||
*/
|
*/
|
||||||
protected _mediaUnloadedHandler(): void {
|
protected _mediaUnloadedHandler(): void {
|
||||||
this._currentMediaLoadedInfo = null;
|
this._currentMediaLoadedInfo = null;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({ media_loaded: false });
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1880,9 +1909,10 @@ class FrigateCard extends LitElement {
|
|||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.on('change', this._fullscreenHandler.bind(this));
|
screenfull.on('change', this._boundFullscreenHandler);
|
||||||
}
|
}
|
||||||
this.addEventListener('mousemove', this._boundMouseHandler);
|
this.addEventListener('mousemove', this._boundMouseHandler);
|
||||||
|
this.addEventListener('ll-custom', this._boundCardActionEventHandler);
|
||||||
this._panel = isCardInPanel(this);
|
this._panel = isCardInPanel(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1894,9 +1924,10 @@ class FrigateCard extends LitElement {
|
|||||||
this._mediaUnloadedHandler();
|
this._mediaUnloadedHandler();
|
||||||
|
|
||||||
if (screenfull.isEnabled) {
|
if (screenfull.isEnabled) {
|
||||||
screenfull.off('change', this._fullscreenHandler.bind(this));
|
screenfull.off('change', this._boundFullscreenHandler);
|
||||||
}
|
}
|
||||||
this.removeEventListener('mousemove', this._boundMouseHandler);
|
this.removeEventListener('mousemove', this._boundMouseHandler);
|
||||||
|
this.removeEventListener('ll-custom', this._boundCardActionEventHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1983,22 +2014,38 @@ class FrigateCard extends LitElement {
|
|||||||
return { ...this._getConfig().view.actions, ...specificActions };
|
return { ...this._getConfig().view.actions, ...specificActions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _isInFullscreen(): boolean {
|
||||||
|
return screenfull.isEnabled && screenfull.isFullscreen;
|
||||||
|
}
|
||||||
|
|
||||||
protected _setExpand(expand: boolean): void {
|
protected _setExpand(expand: boolean): void {
|
||||||
if (screenfull.isEnabled && screenfull.isFullscreen) {
|
if (expand && this._isInFullscreen()) {
|
||||||
// Fullscreen and expanded mode are mutually exclusive.
|
// Fullscreen and expanded mode are mutually exclusive.
|
||||||
screenfull.exit();
|
screenfull.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
this._expand = expand;
|
this._expand = expand;
|
||||||
this._generateConditionState();
|
this._conditionController?.setState({
|
||||||
|
expand: this._expand,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _toggleFullscreen(): void {
|
protected _fullscreenHandler(): void {
|
||||||
if (screenfull.isEnabled) {
|
const inFullscreen = screenfull.isEnabled && screenfull.isFullscreen;
|
||||||
// Fullscreen and expanded mode are mutually exclusive.
|
|
||||||
|
if (inFullscreen) {
|
||||||
this._expand = false;
|
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 {
|
protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void {
|
||||||
@@ -2059,7 +2106,6 @@ class FrigateCard extends LitElement {
|
|||||||
class="${classMap(cardClasses)}"
|
class="${classMap(cardClasses)}"
|
||||||
style="${styleMap(cardStyle)}"
|
style="${styleMap(cardStyle)}"
|
||||||
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
|
@action=${(ev: CustomEvent) => this._actionHandler(ev, actions)}
|
||||||
@ll-custom=${this._cardActionEventHandler.bind(this)}
|
|
||||||
@frigate-card:message=${this._messageHandler.bind(this)}
|
@frigate-card:message=${this._messageHandler.bind(this)}
|
||||||
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
|
@frigate-card:view:change=${this._changeViewHandler.bind(this)}
|
||||||
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
|
@frigate-card:view:change-context=${this._addViewContextHandler.bind(this)}
|
||||||
@@ -2082,7 +2128,7 @@ class FrigateCard extends LitElement {
|
|||||||
.resolvedMediaCache=${this._resolvedMediaCache}
|
.resolvedMediaCache=${this._resolvedMediaCache}
|
||||||
.config=${this._getConfig()}
|
.config=${this._getConfig()}
|
||||||
.nonOverriddenConfig=${this._config}
|
.nonOverriddenConfig=${this._config}
|
||||||
.conditionState=${this._conditionState}
|
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
|
||||||
.hide=${!!this._message}
|
.hide=${!!this._message}
|
||||||
.microphoneStream=${this._microphoneController?.getStream()}
|
.microphoneStream=${this._microphoneController?.getStream()}
|
||||||
></frigate-card-views>`}
|
></frigate-card-views>`}
|
||||||
@@ -2100,15 +2146,15 @@ class FrigateCard extends LitElement {
|
|||||||
${ref(this._refElements)}
|
${ref(this._refElements)}
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
.elements=${this._getConfig().elements}
|
.elements=${this._getConfig().elements}
|
||||||
.conditionState=${this._conditionState}
|
.conditionControllerEpoch=${this._conditionController?.getEpoch()}
|
||||||
@frigate-card:menu-add=${(e) => {
|
@frigate-card:menu-add=${(e) => {
|
||||||
this._addDynamicMenuButton(e.detail);
|
this._addDynamicMenuButton(e.detail);
|
||||||
}}
|
}}
|
||||||
@frigate-card:menu-remove=${(e) => {
|
@frigate-card:menu-remove=${(e) => {
|
||||||
this._removeDynamicMenuButton(e.detail);
|
this._removeDynamicMenuButton(e.detail);
|
||||||
}}
|
}}
|
||||||
@frigate-card:condition-state-request=${(ev) => {
|
@frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
|
||||||
conditionStateRequestHandler(ev, this._conditionState);
|
ev.evaluation = this._conditionController?.evaluateCondition(ev.condition);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</frigate-card-elements>`
|
</frigate-card-elements>`
|
||||||
|
|||||||
+11
-22
@@ -8,7 +8,6 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
|
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import elementsStyle from '../scss/elements.scss';
|
import elementsStyle from '../scss/elements.scss';
|
||||||
import ptzStyle from '../scss/elements-ptz.scss';
|
import ptzStyle from '../scss/elements-ptz.scss';
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
getActionConfigGivenAction,
|
getActionConfigGivenAction,
|
||||||
} from '../utils/action.js';
|
} from '../utils/action.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js';
|
||||||
|
|
||||||
/* A note on picture element rendering:
|
/* A note on picture element rendering:
|
||||||
*
|
*
|
||||||
@@ -81,11 +81,11 @@ export class FrigateCardElementsCore extends LitElement {
|
|||||||
public elements: PictureElements;
|
public elements: PictureElements;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Need to ensure card re-renders when conditionState changes, hence having it
|
* Need to ensure card re-renders when conditions change, hence having it as a
|
||||||
* as a property even though it is not currently directly used by this class.
|
* property even though it is not currently directly used by this class.
|
||||||
*/
|
*/
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
protected _root: HuiConditionalElement | null = null;
|
protected _root: HuiConditionalElement | null = null;
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export class FrigateCardElements extends LitElement {
|
|||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public elements: PictureElements;
|
public elements: PictureElements;
|
||||||
@@ -210,9 +210,6 @@ export class FrigateCardElements extends LitElement {
|
|||||||
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
|
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Connected callback.
|
|
||||||
*/
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -221,30 +218,20 @@ export class FrigateCardElements extends LitElement {
|
|||||||
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
|
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnected callback.
|
|
||||||
*/
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
|
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the template.
|
|
||||||
* @returns A rendered template.
|
|
||||||
*/
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
return html` <frigate-card-elements-core
|
return html`<frigate-card-elements-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.elements=${this.elements}
|
.elements=${this.elements}
|
||||||
>
|
>
|
||||||
</frigate-card-elements-core>`;
|
</frigate-card-elements-core>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get styles.
|
|
||||||
*/
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(elementsStyle);
|
return unsafeCSS(elementsStyle);
|
||||||
}
|
}
|
||||||
@@ -259,7 +246,9 @@ export class FrigateCardElements extends LitElement {
|
|||||||
export class FrigateCardElementsConditional extends LitElement {
|
export class FrigateCardElementsConditional extends LitElement {
|
||||||
protected _config?: FrigateConditional;
|
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
|
// 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
|
// elements), the hass object is used as the (only) trigger for condition
|
||||||
// re-fetch even if hass itself has not changed.
|
// re-fetch even if hass itself has not changed.
|
||||||
@@ -298,7 +287,7 @@ export class FrigateCardElementsConditional extends LitElement {
|
|||||||
* Render the card.
|
* Render the card.
|
||||||
*/
|
*/
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
|
if (evaluateConditionViaEvent(this, this._config.conditions)) {
|
||||||
return html` <frigate-card-elements-core
|
return html` <frigate-card-elements-core
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.elements=${this._config.elements}
|
.elements=${this._config.elements}
|
||||||
|
|||||||
+28
-37
@@ -1,5 +1,7 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { EmblaOptionsType } from 'embla-carousel';
|
import { EmblaOptionsType } from 'embla-carousel';
|
||||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||||
|
import { HassEntity } from 'home-assistant-js-websocket';
|
||||||
import {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
html,
|
html,
|
||||||
@@ -9,14 +11,17 @@ import {
|
|||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
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 { guard } from 'lit/directives/guard.js';
|
||||||
import { keyed } from 'lit/directives/keyed.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 { localize } from '../../localize/localize.js';
|
||||||
import liveStyle from '../../scss/live.scss';
|
|
||||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||||
|
import liveStyle from '../../scss/live.scss';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
CardWideConfig,
|
CardWideConfig,
|
||||||
@@ -36,27 +41,22 @@ import {
|
|||||||
dispatchExistingMediaLoadedInfoAsEvent,
|
dispatchExistingMediaLoadedInfoAsEvent,
|
||||||
dispatchMediaUnloadedEvent,
|
dispatchMediaUnloadedEvent,
|
||||||
} from '../../utils/media-info.js';
|
} 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 { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
|
||||||
import {
|
import {
|
||||||
FrigateCardMediaCarousel,
|
FrigateCardMediaCarousel,
|
||||||
wrapMediaLoadedEventForCarousel,
|
wrapMediaLoadedEventForCarousel,
|
||||||
wrapMediaUnloadedEventForCarousel,
|
wrapMediaUnloadedEventForCarousel,
|
||||||
} from '../media-carousel.js';
|
} from '../media-carousel.js';
|
||||||
|
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||||
import '../next-prev-control.js';
|
import '../next-prev-control.js';
|
||||||
import '../title-control.js';
|
|
||||||
import '../surround.js';
|
import '../surround.js';
|
||||||
|
import '../title-control.js';
|
||||||
import '../zoomer.js';
|
import '../zoomer.js';
|
||||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { Lazyload } from './../embla-plugins/lazyload.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';
|
|
||||||
|
|
||||||
interface LiveViewContext {
|
interface LiveViewContext {
|
||||||
// A cameraID override (used for dependencies/substreams to force a different
|
// A cameraID override (used for dependencies/substreams to force a different
|
||||||
@@ -118,7 +118,7 @@ export const getStateObjOrDispatchError = (
|
|||||||
@customElement('frigate-card-live')
|
@customElement('frigate-card-live')
|
||||||
export class FrigateCardLive extends LitElement {
|
export class FrigateCardLive extends LitElement {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: ExtendedHomeAssistant;
|
public hass?: ExtendedHomeAssistant;
|
||||||
@@ -250,7 +250,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.inBackground=${this._inBackground}
|
.inBackground=${this._inBackground}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
@@ -311,7 +311,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
public inBackground?: boolean;
|
public inBackground?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
@@ -525,21 +525,18 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
slideIndex: number,
|
slideIndex: number,
|
||||||
): TemplateResult | void {
|
): TemplateResult | void {
|
||||||
if (!this.liveConfig || !this.hass || !this.cameraManager) {
|
if (!this.liveConfig || !this.hass || !this.cameraManager || !this.conditionControllerEpoch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The conditionState object contains the currently live camera, which (in
|
// The condition controller object contains the currently live camera, which
|
||||||
// the carousel for example) is not necessarily the live camera this
|
// (in the carousel for example) is not necessarily the live camera *this*
|
||||||
// <frigate-card-live-provider> is rendering right now.
|
// <frigate-card-live-provider> is rendering right now, so we provide a
|
||||||
const conditionState = {
|
// stateOverride to evaluate the condition in that context.
|
||||||
...this.conditionState,
|
|
||||||
camera: cameraID,
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getOverriddenConfig(
|
const config = getOverriddenConfig(
|
||||||
|
this.conditionControllerEpoch.controller,
|
||||||
this.liveConfig,
|
this.liveConfig,
|
||||||
this.liveOverrides,
|
this.liveOverrides,
|
||||||
conditionState,
|
{ camera: cameraID },
|
||||||
) as LiveConfig;
|
) as LiveConfig;
|
||||||
|
|
||||||
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
|
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
|
||||||
@@ -603,12 +600,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = getOverriddenConfig(
|
|
||||||
this.liveConfig,
|
|
||||||
this.liveOverrides,
|
|
||||||
this.conditionState,
|
|
||||||
) as LiveConfig;
|
|
||||||
|
|
||||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||||
|
|
||||||
const overrideCameraID = (cameraID: string): string => {
|
const overrideCameraID = (cameraID: string): string => {
|
||||||
@@ -651,7 +642,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||||
: ''}"
|
: ''}"
|
||||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||||
.titlePopupConfig=${config.controls.title}
|
.titlePopupConfig=${this.liveConfig.controls.title}
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||||
@@ -664,7 +655,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="previous"
|
slot="previous"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'previous'}
|
.direction=${'previous'}
|
||||||
.controlConfig=${config.controls.next_previous}
|
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||||
.icon=${cameraMetadataPrevious?.icon}
|
.icon=${cameraMetadataPrevious?.icon}
|
||||||
?disabled=${prevID === null}
|
?disabled=${prevID === null}
|
||||||
@@ -679,7 +670,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
slot="next"
|
slot="next"
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.direction=${'next'}
|
.direction=${'next'}
|
||||||
.controlConfig=${config.controls.next_previous}
|
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||||
.label=${cameraMetadataNext?.title ?? ''}
|
.label=${cameraMetadataNext?.title ?? ''}
|
||||||
.icon=${cameraMetadataNext?.icon}
|
.icon=${cameraMetadataNext?.icon}
|
||||||
?disabled=${nextID === null}
|
?disabled=${nextID === null}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
import { CameraManager } from '../camera-manager/manager.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 viewsStyle from '../scss/views.scss';
|
||||||
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
|
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
|
||||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||||
@@ -40,10 +40,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
public resolvedMediaCache?: ResolvedMediaCache;
|
public resolvedMediaCache?: ResolvedMediaCache;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public conditionState?: ConditionState;
|
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
public cameras?: ConditionState;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hide?: boolean;
|
public hide?: boolean;
|
||||||
@@ -203,8 +200,8 @@ export class FrigateCardViews extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.liveConfig=${this.nonOverriddenConfig.live}
|
.liveConfig=${this.nonOverriddenConfig.live}
|
||||||
.conditionState=${this.conditionState}
|
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||||
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
|
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.microphoneStream=${this.microphoneStream}
|
.microphoneStream=${this.microphoneStream}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -423,6 +423,7 @@
|
|||||||
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
|
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
|
||||||
"reconnecting": "Reconnecting",
|
"reconnecting": "Reconnecting",
|
||||||
"timeline_no_cameras": "No Frigate cameras to show in timeline",
|
"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",
|
"troubleshooting": "Check troubleshooting",
|
||||||
"unknown": "Unknown error",
|
"unknown": "Unknown error",
|
||||||
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"reconnecting": "Riconnessione",
|
"reconnecting": "Riconnessione",
|
||||||
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
|
||||||
"troubleshooting": "Controllare la risoluzione dei problemi",
|
"troubleshooting": "Controllare la risoluzione dei problemi",
|
||||||
|
"too_many_automations": "",
|
||||||
"unknown": "Errore sconosciuto",
|
"unknown": "Errore sconosciuto",
|
||||||
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
|
"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",
|
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
||||||
|
|||||||
@@ -422,6 +422,7 @@
|
|||||||
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
|
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
|
||||||
"reconnecting": "Reconectando",
|
"reconnecting": "Reconectando",
|
||||||
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
|
||||||
|
"too_many_automations": "",
|
||||||
"troubleshooting": "Verifique a solução de problemas",
|
"troubleshooting": "Verifique a solução de problemas",
|
||||||
"unknown": "Erro desconhecido",
|
"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",
|
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
|
||||||
|
|||||||
+16
-2
@@ -263,7 +263,7 @@ export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSche
|
|||||||
|
|
||||||
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
|
// Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a
|
||||||
// transform on the discriminated union key.
|
// transform on the discriminated union key.
|
||||||
const actionSchema = z.union([
|
export const actionSchema = z.union([
|
||||||
toggleActionSchema,
|
toggleActionSchema,
|
||||||
callServiceActionSchema,
|
callServiceActionSchema,
|
||||||
navigateActionSchema,
|
navigateActionSchema,
|
||||||
@@ -632,7 +632,7 @@ export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
|
|||||||
|
|
||||||
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
|
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
|
||||||
|
|
||||||
const frigateCardConditionSchema = z.object({
|
export const frigateCardConditionSchema = z.object({
|
||||||
view: z.string().array().optional(),
|
view: z.string().array().optional(),
|
||||||
fullscreen: z.boolean().optional(),
|
fullscreen: z.boolean().optional(),
|
||||||
expand: z.boolean().optional(),
|
expand: z.boolean().optional(),
|
||||||
@@ -1316,6 +1316,19 @@ const liveOverridesSchema = z
|
|||||||
.optional();
|
.optional();
|
||||||
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
|
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 = {
|
const performanceConfigDefault = {
|
||||||
profile: 'high' as const,
|
profile: 'high' as const,
|
||||||
features: {
|
features: {
|
||||||
@@ -1392,6 +1405,7 @@ export const frigateCardConfigSchema = z.object({
|
|||||||
timeline: timelineConfigSchema,
|
timeline: timelineConfigSchema,
|
||||||
performance: performanceConfigSchema,
|
performance: performanceConfigSchema,
|
||||||
debug: debugConfigSchema,
|
debug: debugConfigSchema,
|
||||||
|
automations: automationsSchema,
|
||||||
|
|
||||||
// Configuration overrides.
|
// Configuration overrides.
|
||||||
overrides: overridesSchema,
|
overrides: overridesSchema,
|
||||||
|
|||||||
+29
-43
@@ -6,7 +6,6 @@ import {
|
|||||||
} from 'custom-card-helpers';
|
} from 'custom-card-helpers';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
ActionsConfig,
|
|
||||||
ActionType,
|
ActionType,
|
||||||
FrigateCardAction,
|
FrigateCardAction,
|
||||||
FrigateCardCustomAction,
|
FrigateCardCustomAction,
|
||||||
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
|
|||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
camera: args.camera as string,
|
camera: args.camera as string,
|
||||||
...(args.cardID && { card_id: args.cardID})
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action === 'media_player') {
|
if (action === 'media_player') {
|
||||||
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
|
|||||||
frigate_card_action: action,
|
frigate_card_action: action,
|
||||||
media_player: args.media_player,
|
media_player: args.media_player,
|
||||||
media_player_action: args.media_player_action,
|
media_player_action: args.media_player_action,
|
||||||
...(args.cardID && { card_id: args.cardID})
|
...(args.cardID && { card_id: args.cardID }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
action: 'fire-dom-event',
|
action: 'fire-dom-event',
|
||||||
frigate_card_action: action,
|
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.
|
* that handles the custom action events the card supports.
|
||||||
* @param node The node that fired the event.
|
* @param node The node that fired the event.
|
||||||
* @param hass The Home Assistant object.
|
* @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
|
* @param actionConfig A single action config, array of action configs or
|
||||||
* undefined for the default action config for 'tap'.
|
* undefined for the default action config for 'tap'.
|
||||||
* @param action The action string (e.g. 'hold')
|
* @param action The action string (e.g. 'hold')
|
||||||
@@ -143,36 +119,46 @@ export const frigateCardHandleActionConfig = (
|
|||||||
entity?: string;
|
entity?: string;
|
||||||
},
|
},
|
||||||
action: string,
|
action: string,
|
||||||
actionConfig: ActionType | ActionType[] | undefined,
|
actionConfig?: ActionType | ActionType[],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// Only allow a tap action to use a default non-config (the more-info config).
|
// Only allow a tap action to use a default non-config (the more-info config).
|
||||||
if (actionConfig || action == 'tap') {
|
if (actionConfig || action == 'tap') {
|
||||||
// ActionConfig vs ActionType:
|
frigateCardHandleAction(node, hass, config, actionConfig);
|
||||||
// There is a slight typing (but not functional) difference between
|
|
||||||
// ActionType in this card and ActionConfig in `custom-card-helpers`. See
|
|
||||||
// `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
|
|
||||||
// reason behind this difference.
|
|
||||||
if (Array.isArray(actionConfig)) {
|
|
||||||
actionConfig.forEach((action) =>
|
|
||||||
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
* 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.
|
* custom-card-helpers hasAction to also work with arrays of action configs.
|
||||||
* @param config The action config in question.
|
* @param config The action config in question.
|
||||||
* @returns `true` if there's a real action defined, `false` otherwise.
|
* @returns `true` if there's a real action defined, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
export const frigateCardHasAction = (
|
export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
|
||||||
config?: ActionType | ActionType[] | undefined,
|
|
||||||
): boolean => {
|
|
||||||
// See note above on 'ActionConfig vs ActionType' for why this cast is
|
// See note above on 'ActionConfig vs ActionType' for why this cast is
|
||||||
// necessary and harmless.
|
// necessary and harmless.
|
||||||
if (Array.isArray(config)) {
|
if (Array.isArray(config)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
|
||||||
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
|
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 { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
|
||||||
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
|
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
|
||||||
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';
|
|
||||||
|
|
||||||
vi.mock('../../src/utils/ha/entity-registry');
|
vi.mock('../../src/utils/ha/entity-registry');
|
||||||
vi.mock('../../src/utils/ha/entity-registry/cache');
|
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()', () => {
|
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
||||||
it('should get frigate engine from config', async () => {
|
it('should get frigate engine from config', async () => {
|
||||||
const config = createCameraConfig({ engine: 'frigate' });
|
const config = createCameraConfig({ engine: 'frigate' });
|
||||||
@@ -79,7 +50,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
|
|
||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
|
.mockResolvedValue(
|
||||||
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await createFactory({
|
await createFactory({
|
||||||
@@ -94,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(
|
.mockResolvedValue(
|
||||||
createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -109,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
|||||||
|
|
||||||
entityRegistryManager.getEntity = vi
|
entityRegistryManager.getEntity = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
|
.mockResolvedValue(
|
||||||
|
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await createFactory({
|
await createFactory({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user