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