diff --git a/README.md b/README.md
index 3d3de4e9..59e61b70 100644
--- a/README.md
+++ b/README.md
@@ -939,6 +939,25 @@ item, that has both of the following parameters set:
| `conditions` | | :heavy_multiplication_x: | A set of conditions that must evaluate to `true` in order for the overrides to be applied. See [Frigate Card Conditions](#frigate-card-conditions). |
| `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. |
+
+
+### Automation Optionns
+
+All configuration is a list under:
+
+```yaml
+automations:
+ - [conditions:]
+ [actions:]
+ [actions_not:]
+```
+
+| Option | Default | Overridable | Description |
+| - | - | - | - |
+| `conditions` | | :heavy_multiplication_x: | A set of conditions that will trigger the automation. See [Frigate Card Conditions](#frigate-card-conditions). |
+| `actions` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `true`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
+| `actions_not` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `false`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
+
### Media Layout
@@ -1229,6 +1248,8 @@ Parameters for the `custom:frigate-card-ptz` element:
| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). |
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
+
+
### Special Actions
#### `custom:frigate-card-action`
@@ -1236,7 +1257,7 @@ Parameters for the `custom:frigate-card-ptz` element:
| Parameter | Description |
| - | - |
| `action` | Must be `custom:frigate-card-action`. |
-| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`|
+| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`|
@@ -2629,6 +2650,24 @@ performance:
```
+
+ Expand: Automation section
+
+Reference: [Automation Options](#automation-options).
+
+```yaml
+automations:
+ - conditions:
+ fullscreen: true
+ actions:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_on
+ actions_not:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_off
+```
+
+
Expand: Other options
@@ -3610,6 +3649,31 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
```
+
+### Automation actions
+
+The card can automatically execute actions when certain conditions are met.
+
+
+ Expand: Automatically selecting a high-definition substream in fullscreen mode
+
+This example will automatically turn on the first configured substream when the
+card is put in fullscreen mode, and turn off the substream when exiting
+fullscreen mode.
+
+```yaml
+automations:
+ - conditions:
+ fullscreen: true
+ actions:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_on
+ actions_not:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_off
+```
+
+
## Card Refreshes
diff --git a/src/automations.ts b/src/automations.ts
new file mode 100644
index 00000000..643b610d
--- /dev/null
+++ b/src/automations.ts
@@ -0,0 +1,50 @@
+import { HomeAssistant } from 'custom-card-helpers';
+import { AutomationActions, FrigateCardError } from './types.js';
+import { ConditionController } from './conditions.js';
+import { Automation, Automations } from './types.js';
+import { frigateCardHandleAction } from './utils/action.js';
+import { localize } from './localize/localize.js';
+
+const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
+
+export class AutomationsControllerError extends FrigateCardError {}
+
+export class AutomationsController {
+ protected _automations: Automations;
+ protected _priorEvaluations: Map = new Map();
+
+ // A counter to avoid infinite loops, increases every time actions are run,
+ // decreases every time actions are complete.
+ protected _nestedAutomationExecutions = 0;
+
+ constructor(automations: Automations) {
+ this._automations = automations;
+ }
+
+ public execute(
+ element: HTMLElement,
+ hass: HomeAssistant,
+ conditionController: ConditionController,
+ ): void {
+ const actionsToRun: AutomationActions[] = [];
+ for (const automation of this._automations ?? []) {
+ const shouldExecute = conditionController.evaluateCondition(automation.conditions);
+ const actions = shouldExecute ? automation.actions : automation.actions_not;
+ const priorEvaluation = this._priorEvaluations.get(automation);
+ this._priorEvaluations.set(automation, shouldExecute);
+ if (shouldExecute !== priorEvaluation && actions) {
+ actionsToRun.push(actions);
+ }
+ }
+
+ ++this._nestedAutomationExecutions;
+ if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
+ throw new AutomationsControllerError(localize('error.too_many_automations'));
+ }
+
+ actionsToRun.forEach((actions) => {
+ frigateCardHandleAction(element, hass, {}, actions);
+ });
+ --this._nestedAutomationExecutions;
+ }
+}
diff --git a/src/card-condition.ts b/src/card-condition.ts
deleted file mode 100644
index 59dbf0d0..00000000
--- a/src/card-condition.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import {
- FrigateCardCondition,
- FrigateCardConfig,
- frigateConditionalSchema,
- OverrideConfigurationKey,
- RawFrigateCardConfig,
-} from './types';
-import { HassEntities } from 'home-assistant-js-websocket';
-import merge from 'lodash-es/merge';
-import { copyConfig } from './config-mgmt';
-
-export interface ConditionState {
- view?: string;
- fullscreen?: boolean;
- expand?: boolean;
- camera?: string;
- state?: HassEntities;
- media_loaded?: boolean;
-}
-
-class ConditionStateRequestEvent extends Event {
- public conditionState?: ConditionState;
-}
-
-function evaluateCondition(
- condition?: Readonly,
- state?: Readonly,
-): boolean {
- if (!state) {
- return false;
- }
-
- let result = true;
- if (condition?.view?.length) {
- result &&= !!state.view && condition.view.includes(state.view);
- }
- if (condition?.fullscreen !== undefined) {
- result &&=
- state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
- }
- if (condition?.expand !== undefined) {
- result &&=
- state.expand !== undefined && condition.expand == state.expand;
- }
- if (condition?.camera?.length) {
- result &&= !!state.camera && condition.camera.includes(state.camera);
- }
- if (condition?.state?.length) {
- for (const stateTest of condition?.state) {
- result &&=
- !!state.state &&
- ((!stateTest.state && !stateTest.state_not) ||
- (stateTest.entity in state.state &&
- (!stateTest.state ||
- state.state[stateTest.entity].state === stateTest.state) &&
- (!stateTest.state_not ||
- state.state[stateTest.entity].state !== stateTest.state_not)));
- }
- }
- if (condition?.media_loaded !== undefined) {
- result &&=
- state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
- }
- if (condition?.media_query) {
- result &&= window.matchMedia(condition.media_query).matches;
- }
- return result;
-}
-
-/**
- * Evaluate whether a frigateCardCondition is met using an event to fetch state.
- * @returns A boolean indicating whether the condition is met.
- */
-export function fetchStateAndEvaluateCondition(
- element: HTMLElement,
- condition?: FrigateCardCondition,
-): boolean {
- if (!condition) {
- return true;
- }
-
- const stateEvent = new ConditionStateRequestEvent(
- `frigate-card:condition-state-request`,
- {
- bubbles: true,
- composed: true,
- },
- );
-
- /* Special note on what's going on here:
- *
- * Some parts of the card (e.g. ) may have arbitrary
- * complexity and layers (that this card doesn't control) between that master
- * element and the element that needs to evaluate the condition. In these
- * cases there's no clean way to pass state from the rest of card down
- * through these layers. Instead, an event is dispatched as a "request for
- * state" (StateRequestEvent) upwards which is caught by the outer card
- * and state added to the event object. Because event propagation is handled
- * synchronously, the state will be added to the event before the flow
- * proceeds.
- */
- element.dispatchEvent(stateEvent);
- return evaluateCondition(condition, stateEvent.conditionState);
-}
-
-export function conditionStateRequestHandler(
- ev: ConditionStateRequestEvent,
- conditionState?: ConditionState,
-): void {
- ev.conditionState = conditionState;
-}
-
-type RawOverrides = {
- conditions: FrigateCardCondition;
- overrides: RawFrigateCardConfig;
-}[];
-
-export function getOverriddenConfig(
- config: Readonly,
- overrides: Readonly | undefined,
- conditionState?: Readonly,
-): RawFrigateCardConfig {
- const output = copyConfig(config);
- let overridden = false;
- if (overrides) {
- for (const override of overrides) {
- if (evaluateCondition(override.conditions, conditionState)) {
- merge(output, override.overrides);
- overridden = true;
- }
- }
- }
- // Attempt to return the same configuration object if it has not been
- // overridden (to reduce re-renders for a configuration that has not changed).
- return overridden ? output : config;
-}
-
-export function getOverridesByKey(
- overrides: Readonly | undefined,
- key: OverrideConfigurationKey,
-): RawOverrides {
- return (
- overrides
- ?.filter((o) => key in o.overrides)
- .map((o) => ({
- conditions: o.conditions,
- overrides: o.overrides[key] as RawFrigateCardConfig,
- })) ?? []
- );
-}
-
-export class CardConditionManager {
- // Whether or not to include HA state in ConditionState. Doing so increases
- // CPU usage as HA state is pumped out very fast, so this is only enabled if
- // the configuration needs to consume it.
- protected _hasHAStateConditions = false;
-
- protected _callback: () => void;
- protected _mediaQueries: MediaQueryList[] = [];
- protected _boundTriggerChange = this._triggerChange.bind(this);
-
- constructor(config: FrigateCardConfig, callback: () => void) {
- this._initConditions(config);
- this._callback = callback;
- }
-
- /**
- * Destroy the object.
- */
- public destroy(): void {
- this._mediaQueries.forEach((mql) =>
- mql.removeEventListener('change', this._boundTriggerChange),
- );
- this._mediaQueries = [];
- }
-
- /**
- * Determine if the conditions have state conditions.
- */
- get hasHAStateConditions(): boolean {
- return this._hasHAStateConditions;
- }
-
- /**
- * Trigger the callback.
- * @param _ Ignored parameter.
- */
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- protected _triggerChange(_: MediaQueryListEvent): void {
- this._callback();
- }
-
- /**
- * Init the conditions.
- * @param config The card configuration.
- */
- protected _initConditions(config: FrigateCardConfig): void {
- const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
- const conditions: FrigateCardCondition[] = [];
- config.overrides?.forEach((override) => conditions.push(override.conditions));
-
- // Element conditions can be arbitrarily nested underneath conditionals and
- // custom elements that this card may not known. Here we recursively parse
- // down the elements tree, parsing as we go to find valid conditions.
- const getElementsConditions = (data: unknown): void => {
- const parseResult = frigateConditionalSchema.safeParse(data);
- if (parseResult.success) {
- conditions.push(parseResult.data.conditions);
- parseResult.data.elements?.forEach(getElementsConditions);
- } else if (data && typeof data === 'object') {
- Object.keys(data).forEach((key) => getElementsConditions(data[key]));
- }
- };
- config.elements?.forEach(getElementsConditions);
- return conditions;
- };
-
- const conditions = getAllConditions(config);
- this._hasHAStateConditions = conditions.some(
- (condition) => !!condition.state?.length,
- );
- conditions.forEach((condition) => {
- if (condition.media_query) {
- const mql = window.matchMedia(condition.media_query);
- mql.addEventListener('change', this._boundTriggerChange);
- this._mediaQueries.push(mql);
- }
- });
- }
-}
diff --git a/src/card.ts b/src/card.ts
index 2da4122c..ef892399 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -21,23 +21,23 @@ import 'web-dialog';
import { z } from 'zod';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
+import { AutomationsController } from './automations';
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 +45,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 +65,7 @@ import {
import {
convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction,
- frigateCardHandleAction,
+ frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
} from './utils/action.js';
@@ -92,6 +92,12 @@ import { FrigateCardInitializer } from './utils/initializer.js';
import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { MicrophoneController } from './utils/microphone';
import { getActionsFromQueryString } from './utils/querystring.js';
+import {
+ createViewWithNextStream,
+ createViewWithoutSubstream,
+ createViewWithSelectedSubstream,
+ hasSubstream,
+} from './utils/substream';
import { View } from './view/view.js';
/** A note on media callbacks:
@@ -113,11 +119,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 +186,9 @@ class FrigateCard extends LitElement {
@state()
protected _expand?: boolean = false;
- // null implies the user refused microphone access.
protected _microphoneController?: MicrophoneController;
-
- protected _conditionState?: ConditionState;
+ protected _conditionController?: ConditionController;
+ protected _automationsController?: AutomationsController;
protected _refMenu: Ref = createRef();
protected _refMain: Ref = createRef();
@@ -217,12 +222,12 @@ class FrigateCard extends LitElement {
// The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons.
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
+ protected _boundCardActionEventHandler = this._cardActionEventHandler.bind(this);
+ protected _boundFullscreenHandler = this._fullscreenHandler.bind(this);
protected _triggers: Map = new Map();
protected _untriggerTimerID: number | null = null;
- protected _conditionManager: CardConditionManager | null = null;
-
protected _mediaPlayers?: string[];
protected _initializer = new FrigateCardInitializer();
@@ -253,9 +258,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 +298,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
@@ -485,12 +483,9 @@ class FrigateCard extends LitElement {
title: localize('config.menu.buttons.substreams'),
...this._getConfig().menu.buttons.substreams,
type: 'custom:frigate-card-menu-icon',
- tap_action: createFrigateCardCustomAction('live_substream_select', {
- camera:
- override === undefined || override === dependencies[0]
- ? dependencies[1]
- : dependencies[0],
- }) as FrigateCardCustomAction,
+ tap_action: createFrigateCardCustomAction(
+ hasSubstream(this._view) ? 'live_substream_off' : 'live_substream_on',
+ ) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
const menuItems = Array.from(dependencies, (cameraID) => {
@@ -858,18 +853,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 +912,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,15 +1459,21 @@ class FrigateCard extends LitElement {
}
}
- protected _cardActionEventHandler(ev: CustomEvent): void {
- const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
- if (frigateCardAction) {
- this._cardActionHandler(frigateCardAction);
+ protected _cardActionEventHandler(ev: Event): void {
+ // The event may not actually be a CustomEvent object, but may still have a
+ // detail field (see:
+ // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
+ // )
+ if ('detail' in ev) {
+ const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
+ if (frigateCardAction) {
+ this._cardActionHandler(frigateCardAction);
+ }
}
}
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
- if (!this._view) {
+ if (!this._view || !this._cameraManager) {
return;
}
@@ -1490,7 +1520,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
@@ -1515,16 +1545,24 @@ class FrigateCard extends LitElement {
});
}
break;
- case 'live_substream_select':
- const overrides: Map =
- this._view.context?.live?.overrides ?? new Map();
- overrides.set(this._view.camera, frigateCardAction.camera);
- this._changeView({
- view: this._view.clone().mergeInContext({
- live: { overrides: overrides },
- }),
- });
+ case 'live_substream_select': {
+ const view = createViewWithSelectedSubstream(
+ this._view,
+ frigateCardAction.camera,
+ );
+ view && this._changeView({ view: view });
break;
+ }
+ case 'live_substream_off': {
+ const view = createViewWithoutSubstream(this._view);
+ view && this._changeView({ view: view });
+ break;
+ }
+ case 'live_substream_on': {
+ const view = createViewWithNextStream(this._cameraManager, this._view);
+ view && this._changeView({ view: view });
+ break;
+ }
case 'media_player':
this._mediaPlayerAction(
frigateCardAction.media_player,
@@ -1542,7 +1580,7 @@ class FrigateCard extends LitElement {
const unmuteAndUpdate = () => {
this._microphoneController?.unmute();
this.requestUpdate();
- }
+ };
if (
!this._microphoneController?.isConnected() &&
!this._microphoneController?.isForbidden()
@@ -1656,23 +1694,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 +1869,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 +1903,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 +1912,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 +1927,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 +2017,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 +2109,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 +2131,7 @@ class FrigateCard extends LitElement {
.resolvedMediaCache=${this._resolvedMediaCache}
.config=${this._getConfig()}
.nonOverriddenConfig=${this._config}
- .conditionState=${this._conditionState}
+ .conditionControllerEpoch=${this._conditionController?.getEpoch()}
.hide=${!!this._message}
.microphoneStream=${this._microphoneController?.getStream()}
>`}
@@ -2100,15 +2149,15 @@ class FrigateCard extends LitElement {
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._getConfig().elements}
- .conditionState=${this._conditionState}
+ .conditionControllerEpoch=${this._conditionController?.getEpoch()}
@frigate-card:menu-add=${(e) => {
this._addDynamicMenuButton(e.detail);
}}
@frigate-card:menu-remove=${(e) => {
this._removeDynamicMenuButton(e.detail);
}}
- @frigate-card:condition-state-request=${(ev) => {
- conditionStateRequestHandler(ev, this._conditionState);
+ @frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
+ ev.evaluation = this._conditionController?.evaluateCondition(ev.condition);
}}
>
`
diff --git a/src/components/elements.ts b/src/components/elements.ts
index 6061e181..66e9b8d6 100644
--- a/src/components/elements.ts
+++ b/src/components/elements.ts
@@ -8,7 +8,6 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
-import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js';
import { localize } from '../localize/localize.js';
import elementsStyle from '../scss/elements.scss';
import ptzStyle from '../scss/elements-ptz.scss';
@@ -34,6 +33,7 @@ import {
getActionConfigGivenAction,
} from '../utils/action.js';
import { classMap } from 'lit/directives/class-map.js';
+import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js';
/* A note on picture element rendering:
*
@@ -81,11 +81,11 @@ export class FrigateCardElementsCore extends LitElement {
public elements: PictureElements;
/**
- * Need to ensure card re-renders when conditionState changes, hence having it
- * as a property even though it is not currently directly used by this class.
+ * Need to ensure card re-renders when conditions change, hence having it as a
+ * property even though it is not currently directly used by this class.
*/
@property({ attribute: false })
- public conditionState?: ConditionState;
+ public conditionControllerEpoch?: ConditionControllerEpoch;
protected _root: HuiConditionalElement | null = null;
@@ -167,7 +167,7 @@ export class FrigateCardElements extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false })
- public conditionState?: ConditionState;
+ public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public elements: PictureElements;
@@ -210,9 +210,6 @@ export class FrigateCardElements extends LitElement {
path[0].addEventListener('frigate-card:menu-remove', this._boundMenuRemoveHandler);
}
- /**
- * Connected callback.
- */
connectedCallback(): void {
super.connectedCallback();
@@ -221,30 +218,20 @@ export class FrigateCardElements extends LitElement {
this.addEventListener('frigate-card:menu-add', this._menuAddHandler);
}
- /**
- * Disconnected callback.
- */
disconnectedCallback(): void {
this.removeEventListener('frigate-card:menu-add', this._menuAddHandler);
super.disconnectedCallback();
}
- /**
- * Render the template.
- * @returns A rendered template.
- */
protected render(): TemplateResult {
- return html`
`;
}
- /**
- * Get styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(elementsStyle);
}
@@ -259,7 +246,9 @@ export class FrigateCardElements extends LitElement {
export class FrigateCardElementsConditional extends LitElement {
protected _config?: FrigateConditional;
- // Every set of hass is treated as a reason to re-evaluate. Given that this
+ // A note on hass as an update mechanism:
+ //
+ // Every set of hass is treated as a reason to re-evaluate. Given that this
// node may be buried down the DOM (as a descendent of non-Frigate card
// elements), the hass object is used as the (only) trigger for condition
// re-fetch even if hass itself has not changed.
@@ -298,7 +287,7 @@ export class FrigateCardElementsConditional extends LitElement {
* Render the card.
*/
protected render(): TemplateResult | void {
- if (fetchStateAndEvaluateCondition(this, this._config.conditions)) {
+ if (evaluateConditionViaEvent(this, this._config.conditions)) {
return html` is rendering right now.
- const conditionState = {
- ...this.conditionState,
- camera: cameraID,
- };
-
+ // The condition controller object contains the currently live camera, which
+ // (in the carousel for example) is not necessarily the live camera *this*
+ // is rendering right now, so we provide a
+ // stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
+ this.conditionControllerEpoch.controller,
this.liveConfig,
this.liveOverrides,
- conditionState,
+ { camera: cameraID },
) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
@@ -603,12 +600,6 @@ export class FrigateCardLiveCarousel extends LitElement {
return;
}
- const config = getOverriddenConfig(
- this.liveConfig,
- this.liveOverrides,
- this.conditionState,
- ) as LiveConfig;
-
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
@@ -651,7 +642,7 @@ export class FrigateCardLiveCarousel extends LitElement {
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
- .titlePopupConfig=${config.controls.title}
+ .titlePopupConfig=${this.liveConfig.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@@ -664,7 +655,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
- .controlConfig=${config.controls.next_previous}
+ .controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@@ -679,7 +670,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next"
.hass=${this.hass}
.direction=${'next'}
- .controlConfig=${config.controls.next_previous}
+ .controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
diff --git a/src/components/views.ts b/src/components/views.ts
index da13525e..f7032397 100644
--- a/src/components/views.ts
+++ b/src/components/views.ts
@@ -9,7 +9,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
-import { ConditionState, getOverridesByKey } from '../card-condition';
+import { ConditionControllerEpoch, getOverridesByKey } from '../conditions';
import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
@@ -40,10 +40,7 @@ export class FrigateCardViews extends LitElement {
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
- public conditionState?: ConditionState;
-
- @property({ attribute: false })
- public cameras?: ConditionState;
+ public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hide?: boolean;
@@ -203,8 +200,8 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
- .conditionState=${this.conditionState}
- .liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
+ .conditionControllerEpoch=${this.conditionControllerEpoch}
+ .liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneStream=${this.microphoneStream}
diff --git a/src/conditions.ts b/src/conditions.ts
new file mode 100644
index 00000000..a47e8ed5
--- /dev/null
+++ b/src/conditions.ts
@@ -0,0 +1,251 @@
+import { HassEntities } from 'home-assistant-js-websocket';
+import merge from 'lodash-es/merge';
+import { copyConfig } from './config-mgmt';
+import {
+ FrigateCardCondition,
+ FrigateCardConfig,
+ frigateConditionalSchema,
+ OverrideConfigurationKey,
+ RawFrigateCardConfig,
+} from './types';
+
+interface ConditionState {
+ view?: string;
+ fullscreen?: boolean;
+ expand?: boolean;
+ camera?: string;
+ state?: HassEntities;
+ media_loaded?: boolean;
+}
+
+export class ConditionEvaluateRequestEvent extends Event {
+ public condition: FrigateCardCondition;
+ public evaluation?: boolean;
+
+ constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
+ super('frigate-card:condition:evaluate', eventInitDict);
+ this.condition = condition;
+ }
+}
+
+/**
+ * Evaluate whether a frigateCardCondition is met using an event to evaluate.
+ * @returns A boolean indicating whether the condition is met.
+ */
+export function evaluateConditionViaEvent(
+ element: HTMLElement,
+ condition?: FrigateCardCondition,
+): boolean {
+ if (!condition) {
+ return true;
+ }
+
+ const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
+ bubbles: true,
+ composed: true,
+ });
+
+ /* Special note on what's going on here:
+ *
+ * Some parts of the card (e.g. ) may have arbitrary
+ * complexity and layers (that this card doesn't control) between that master
+ * element and the element that needs to evaluate the condition. In these
+ * cases there's no clean way to pass state from the rest of card down through
+ * these layers. Instead, an event is dispatched as a "request for evaluation"
+ * (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
+ * and the evaluation result is added to the event object. Because event
+ * propagation is handled synchronously, the result will be added to the event
+ * before the flow proceeds.
+ */
+ element.dispatchEvent(evaluateEvent);
+ return evaluateEvent.evaluation ?? false;
+}
+
+type RawOverrides = {
+ conditions: FrigateCardCondition;
+ overrides: RawFrigateCardConfig;
+}[];
+
+export function getOverriddenConfig(
+ controller: Readonly,
+ config: Readonly,
+ configOverrides?: Readonly,
+ stateOverrides?: Partial,
+): RawFrigateCardConfig {
+ const output = copyConfig(config);
+ let overridden = false;
+ if (configOverrides) {
+ for (const override of configOverrides) {
+ if (controller.evaluateCondition(override.conditions, stateOverrides)) {
+ merge(output, override.overrides);
+ overridden = true;
+ }
+ }
+ }
+ // Attempt to return the same configuration object if it has not been
+ // overridden (to reduce re-renders for a configuration that has not changed).
+ return overridden ? output : config;
+}
+
+export function getOverridesByKey(
+ key: OverrideConfigurationKey,
+ overrides?: Readonly,
+): RawOverrides {
+ return (
+ overrides
+ ?.filter((o) => key in o.overrides)
+ .map((o) => ({
+ conditions: o.conditions,
+ overrides: o.overrides[key] as RawFrigateCardConfig,
+ })) ?? []
+ );
+}
+
+// A tiny wrapper interface to allow the same controller to be passed around
+// immutably within objects that will not be equal (===). Every state change
+// generates a new epoch. This is used for Lit rendering to ensure changes to
+// condition state are recognized as changes even though the controller is the
+// same.
+export interface ConditionControllerEpoch {
+ controller: Readonly;
+}
+
+export class ConditionController {
+ protected _state: ConditionState = {};
+ protected _epoch: ConditionControllerEpoch = this._createEpoch();
+ protected _stateListeners: (() => void)[] = [];
+
+ // Whether or not to include HA state in ConditionState. Doing so increases
+ // CPU usage as HA state is pumped out very fast, so this is only enabled if
+ // the configuration needs to consume it.
+ protected _hasHAStateConditions = false;
+ protected _mediaQueries: MediaQueryList[] = [];
+ protected _mediaQueryTrigger = () => this._triggerChange();
+
+ constructor(config?: FrigateCardConfig) {
+ if (config) {
+ this._initConditions(config);
+ }
+ }
+
+ public addStateListener(callback: () => void): void {
+ this._stateListeners.push(callback);
+ }
+
+ public removeStateListener(callback: () => void): void {
+ this._stateListeners = this._stateListeners.filter(
+ (listener) => listener != callback,
+ );
+ }
+
+ public destroy(): void {
+ this._mediaQueries.forEach((mql) =>
+ mql.removeEventListener('change', this._mediaQueryTrigger),
+ );
+ this._mediaQueries = [];
+ }
+
+ public setState(state: Partial): void {
+ this._state = {
+ ...this._state,
+ ...state,
+ };
+ this._triggerChange();
+ }
+
+ get hasHAStateConditions(): boolean {
+ return this._hasHAStateConditions;
+ }
+
+ public getEpoch(): ConditionControllerEpoch {
+ return this._epoch;
+ }
+
+ public evaluateCondition(
+ condition: Readonly,
+ stateOverrides?: Partial,
+ ): boolean {
+ const state = {
+ ...this._state,
+ ...stateOverrides,
+ };
+
+ let result = true;
+ if (condition.view?.length) {
+ result &&= !!state?.view && condition.view.includes(state.view);
+ }
+ if (condition.fullscreen !== undefined) {
+ result &&=
+ state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
+ }
+ if (condition.expand !== undefined) {
+ result &&= state.expand !== undefined && condition.expand == state.expand;
+ }
+ if (condition.camera?.length) {
+ result &&= !!state.camera && condition.camera.includes(state.camera);
+ }
+ if (condition.state?.length) {
+ for (const stateTest of condition.state) {
+ result &&=
+ !!state.state &&
+ ((!stateTest.state && !stateTest.state_not) ||
+ (stateTest.entity in state.state &&
+ (!stateTest.state ||
+ state.state[stateTest.entity].state === stateTest.state) &&
+ (!stateTest.state_not ||
+ state.state[stateTest.entity].state !== stateTest.state_not)));
+ }
+ }
+ if (condition.media_loaded !== undefined) {
+ result &&=
+ state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
+ }
+ if (condition.media_query) {
+ result &&= window.matchMedia(condition.media_query).matches;
+ }
+ return result;
+ }
+
+ protected _createEpoch(): ConditionControllerEpoch {
+ return { controller: this };
+ }
+
+ protected _triggerChange(): void {
+ this._epoch = this._createEpoch();
+ this._stateListeners.forEach((listener) => listener());
+ }
+
+ protected _initConditions(config: FrigateCardConfig): void {
+ const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
+ const conditions: FrigateCardCondition[] = [];
+ config.overrides?.forEach((override) => conditions.push(override.conditions));
+
+ // Element conditions can be arbitrarily nested underneath conditionals and
+ // custom elements that this card may not known. Here we recursively parse
+ // down the elements tree, parsing as we go to find valid conditions.
+ const getElementsConditions = (data: unknown): void => {
+ const parseResult = frigateConditionalSchema.safeParse(data);
+ if (parseResult.success) {
+ conditions.push(parseResult.data.conditions);
+ parseResult.data.elements?.forEach(getElementsConditions);
+ } else if (data && typeof data === 'object') {
+ Object.keys(data).forEach((key) => getElementsConditions(data[key]));
+ }
+ };
+ config.elements?.forEach(getElementsConditions);
+ return conditions;
+ };
+
+ const conditions = getAllConditions(config);
+ this._hasHAStateConditions = conditions.some(
+ (condition) => !!condition.state?.length,
+ );
+ conditions.forEach((condition) => {
+ if (condition.media_query) {
+ const mql = window.matchMedia(condition.media_query);
+ mql.addEventListener('change', this._mediaQueryTrigger);
+ this._mediaQueries.push(mql);
+ }
+ });
+ }
+}
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index 1f7322ec..93759c24 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -423,6 +423,7 @@
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting",
"timeline_no_cameras": "No Frigate cameras to show in timeline",
+ "too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"unknown": "Unknown error",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 50c6c8ba..13b7cdca 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -416,6 +416,7 @@
"reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi",
+ "too_many_automations": "",
"unknown": "Errore sconosciuto",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index d746f2ec..553dff48 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -422,6 +422,7 @@
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
+ "too_many_automations": "",
"troubleshooting": "Verifique a solução de problemas",
"unknown": "Erro desconhecido",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json
index 58e28c96..bbab7804 100644
--- a/src/localize/languages/pt-PT.json
+++ b/src/localize/languages/pt-PT.json
@@ -404,6 +404,7 @@
"reconnecting": "A voltar a ligar",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"troubleshooting": "Verifique a solução de problemas",
+ "too_many_automations": "",
"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",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
diff --git a/src/types.ts b/src/types.ts
index fa1cc018..5b9b065d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -219,6 +219,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image',
'live',
'menu_toggle',
+ 'live_substream_on',
+ 'live_substream_off',
'microphone_mute',
'microphone_unmute',
'recording',
@@ -263,7 +265,7 @@ export type FrigateCardCustomAction = z.infer;
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
-const frigateCardConditionSchema = z.object({
+export const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
expand: z.boolean().optional(),
@@ -1316,6 +1318,19 @@ const liveOverridesSchema = z
.optional();
export type LiveOverrides = z.infer;
+const automationActionSchema = actionSchema.array().optional();
+export type AutomationActions = z.infer;
+
+const automationSchema = z.object({
+ conditions: frigateCardConditionSchema,
+ actions: automationActionSchema,
+ actions_not: automationActionSchema,
+});
+export type Automation = z.infer;
+
+export const automationsSchema = automationSchema.array().optional();
+export type Automations = z.infer;
+
const performanceConfigDefault = {
profile: 'high' as const,
features: {
@@ -1392,6 +1407,7 @@ export const frigateCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
+ automations: automationsSchema,
// Configuration overrides.
overrides: overridesSchema,
diff --git a/src/utils/action.ts b/src/utils/action.ts
index e64c0c37..edc700d1 100644
--- a/src/utils/action.ts
+++ b/src/utils/action.ts
@@ -6,7 +6,6 @@ import {
} from 'custom-card-helpers';
import {
Actions,
- ActionsConfig,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
- ...(args.cardID && { card_id: args.cardID})
+ ...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'media_player') {
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
- ...(args.cardID && { card_id: args.cardID})
+ ...(args.cardID && { card_id: args.cardID }),
};
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
- ...(args?.cardID && { card_id: args.cardID})
+ ...(args?.cardID && { card_id: args.cardID }),
};
}
@@ -107,29 +106,6 @@ export function getActionConfigGivenAction(
* that handles the custom action events the card supports.
* @param node The node that fired the event.
* @param hass The Home Assistant object.
- * @param config The multi-action configuration.
- * @param action The action string (e.g. 'hold')
- * @returns Whether or not an action was executed.
- */
-export const frigateCardHandleAction = (
- node: HTMLElement,
- hass: HomeAssistant,
- config: ActionsConfig,
- action: string,
-): boolean => {
- return frigateCardHandleActionConfig(
- node,
- hass,
- config,
- action,
- getActionConfigGivenAction(action, config),
- );
-};
-
-/**
- * Handle an ActionConfig or array of ActionConfigs.
- * @param node The node that fired the event.
- * @param hass The Home Assistant object.
* @param actionConfig A single action config, array of action configs or
* undefined for the default action config for 'tap'.
* @param action The action string (e.g. 'hold')
@@ -143,36 +119,46 @@ export const frigateCardHandleActionConfig = (
entity?: string;
},
action: string,
- actionConfig: ActionType | ActionType[] | undefined,
+ actionConfig?: ActionType | ActionType[],
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
- // ActionConfig vs ActionType:
- // There is a slight typing (but not functional) difference between
- // ActionType in this card and ActionConfig in `custom-card-helpers`. See
- // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
- // reason behind this difference.
- if (Array.isArray(actionConfig)) {
- actionConfig.forEach((action) =>
- handleActionConfig(node, hass, config, action as ActionConfig | undefined),
- );
- } else {
- handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
- }
+ frigateCardHandleAction(node, hass, config, actionConfig);
return true;
}
return false;
};
+export const frigateCardHandleAction = (
+ node: HTMLElement,
+ hass: HomeAssistant,
+ config: {
+ camera_image?: string;
+ entity?: string;
+ },
+ actionConfig: ActionType | ActionType[] | undefined,
+): void => {
+ // ActionConfig vs ActionType:
+ // * There is a slight typing (but not functional) difference between
+ // ActionType in this card and ActionConfig in `custom-card-helpers`. See
+ // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
+ // reason behind this difference.
+ if (Array.isArray(actionConfig)) {
+ actionConfig.forEach((action) =>
+ handleActionConfig(node, hass, config, action as ActionConfig | undefined),
+ );
+ } else {
+ handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
+ }
+};
+
/**
* Determine if an action config has a real action. A modified version of
* custom-card-helpers hasAction to also work with arrays of action configs.
* @param config The action config in question.
* @returns `true` if there's a real action defined, `false` otherwise.
*/
-export const frigateCardHasAction = (
- config?: ActionType | ActionType[] | undefined,
-): boolean => {
+export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is
// necessary and harmless.
if (Array.isArray(config)) {
diff --git a/src/utils/camera.ts b/src/utils/camera.ts
index 2982c501..a9b8c6d7 100644
--- a/src/utils/camera.ts
+++ b/src/utils/camera.ts
@@ -28,12 +28,21 @@ export function getCameraID(
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @param cameraID ID of the target camera.
- * @returns A set of dependent cameraIDs or null.
+ * @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
+ * the first item in the set is guaranteed to be the cameraID itself).
*/
-export const getAllDependentCameras = (
+export function getAllDependentCameras(
+ cameraManager: CameraManager,
+ cameraID: string,
+): Set;
+export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
-): Set | null => {
+): Set | null;
+export function getAllDependentCameras(
+ cameraManager?: CameraManager,
+ cameraID?: string,
+): Set | null {
if (!cameraManager || !cameraID) {
return null;
}
@@ -45,9 +54,7 @@ export const getAllDependentCameras = (
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set = new Set();
- (cameraConfig.dependencies.cameras || []).forEach((item) =>
- dependentCameras.add(item),
- );
+ cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
@@ -62,4 +69,4 @@ export const getAllDependentCameras = (
getDependentCameras(cameraID);
}
return cameraIDs;
-};
+}
diff --git a/src/utils/substream.ts b/src/utils/substream.ts
new file mode 100644
index 00000000..715e022b
--- /dev/null
+++ b/src/utils/substream.ts
@@ -0,0 +1,48 @@
+import { CameraManager } from '../camera-manager/manager';
+import { View } from '../view/view';
+import { getAllDependentCameras } from './camera';
+
+export const createViewWithSelectedSubstream = (
+ view: View,
+ substreamID: string,
+): View | null => {
+ const overrides: Map = view.context?.live?.overrides ?? new Map();
+ overrides.set(view.camera, substreamID);
+ return view.clone().mergeInContext({
+ live: { overrides: overrides },
+ });
+};
+
+export const createViewWithoutSubstream = (view: View): View => {
+ const newView = view.clone();
+ const overrides: Map | undefined = newView.context?.live?.overrides;
+ if (overrides && overrides.has(view.camera)) {
+ newView.context?.live?.overrides?.delete(view.camera);
+ }
+ return newView;
+};
+
+export const hasSubstream = (view: View): boolean => {
+ const override = view?.context?.live?.overrides?.get(view.camera);
+ return !!override && override !== view.camera;
+};
+
+export const createViewWithNextStream = (
+ cameraManager: CameraManager,
+ view: View,
+): View => {
+ const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
+ if (dependencies.length <= 1) {
+ return view.clone();
+ }
+
+ const newView = view.clone();
+ const overrides: Map = newView.context?.live?.overrides ?? new Map();
+ const currentOverride = overrides.get(newView.camera) ?? newView.camera;
+ const currentIndex = dependencies.indexOf(currentOverride);
+ const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
+ overrides.set(view.camera, dependencies[newIndex]);
+ newView.mergeInContext({ live: { overrides: overrides } });
+
+ return newView;
+};
diff --git a/tests/automations.test.ts b/tests/automations.test.ts
new file mode 100644
index 00000000..c5051188
--- /dev/null
+++ b/tests/automations.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { AutomationsController, AutomationsControllerError } from '../src/automations';
+import { ConditionController } from '../src/conditions';
+import { automationsSchema, FrigateCardError } from '../src/types';
+import { frigateCardHandleAction } from '../src/utils/action.js';
+import { createHASS } from './test-utils';
+
+vi.mock('../src/utils/action.js');
+
+describe('AutomationsController', () => {
+ const actions = [
+ {
+ action: 'custom:frigate-card-action',
+ frigate_card_action: 'clips',
+ },
+ ];
+ const conditions = { fullscreen: true };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should do nothing without automations', () => {
+ const automationController = new AutomationsController(undefined);
+ automationController.execute(
+ mock(),
+ createHASS(),
+ new ConditionController(),
+ );
+ expect(frigateCardHandleAction).not.toBeCalled();
+ });
+
+ it('should execute actions', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: conditions,
+ actions: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ const conditionController = new ConditionController();
+ const element = mock();
+ const hass = createHASS();
+
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).not.toBeCalled();
+
+ conditionController.setState({ fullscreen: true });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ // Automation will not re-fire when condition continues to evaluate the
+ // same.
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ conditionController.setState({ fullscreen: false });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ conditionController.setState({ fullscreen: true });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(2);
+ });
+
+ it('should execute actions_not', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: conditions,
+ actions_not: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ automationController.execute(
+ mock(),
+ createHASS(),
+ new ConditionController(),
+ );
+ expect(frigateCardHandleAction).toBeCalled();
+ });
+
+ it('should prevent automation loops', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: { fullscreen: true },
+ actions: actions,
+ },
+ {
+ conditions: { fullscreen: false },
+ actions: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ const conditionController = new ConditionController();
+ const element = mock();
+ const hass = createHASS();
+
+ // Create a setup where one automation action causes another...
+ let fullscreen = true;
+ vi.mocked(frigateCardHandleAction).mockImplementation(() => {
+ fullscreen = !fullscreen;
+ conditionController.setState({ fullscreen: fullscreen });
+ automationController.execute(element, hass, conditionController);
+ });
+
+ conditionController.setState({ fullscreen: fullscreen });
+
+ expect(() =>
+ automationController.execute(element, hass, conditionController),
+ ).toThrowError(/Too many nested automation calls/);
+ expect(frigateCardHandleAction).toBeCalledTimes(10);
+ });
+
+ it('should be able to construct error', () => {
+ const error = new AutomationsControllerError('message');
+ expect(error).toBeTruthy();
+ expect(error instanceof FrigateCardError).toBeTruthy();
+ });
+});
diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts
index 0768422d..24ebde87 100644
--- a/tests/camera-manager/engine-factory.test.ts
+++ b/tests/camera-manager/engine-factory.test.ts
@@ -1,17 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
-import { mock } from 'vitest-mock-extended';
+import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
+import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
+import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
+import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
+import { Engine } from '../../src/camera-manager/types.js';
+import { CardWideConfig } from '../../src/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
-import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
-import { CameraConfig, cameraConfigSchema, CardWideConfig } from '../../src/types.js';
-import { HomeAssistant } from 'custom-card-helpers';
-import { Engine } from '../../src/camera-manager/types.js';
-import { Entity } from '../../src/utils/ha/entity-registry/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
-import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
-import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
-import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
-import { HassEntities } from 'home-assistant-js-websocket';
+import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache');
@@ -28,32 +25,6 @@ const createFactory = (options?: {
);
};
-const createCameraConfig = (config: Partial): CameraConfig => {
- return cameraConfigSchema.parse(config);
-};
-
-const createHASS = (states?: HassEntities): HomeAssistant => {
- const hass = mock();
- if (states) {
- hass.states = states;
- }
- return hass;
-};
-
-const createEntity = (entity: Partial): Entity => {
- return {
- ...entity,
- config_entry_id: entity.config_entry_id ?? null,
- device_id: entity.device_id ?? null,
- disabled_by: entity.disabled_by ?? null,
- entity_id: entity.entity_id ?? 'entity_id',
- hidden_by: entity.hidden_by ?? null,
- platform: entity.platform ?? 'platform',
- translation_key: entity.translation_key ?? null,
- unique_id: entity.unique_id ?? 'unique_id',
- };
-};
-
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
it('should get frigate engine from config', async () => {
const config = createCameraConfig({ engine: 'frigate' });
@@ -79,7 +50,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
- .mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
+ .mockResolvedValue(
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
+ );
expect(
await createFactory({
@@ -94,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
- createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
);
expect(
@@ -109,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
- .mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
+ .mockResolvedValue(
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
+ );
expect(
await createFactory({
diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts
new file mode 100644
index 00000000..3c98db99
--- /dev/null
+++ b/tests/conditions.test.ts
@@ -0,0 +1,350 @@
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import {
+ ConditionController,
+ ConditionEvaluateRequestEvent,
+ evaluateConditionViaEvent,
+ getOverriddenConfig,
+ getOverridesByKey,
+} from '../src/conditions';
+import { createCondition, createConfig, createStateEntity } from './test-utils';
+
+// @vitest-environment jsdom
+describe('ConditionEvaluateRequestEvent', () => {
+ it('should construct', () => {
+ const condition = createCondition({ fullscreen: true });
+ const event = new ConditionEvaluateRequestEvent(condition, {
+ bubbles: true,
+ composed: true,
+ });
+
+ expect(event.type).toBe('frigate-card:condition:evaluate');
+ expect(event.condition).toBe(condition);
+ expect(event.bubbles).toBeTruthy();
+ expect(event.composed).toBeTruthy();
+ });
+});
+
+describe('evaluateConditionViaEvent', () => {
+ it('should evaluate true without condition', () => {
+ const element = document.createElement('div');
+ expect(evaluateConditionViaEvent(element)).toBeTruthy();
+ });
+ it('should dispatch event with condition and evaluate true', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
+ expect(ev.condition).toBe(condition);
+ ev.evaluation = true;
+ });
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeTruthy();
+ expect(handler).toBeCalled();
+ });
+ it('should dispatch event with condition and evaluate false', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
+ expect(ev.condition).toBe(condition);
+ ev.evaluation = false;
+ });
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
+ expect(handler).toBeCalled();
+ });
+ it('should dispatch event evaluate false if no evaluation', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn();
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
+ expect(handler).toBeCalled();
+ });
+});
+
+describe('getOverriddenConfig', () => {
+ const config = {
+ menu: {
+ style: 'none',
+ },
+ };
+ const overrides = [
+ {
+ overrides: {
+ menu: {
+ style: 'above',
+ },
+ },
+ conditions: {
+ fullscreen: true,
+ },
+ },
+ ];
+
+ it('should not override config', () => {
+ const controller = new ConditionController();
+ expect(getOverriddenConfig(controller, config, overrides)).toBe(config);
+ });
+
+ it('should override config', () => {
+ const controller = new ConditionController();
+ controller.setState({ fullscreen: true });
+
+ expect(getOverriddenConfig(controller, config, overrides)).toEqual({
+ menu: {
+ style: 'above',
+ },
+ });
+ });
+});
+
+describe('getOverridesByKey', () => {
+ const condition = {
+ fullscreen: true,
+ };
+ const override = {
+ menu: {
+ style: 'above',
+ },
+ };
+ const overrides = [
+ {
+ overrides: override,
+ conditions: condition,
+ },
+ ];
+
+ it('should get overrides', () => {
+ expect(getOverridesByKey('menu', overrides)).toEqual([
+ { conditions: condition, overrides: { style: 'above' } },
+ ]);
+ });
+
+ it('should get no overrides', () => {
+ expect(getOverridesByKey('live', overrides)).toEqual([]);
+ });
+
+ it('should get no overrides when undefined', () => {
+ expect(getOverridesByKey('live')).toEqual([]);
+ });
+});
+
+describe('ConditionController', () => {
+ const config = {
+ type: 'custom:frigate-card',
+ cameras: [],
+ elements: [
+ {
+ type: 'custom:frigate-card-conditional',
+ conditions: {
+ fullscreen: true,
+ },
+ elements: [
+ {
+ type: 'custom:nested-unknown-object',
+ unknown_key: {
+ type: 'custom:frigate-card-conditional',
+ conditions: {
+ media_query: 'media query goes here',
+ },
+ elements: [],
+ },
+ },
+ ],
+ },
+ ],
+ overrides: [
+ {
+ overrides: {
+ menu: {
+ style: 'overlay',
+ },
+ },
+ conditions: {
+ fullscreen: true,
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state: 'on',
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should add listener', () => {
+ const controller = new ConditionController();
+ const handler = vi.fn();
+ controller.addStateListener(handler);
+ controller.setState({ fullscreen: true });
+ expect(handler).toBeCalled();
+ });
+
+ it('should remove listener', () => {
+ const controller = new ConditionController();
+ const handler = vi.fn();
+ controller.addStateListener(handler);
+ controller.removeStateListener(handler);
+ controller.setState({ fullscreen: true });
+ expect(handler).not.toBeCalled();
+ });
+
+ it('should get wrapper', () => {
+ const controller = new ConditionController();
+ const wrapper_1 = controller.getEpoch();
+ expect(wrapper_1).toEqual({ controller: controller });
+
+ controller.setState({ fullscreen: true });
+
+ const wrapper_2 = controller.getEpoch();
+ expect(wrapper_2).toEqual({ controller: controller });
+
+ // Since the state was set the wrappers should be different.
+ expect(wrapper_1).not.toBe(wrapper_2);
+ });
+
+ it('should not return hasHAStateConditions without HA state conditions', () => {
+ const controller = new ConditionController();
+ expect(controller.hasHAStateConditions).toBeFalsy();
+ });
+
+ it('should return hasHAStateConditions with HA state conditions', () => {
+ vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
+ matches: false,
+ addEventListener: vi.fn(),
+ } as unknown as MediaQueryList);
+ const controller = new ConditionController(createConfig(config));
+ expect(controller.hasHAStateConditions).toBeTruthy();
+ });
+
+ it('should evaluate conditions with a view', () => {
+ const controller = new ConditionController();
+ const condition = { view: ['foo'] };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ view: 'foo' });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ });
+
+ it('should evaluate conditions with fullscreen', () => {
+ const controller = new ConditionController();
+ const condition = { fullscreen: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ fullscreen: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ fullscreen: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with expand', () => {
+ const controller = new ConditionController();
+ const condition = { expand: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ expand: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ expand: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with camera', () => {
+ const controller = new ConditionController();
+ const condition = { camera: ['bar'] };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ camera: 'bar' });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ camera: 'will-not-match' });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with ha state positive check', () => {
+ const controller = new ConditionController();
+ const condition = {
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state: 'on',
+ },
+ ],
+ };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({
+ state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
+ });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with ha state negative check', () => {
+ const controller = new ConditionController();
+ const condition = {
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state_not: 'on',
+ },
+ ],
+ };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({
+ state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
+ });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ });
+
+ it('should evaluate conditions with media_loaded', () => {
+ const controller = new ConditionController();
+ const condition = { media_loaded: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ media_loaded: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ media_loaded: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with media query', () => {
+ vi.spyOn(window, 'matchMedia')
+ .mockReturnValueOnce({ matches: true })
+ .mockReturnValueOnce({ matches: false });
+
+ const controller = new ConditionController();
+ const condition = { media_query: 'whatever' };
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should trigger on changes to media query conditions', () => {
+ const addEventListener = vi.fn();
+ const removeEventListener = vi.fn();
+ vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
+ matches: true,
+ addEventListener: addEventListener,
+ removeEventListener: removeEventListener,
+ } as unknown as MediaQueryList);
+
+ const controller = new ConditionController(createConfig(config));
+ expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
+
+ const callback = vi.fn();
+ controller.addStateListener(callback);
+
+ // Call the media query callback and use it to pretend a match happened. The
+ // callback is the 0th mock innvocation and the 1st argument.
+ addEventListener.mock.calls[0][1]();
+
+ // This should result in a callback to our state listener.
+ expect(callback).toBeCalled();
+
+ // Destroy the controller, which should remove the media query listener.
+ controller.destroy();
+ expect(removeEventListener).toBeCalled();
+ });
+});
diff --git a/tests/test-utils.ts b/tests/test-utils.ts
new file mode 100644
index 00000000..7e14ebdc
--- /dev/null
+++ b/tests/test-utils.ts
@@ -0,0 +1,62 @@
+import { HomeAssistant } from 'custom-card-helpers';
+import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
+import { mock } from 'vitest-mock-extended';
+import {
+ CameraConfig,
+ FrigateCardCondition,
+ FrigateCardConfig,
+ cameraConfigSchema,
+ frigateCardConditionSchema,
+ frigateCardConfigSchema,
+} from '../src/types';
+import { Entity } from '../src/utils/ha/entity-registry/types';
+
+export const createCameraConfig = (config: unknown): CameraConfig => {
+ return cameraConfigSchema.parse(config);
+};
+
+export const createCondition = (
+ condition?: Partial,
+): FrigateCardCondition => {
+ return frigateCardConditionSchema.parse(condition ?? {});
+};
+
+export const createConfig = (config?: Partial): FrigateCardConfig => {
+ return frigateCardConfigSchema.parse(config);
+};
+
+export const createHASS = (states?: HassEntities): HomeAssistant => {
+ const hass = mock();
+ if (states) {
+ hass.states = states;
+ }
+ return hass;
+};
+
+export const createRegistryEntity = (entity?: Partial): Entity => {
+ return {
+ config_entry_id: entity?.config_entry_id ?? null,
+ device_id: entity?.device_id ?? null,
+ disabled_by: entity?.disabled_by ?? null,
+ entity_id: entity?.entity_id ?? 'entity_id',
+ hidden_by: entity?.hidden_by ?? null,
+ platform: entity?.platform ?? 'platform',
+ translation_key: entity?.translation_key ?? null,
+ unique_id: entity?.unique_id ?? 'unique_id',
+ };
+};
+
+export const createStateEntity = (entity?: Partial): HassEntity => {
+ return {
+ entity_id: entity?.entity_id ?? 'entity_id',
+ state: entity?.state ?? 'on',
+ last_changed: entity?.last_changed ?? 'never',
+ last_updated: entity?.last_updated ?? 'never',
+ attributes: entity?.attributes ?? {},
+ context: entity?.context ?? {
+ id: 'id',
+ parent_id: 'parent_id',
+ user_id: 'user_id',
+ },
+ };
+};
diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts
new file mode 100644
index 00000000..264db819
--- /dev/null
+++ b/tests/utils/action.test.ts
@@ -0,0 +1,202 @@
+import { handleActionConfig, hasAction } from 'custom-card-helpers';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { actionSchema } from '../../src/types';
+import {
+ convertActionToFrigateCardCustomAction,
+ createFrigateCardCustomAction,
+ frigateCardHandleActionConfig,
+ frigateCardHasAction,
+ getActionConfigGivenAction,
+ stopEventFromActivatingCardWideActions,
+} from '../../src/utils/action';
+import { createHASS } from '../test-utils';
+
+vi.mock('custom-card-helpers');
+
+describe('convertActionToFrigateCardCustomAction', () => {
+ it('should skip null action', () => {
+ expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy();
+ });
+
+ it('should parse valid', () => {
+ expect(
+ convertActionToFrigateCardCustomAction({
+ action: 'custom:frigate-card-action',
+ frigate_card_action: 'download',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'download',
+ });
+ });
+
+ it('should not parse invalid', () => {
+ expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull();
+ });
+});
+
+describe('createFrigateCardCustomAction', () => {
+ it('should create camera_select', () => {
+ expect(
+ createFrigateCardCustomAction('camera_select', {
+ camera: 'camera',
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ camera: 'camera',
+ frigate_card_action: 'camera_select',
+ card_id: 'card_id',
+ });
+ });
+
+ it('should not create camera_select without camera', () => {
+ expect(createFrigateCardCustomAction('camera_select')).toBeNull();
+ });
+
+ it('should create media_player', () => {
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player: 'device',
+ media_player_action: 'play',
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'media_player',
+ media_player: 'device',
+ media_player_action: 'play',
+ card_id: 'card_id',
+ });
+ });
+
+ it('should not create media_player without player or action', () => {
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player_action: 'play',
+ }),
+ ).toBeNull();
+
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player: 'device',
+ }),
+ ).toBeNull();
+ });
+
+ it('should create general action', () => {
+ expect(
+ createFrigateCardCustomAction('clips', {
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'clips',
+ card_id: 'card_id',
+ });
+ });
+});
+
+describe('getActionConfigGivenAction', () => {
+ const action = actionSchema.parse({
+ action: 'fire-dom-event',
+ frigate_card_action: 'clips',
+ });
+
+ it('should not handle undefined arguments', () => {
+ expect(getActionConfigGivenAction()).toBeUndefined();
+ });
+
+ it('should not handle unknown interactions', () => {
+ expect(
+ getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
+ ).toBeUndefined();
+ });
+
+ it('should handle tap actions', () => {
+ expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
+ });
+
+ it('should handle hold actions', () => {
+ expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
+ });
+
+ it('should handle double_tap actions', () => {
+ expect(getActionConfigGivenAction('double_tap', { double_tap_action: action })).toBe(
+ action,
+ );
+ });
+
+ it('should handle end_tap actions', () => {
+ expect(getActionConfigGivenAction('end_tap', { end_tap_action: action })).toBe(
+ action,
+ );
+ });
+
+ it('should handle start_tap actions', () => {
+ expect(getActionConfigGivenAction('start_tap', { start_tap_action: action })).toBe(
+ action,
+ );
+ });
+});
+
+// @vitest-environment jsdom
+describe('frigateCardHandleActionConfig', () => {
+ const element = document.createElement('div');
+ const action = actionSchema.parse({
+ action: 'none',
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not handle missing arguments', () => {
+ expect(
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'),
+ ).toBeFalsy();
+ });
+
+ it('should handle simple case', () => {
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
+ expect(handleActionConfig).toBeCalled();
+ });
+
+ it('should handle array case', () => {
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
+ action,
+ action,
+ action,
+ ]);
+ expect(handleActionConfig).toBeCalledTimes(3);
+ });
+});
+
+describe('frigateCardHasAction', () => {
+ const action = actionSchema.parse({
+ action: 'toggle',
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should handle non-array case', () => {
+ expect(frigateCardHasAction(action)).toBeFalsy();
+ expect(hasAction).toBeCalledTimes(1);
+ });
+ it('should handle array case', () => {
+ expect(frigateCardHasAction([action, action, action])).toBeFalsy();
+ expect(hasAction).toBeCalledTimes(3);
+ });
+});
+
+// @vitest-environment jsdom
+describe('stopEventFromActivatingCardWideActions', () => {
+ it('should stop event from propogating', () => {
+ const event = mock();
+ stopEventFromActivatingCardWideActions(event);
+ expect(event.stopPropagation).toBeCalled();
+ });
+});
diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts
new file mode 100644
index 00000000..1b7c9689
--- /dev/null
+++ b/tests/utils/camera.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
+import { CameraManager } from '../../src/camera-manager/manager.js';
+import { CameraManagerStore } from '../../src/camera-manager/store.js';
+import { CameraConfigs } from '../../src/camera-manager/types.js';
+import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js';
+import { createCameraConfig } from '../test-utils.js';
+
+vi.mock('../../src/camera-manager/manager.js');
+
+describe('getCameraID', () => {
+ it('should get camera id with id', () => {
+ const config = createCameraConfig({ id: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with camera_entity', () => {
+ const config = createCameraConfig({ camera_entity: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with webrtc entity', () => {
+ const config = createCameraConfig({ webrtc_card: { entity: 'foo' } });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with frigate camera_name', () => {
+ const config = createCameraConfig({
+ frigate: { client_id: 'bar', camera_name: 'foo' },
+ });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get blank id without anything', () => {
+ const config = createCameraConfig({});
+ expect(getCameraID(config)).toBe('');
+ });
+});
+
+describe('getAllDependentCameras', () => {
+ it('should return null without cameraManager', () => {
+ expect(getAllDependentCameras()).toBeNull();
+ });
+ it('should return null without cameraID', () => {
+ expect(getAllDependentCameras(mock())).toBeNull();
+ });
+ it('should return dependent cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ cameras: ['two', 'three'],
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+ it('should return all cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ all_cameras: true,
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+});
diff --git a/tests/utils/debug.test.ts b/tests/utils/debug.test.ts
new file mode 100644
index 00000000..7a3b8f2e
--- /dev/null
+++ b/tests/utils/debug.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it, vi } from 'vitest';
+import { log } from '../../src/utils/debug.js';
+
+describe('log', () => {
+ it('should do nothing without debug logging set', () => {
+ const spy = vi.spyOn(global.console, 'debug');
+ log({}, 'foo');
+ expect(spy).not.toBeCalled();
+ });
+ it('should log debug when appropriately configured', () => {
+ const spy = vi.spyOn(global.console, 'debug');
+ log({ debug: { logging: true } }, 'foo');
+ expect(spy).toBeCalledWith('foo');
+ });
+});
diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts
new file mode 100644
index 00000000..9132c764
--- /dev/null
+++ b/tests/utils/substream.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CameraManager } from '../../src/camera-manager/manager';
+import { getAllDependentCameras } from '../../src/utils/camera';
+import {
+ createViewWithNextStream,
+ createViewWithSelectedSubstream,
+ createViewWithoutSubstream,
+ hasSubstream,
+} from '../../src/utils/substream';
+import { View } from '../../src/view/view';
+
+vi.mock('../../src/utils/camera');
+
+describe('createViewWithSelectedSubstream', () => {
+ it('should create view with selected substream', () => {
+ const view = new View({ view: 'live', camera: 'camera' });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+
+ it('should create view with selected substream with existing overrides', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+});
+
+describe('createViewWithoutSubstream', () => {
+ it('should create view without substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithoutSubstream(view);
+ expect(newView?.context?.live?.overrides).toEqual(new Map());
+ });
+});
+
+describe('hasSubstream', () => {
+ it('should detect substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeTruthy();
+ });
+ it('should not detect substream when absent', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+ it('should not detect substream when main stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+});
+
+describe('createViewWithNextStream', () => {
+ it('should create new equal view with no dependencies', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.camera).toBe(view.camera);
+ expect(newView.view).toBe(view.view);
+ expect(newView.context).toEqual(view.context);
+ });
+ it('should create new view with next stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
+ });
+ it('should create new view with next stream that cycles back', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+ it('should create new view with first stream with invalid substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera-that-does-not-exist']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+});