Initial version of more advanced scan mode control.

This commit is contained in:
Dermot Duffy
2024-01-17 22:07:30 -08:00
parent ad1c323cb0
commit 278f4f372d
24 changed files with 648 additions and 327 deletions
+29 -6
View File
@@ -421,20 +421,43 @@ view:
scan: scan:
``` ```
Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when an entity changes to an active state (specifically `on` or `open`). The entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`). Scan mode allows the card to automatically "follow the action". In this mode the card will automatically execute an action (defined by `trigger_action`) when a camera is 'triggered', by default selecting that camera in the `live` view . The trigger entities considered are defined by your camera configuration (see `triggers` parameters). An untrigger is defined as the state for all the configured entities returning to inactive (i.e. not `on` or `open`), with an optional number of seconds to wait prior to the untriggering (see `untrigger_seconds`).
When the camera untriggers, the view will either remain as-is (if `untrigger_reset` is `false`) and the card return to normal operation, or reset to the default view (if `untrigger_reset` is `true` -- the default). When the camera untriggers, a different action (defined by `untrigger_action`) is automatically executed, by default returning the card to the default view and camera.
Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. By default, triggering is only allowed when there is no ongoing human interaction with the card. This behavior can be controlled by the `interaction_mode` parameter.
Scan mode tracks Home Assistant state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room will). Scan mode tracks Home Assistant state *changes* -- when the card is first started, it takes an active change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room will).
| Option | Default | Overridable | Description | | Option | Default | Overridable | Description |
| - | - | - | - | | - | - | - | - |
| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. | | `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. |
| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | | `interaction_mode` | `inactive` | :white_check_mark: | Whether actions should be taken when the card is being interacted with. If `all`, actions will always be taken regardless. If `inactive` actions will only be taken if the card has *not* had human interaction recently (as defined by `view.timeout_seconds`). If `active` actions will only be taken if the card *has* had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `trigger_show_status` is true) but rather just prevents the actions being performed.|
| `trigger_filter_camera` | `all` | :white_check_mark: | If set to `all` the camera will be triggered regardless of which camera is currently selected, if set to `selected` the camera will only trigger if that camera is already selected.|
| `trigger_show_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). |
| `trigger_action` | Selects triggered camera in `live` view (see below) | :white_check_mark: | An action or list of actions that are executed when a camera is triggered. May be set to `null` for no action.|
| `untrigger_reset` | `true` | :white_check_mark: | Whether or not to reset the view to the default after untriggering. | | `untrigger_reset` | `true` | :white_check_mark: | Whether or not to reset the view to the default after untriggering. |
| `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after all entities are inactive before untriggering. | | `untrigger_seconds` | `0` | :white_check_mark: | The number of seconds to wait after all entities are inactive before untriggering. |
| `untrigger_action` | Selects default view and camera (see below) | :white_check_mark: | An action or list of actions that are executed when a camera is triggered. May bet to `null` for no action.|
##### View: Scan Mode default actions
The default `trigger_action` is:
```yaml
- action: custom:frigate-card-action
frigate_card_action: camera_select
triggered: true
- action: custom:frigate-card-action
frigate_card_action: live
```
The default `untrigger_action` is:
```yaml
- action: custom:frigate-card-action
frigate_card_action: default
```
### Menu Options ### Menu Options
@@ -1422,7 +1445,7 @@ Parameters for the `custom:frigate-card-conditional` element:
|`download`|Download the displayed media.| |`download`|Download the displayed media.|
|`camera_ui`|Open the Frigate UI at the configured URL.| |`camera_ui`|Open the Frigate UI at the configured URL.|
|`fullscreen`|Toggle fullscreen.| |`fullscreen`|Toggle fullscreen.|
|`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera.| |`camera_select`|Select a given camera. Takes an additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select. Respects the value of `view.camera_select` to choose the appropriate view on the new camera. If a `triggered` parameter is set to `true` instead of `camera` being specified then a triggered camera (if any) is selected instead. |
|`menu_toggle` | Show/hide the menu (for the `hidden` mode style). | |`menu_toggle` | Show/hide the menu (for the `hidden` mode style). |
|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. | |`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. |
|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. | |`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. |
@@ -1912,7 +1935,7 @@ view:
dark_mode: 'off' dark_mode: 'off'
scan: scan:
enabled: false enabled: false
show_trigger_status: true trigger_show_status: true
untrigger_reset: true untrigger_reset: true
untrigger_seconds: 0 untrigger_seconds: 0
actions: actions:
+30 -4
View File
@@ -1,11 +1,13 @@
import { import {
Actions, Actions,
ActionsConfig, ActionsConfig,
ActionType,
FrigateCardCustomAction, FrigateCardCustomAction,
FRIGATE_CARD_VIEW_DEFAULT, FRIGATE_CARD_VIEW_DEFAULT,
} from '../config/types.js'; } from '../config/types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
getActionConfigGivenAction, getActionConfigGivenAction,
} from '../utils/action.js'; } from '../utils/action.js';
@@ -81,16 +83,36 @@ export class ActionsManager {
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
if (frigateCardAction) { if (frigateCardAction) {
this.executeAction(frigateCardAction); this.executeFrigateAction(frigateCardAction);
} }
}; };
/**
* Small convenience method to call frigateCardHandleAction without the caller
* needing hass or the element.
*/
public executeActions(actions: ActionType | ActionType[]): void {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
frigateCardHandleAction(
this._api.getCardElementManager().getElement(),
hass,
{},
actions,
);
}
/** /**
* Execute a card action. * Execute a card action.
* @param frigateCardAction * @param frigateCardAction
* @returns `true` if an action is executed. * @returns `true` if an action is executed.
*/ */
public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise<void> { public async executeFrigateAction(
frigateCardAction: FrigateCardCustomAction,
): Promise<void> {
const config = this._api.getConfigManager().getConfig(); const config = this._api.getConfigManager().getConfig();
const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager(); const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager();
@@ -146,8 +168,12 @@ export class ActionsManager {
this._api.getCardElementManager().toggleMenu(); this._api.getCardElementManager().toggleMenu();
break; break;
case 'camera_select': case 'camera_select':
const selectCameraID = frigateCardAction.camera; const selectCameraID =
if (view) { frigateCardAction.camera ??
(frigateCardAction.triggered
? this._api.getTriggersManager().getMostRecentlyTriggeredCameraID()
: null);
if (selectCameraID && view) {
const viewOnCameraSelect = config?.view.camera_select ?? 'current'; const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName = const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect; viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
+1 -1
View File
@@ -74,7 +74,7 @@ export class HASSManager {
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
} }
this._api.getTriggersManager().updateTriggeredCameras(oldHass); this._api.getTriggersManager().updateTriggerHAState(oldHass);
if (this._api.getConditionsManager().hasHAStateConditions()) { if (this._api.getConditionsManager().hasHAStateConditions()) {
this._api.getConditionsManager().setState({ state: this._hass.states }); this._api.getConditionsManager().setState({ state: this._hass.states });
@@ -27,9 +27,6 @@ export class InteractionManager {
protected _reportInteraction(): void { protected _reportInteraction(): void {
this._timer.stop(); this._timer.stop();
// Interactions reset the trigger state.
this._api.getTriggersManager().untrigger();
const timeoutSeconds = this._api.getConfigManager().getConfig() const timeoutSeconds = this._api.getConfigManager().getConfig()
?.view.timeout_seconds; ?.view.timeout_seconds;
+5 -39
View File
@@ -1,5 +1,8 @@
import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types'; import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types';
import { createFrigateCardCameraAction, createFrigateCardSimpleAction } from '../utils/action.js'; import {
createFrigateCardCameraAction,
createFrigateCardSimpleAction
} from '../utils/action.js';
import { CardQueryStringAPI } from './types'; import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager'; import { ViewManagerSetViewParameters } from './view-manager';
@@ -35,43 +38,6 @@ export class QueryStringManager {
this._executeNonViewRelated(intent); this._executeNonViewRelated(intent);
}; };
public generateQueryString(action: FrigateCardCustomAction): string | null {
const baseKey =
'frigate-card-action.' + (action.card_id ? `${action.card_id}.` : '');
switch (action.frigate_card_action) {
case 'camera_select':
case 'live_substream_select':
return new URLSearchParams([
[baseKey + action.frigate_card_action, action.camera],
]).toString();
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return new URLSearchParams([
[baseKey + action.frigate_card_action, ''],
]).toString();
default:
console.warn(
`Frigate card cannot convert unsupported action to query string:`,
action,
);
}
return null;
}
protected _executeViewRelated(intent: QueryStringViewIntent): void { protected _executeViewRelated(intent: QueryStringViewIntent): void {
if (intent.view) { if (intent.view) {
if (intent.view.default) { if (intent.view.default) {
@@ -96,7 +62,7 @@ export class QueryStringManager {
} }
intent.other?.forEach((action) => intent.other?.forEach((action) =>
this._api.getActionsManager().executeAction(action), this._api.getActionsManager().executeFrigateAction(action),
); );
} }
+94 -60
View File
@@ -2,30 +2,44 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import { getHassDifferences, isTriggeredState } from '../utils/ha'; import { getHassDifferences, isTriggeredState } from '../utils/ha';
import { Timer } from '../utils/timer'; import { Timer } from '../utils/timer';
import { View } from '../view/view';
import { CardTriggersAPI } from './types'; import { CardTriggersAPI } from './types';
export class TriggersManager { export class TriggersManager {
protected _api: CardTriggersAPI; protected _api: CardTriggersAPI;
protected _triggers: Map<string, Date> = new Map(); protected _triggeredCameras: Map<string, Date> = new Map();
protected _untriggerTimer = new Timer(); protected _triggeredCameraTimers: Map<string, Timer> = new Map();
protected _triggeredState: Set<string> = new Set();
constructor(api: CardTriggersAPI) { constructor(api: CardTriggersAPI) {
this._api = api; this._api = api;
} }
public isTriggered(): boolean { public getTriggeredCameraIDs(): Set<string> {
return !!this._triggers.size || this._untriggerTimer.isRunning(); return new Set(this._triggeredCameras.keys());
} }
public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean { public isTriggered(): boolean {
if (!this._shouldTrackTriggers()) { return !!this._triggeredCameras.size;
return false; }
public getMostRecentlyTriggeredCameraID(): string | null {
const sorted = orderBy(
[...this._triggeredCameras.entries()],
(entry) => entry[1].getTime(),
'desc',
);
return sorted.length ? sorted[0][0] : null;
}
public updateTriggerHAState(oldHass?: HomeAssistant | null): void {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
if (!scanConfig || !scanConfig.enabled) {
return;
} }
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
const now = new Date();
let triggerChanges = false; let triggerChanges = false;
const visibleCameraIDs = this._api const visibleCameraIDs = this._api
@@ -45,88 +59,108 @@ export class TriggersManager {
(entity) => !isTriggeredState(hass?.states[entity]), (entity) => !isTriggeredState(hass?.states[entity]),
); );
if (shouldTrigger) { if (shouldTrigger) {
this._triggers.set(cameraID, now); this._triggeredState.add(cameraID);
triggerChanges = true; triggerChanges = true;
} else if (shouldUntrigger && this._triggers.has(cameraID)) { } else if (shouldUntrigger && this._triggeredState.has(cameraID)) {
this._triggers.delete(cameraID); this._triggeredState.delete(cameraID);
triggerChanges = true; triggerChanges = true;
} }
} }
if (triggerChanges) { if (triggerChanges) {
const targetCameraID = this._getMostRecentTrigger(); this._evaluateTriggers();
if (targetCameraID) {
this._triggerAction(targetCameraID);
return true;
} else {
this._startUntriggerTimer();
}
}
return false;
}
public untrigger(): void {
const wasTriggered = this.isTriggered();
this._triggers.clear();
this._untriggerTimer.stop();
if (wasTriggered) {
this._untriggerAction();
} }
} }
protected _triggerAction(cameraID: string): void { public updateView(oldView?: View | null): void {
const view = this._api.getViewManager().getView(); if (oldView?.camera !== this._api.getViewManager().getView()?.camera) {
// If the view changes, a new camera may have been selected, which may
// mean a trigger is required (in the case that `trigger_filter_camera`
// has been set to `selected`).
this._evaluateTriggers();
}
}
protected _evaluateTriggers(): void {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
if (!scanConfig) {
return;
}
const now = new Date();
for (const cameraID of this._triggeredState.keys()) {
if ( if (
this._isAutomatedViewUpdateAllowed() && !this._triggeredCameras.has(cameraID) &&
(view?.camera !== cameraID || !view?.is('live')) (scanConfig.trigger_filter_camera === 'all' ||
(scanConfig.trigger_filter_camera === 'selected' &&
cameraID === this._api.getViewManager().getView()?.camera))
) { ) {
this._api.getViewManager().setViewByParameters({ this._triggeredCameras.set(cameraID, now);
viewName: 'live', this._triggerAction();
cameraID: cameraID,
});
} }
} }
protected _untriggerAction(): void { for (const cameraID of this._triggeredCameras.keys()) {
if ( if (!this._triggeredState.has(cameraID)) {
!this.isTriggered() && this._startUntriggerTimer(cameraID);
this._isAutomatedViewUpdateAllowed() && }
this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset
) {
this._api.getViewManager().setViewDefault();
} }
} }
protected _isAutomatedViewUpdateAllowed(): boolean { protected _hasAllowableInteractionState(): boolean {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
const hasInteraction = this._api.getInteractionManager().hasInteraction();
return ( return (
this._api.getConfigManager().getConfig()?.view.update_force || !!scanConfig &&
!this._api.getInteractionManager().hasInteraction() (scanConfig.interaction_mode === 'all' ||
(scanConfig.interaction_mode === 'active' && hasInteraction) ||
(scanConfig.interaction_mode === 'inactive' && !hasInteraction))
); );
} }
protected _shouldTrackTriggers(): boolean { protected _triggerAction(): void {
return !!this._api.getConfigManager().getConfig()?.view.scan.enabled; const action = this._api.getConfigManager().getConfig()?.view.scan.trigger_action;
if (action && this._hasAllowableInteractionState()) {
this._api.getActionsManager().executeActions(action);
} }
protected _startUntriggerTimer(): void { // Must update master element to add border pulsing.
this._untriggerTimer.start( this._api.getCardElementManager().update();
}
protected _untriggerAction(cameraID: string): void {
const action = this._api.getConfigManager().getConfig()?.view.scan.untrigger_action;
if (action && this._hasAllowableInteractionState()) {
this._api.getActionsManager().executeActions(action);
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
// Must update master element to remove border pulsing.
this._api.getCardElementManager().update();
}
protected _startUntriggerTimer(cameraID: string): void {
this._deleteTimer(cameraID);
const timer = new Timer();
this._triggeredCameraTimers.set(cameraID, timer);
timer.start(
/* istanbul ignore next: the case of config being null here cannot be /* istanbul ignore next: the case of config being null here cannot be
reached, as there's no way to have the untrigger call happen without reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */ a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0, this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0,
() => { () => {
this._untriggerAction(); this._untriggerAction(cameraID);
}, },
); );
} }
protected _getMostRecentTrigger(): string | null { protected _deleteTimer(cameraID: string): void {
const sorted = orderBy( this._triggeredCameraTimers.get(cameraID)?.stop();
[...this._triggers.entries()], this._triggeredCameraTimers.delete(cameraID);
(entry) => entry[1].getTime(),
'desc',
);
return sorted.length ? sorted[0][0] : null;
} }
} }
+17 -13
View File
@@ -41,8 +41,9 @@ export interface CardActionsManagerAPI {
getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager; getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getViewManager(): ViewManager;
getMicrophoneManager(): MicrophoneManager; getMicrophoneManager(): MicrophoneManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
} }
export interface CardAutomationsAPI { export interface CardAutomationsAPI {
@@ -55,17 +56,17 @@ export interface CardAutomationsAPI {
export interface CardAutoRefreshAPI { export interface CardAutoRefreshAPI {
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getViewManager(): ViewManager;
getTriggersManager(): TriggersManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
} }
export interface CardCameraAPI { export interface CardCameraAPI {
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager; getEntityRegistryManager(): EntityRegistryManager;
getResolvedMediaCache(): ResolvedMediaCache;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getResolvedMediaCache(): ResolvedMediaCache;
} }
export interface CardCameraURLAPI { export interface CardCameraURLAPI {
@@ -106,15 +107,15 @@ export interface CardElementAPI {
} }
export interface CardExpandAPI { export interface CardExpandAPI {
getFullscreenManager(): FullscreenManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getFullscreenManager(): FullscreenManager;
} }
export interface CardFullscreenAPI { export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getExpandManager(): ExpandManager;
getConditionsManager(): ConditionsManager; getConditionsManager(): ConditionsManager;
getExpandManager(): ExpandManager;
getMediaPlayerManager(): MediaPlayerManager; getMediaPlayerManager(): MediaPlayerManager;
} }
@@ -153,23 +154,23 @@ export interface CardInteractionAPI {
} }
export interface CardMediaLoadedAPI { export interface CardMediaLoadedAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager; getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getCardElementManager(): CardElementManager;
getStyleManager(): StyleManager; getStyleManager(): StyleManager;
} }
export interface CardMediaPlayerAPI { export interface CardMediaPlayerAPI {
getHASSManager(): HASSManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getEntityRegistryManager(): EntityRegistryManager; getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager; getQueryStringManager(): QueryStringManager;
} }
export interface CardMessageAPI { export interface CardMessageAPI {
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager;
} }
@@ -179,9 +180,9 @@ export interface CardMicrophoneAPI {
} }
export interface CardQueryStringAPI { export interface CardQueryStringAPI {
getActionsManager(): ActionsManager;
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
getActionsManager(): ActionsManager;
} }
export interface CardStyleAPI { export interface CardStyleAPI {
@@ -196,7 +197,9 @@ export interface CardStyleAPI {
} }
export interface CardTriggersAPI { export interface CardTriggersAPI {
getActionsManager(): ActionsManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
@@ -206,11 +209,12 @@ export interface CardTriggersAPI {
export interface CardViewAPI { export interface CardViewAPI {
getAutoUpdateManager(): AutoUpdateManager; getAutoUpdateManager(): AutoUpdateManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getStyleManager(): StyleManager; getStyleManager(): StyleManager;
getConditionsManager(): ConditionsManager; getTriggersManager(): TriggersManager;
getCardElementManager(): CardElementManager;
} }
+2
View File
@@ -244,6 +244,8 @@ export class ViewManager {
camera: view.camera, camera: view.camera,
displayMode: view.displayMode ?? undefined, displayMode: view.displayMode ?? undefined,
}); });
this._api.getTriggersManager().updateView(oldView);
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
} }
+1 -1
View File
@@ -241,7 +241,7 @@ class FrigateCard extends LitElement {
const cardClasses = { const cardClasses = {
triggered: triggered:
!!this._controller.getTriggersManager().isTriggered() && !!this._controller.getTriggersManager().isTriggered() &&
!!this._config?.view.scan.show_trigger_status, !!this._config?.view.scan.trigger_show_status,
}; };
const mainClasses = { const mainClasses = {
main: true, main: true,
+77 -5
View File
@@ -1,3 +1,43 @@
// TODO: Config migration
// TODO: Editor support for scan mode changes
// TODO: Consider this:
/*
// NOW ===>
view:
scan:
enabled: true
trigger_show_status: true,
trigger_action:
- action: custom:frigate-card-action
frigate_card_action: camera_select
triggered: true
- action: custom:frigate-card-action
frigate_card_action: live
untrigger_action:
- action: custom:frigate-card-action
interaction_mode: inactive
trigger_filter_camera: all
untrigger_seconds: 0
view:
scan:
enabled: true
// TO
view:
scan:
enabled: true
trigger_show_status: true
actions:
live | live-reset | none
interaction: inactive | active | all
trigger_filter_camera: all
untrigger_seconds: 0
automations:
*/
import { import {
CallServiceActionConfig, CallServiceActionConfig,
ConfirmationRestrictionConfig, ConfirmationRestrictionConfig,
@@ -241,7 +281,8 @@ const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend
const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.literal('camera_select'), frigate_card_action: z.literal('camera_select'),
camera: z.string(), camera: z.string().optional(),
triggered: z.boolean().optional(),
}); });
const frigateCardLiveDependencySelectActionSchema = const frigateCardLiveDependencySelectActionSchema =
@@ -1119,17 +1160,48 @@ const viewConfigDefault = {
dark_mode: 'off' as const, dark_mode: 'off' as const,
scan: { scan: {
enabled: false, enabled: false,
show_trigger_status: true, trigger_show_status: true,
trigger_action: [
{
action: 'custom:frigate-card-action' as const,
frigate_card_action: 'camera_select' as const,
triggered: true,
},
{
action: 'custom:frigate-card-action' as const,
frigate_card_action: 'live' as const,
},
],
untrigger_action: {
action: 'custom:frigate-card-action' as const,
frigate_card_action: 'default' as const,
},
interaction_mode: 'inactive' as const,
trigger_filter_camera: 'all' as const,
untrigger_seconds: 0, untrigger_seconds: 0,
untrigger_reset: true,
}, },
}; };
const scanSchema = z.object({ const scanSchema = z.object({
enabled: z.boolean().default(viewConfigDefault.scan.enabled), enabled: z.boolean().default(viewConfigDefault.scan.enabled),
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
interaction_mode: z
.enum(['all', 'inactive', 'active'])
.default(viewConfigDefault.scan.interaction_mode),
trigger_filter_camera: z
.enum(['all', 'selected'])
.default(viewConfigDefault.scan.trigger_filter_camera),
trigger_show_status: z.boolean().default(viewConfigDefault.scan.trigger_show_status),
trigger_action: actionSchema
.or(actionSchema.array())
.nullable()
.default(viewConfigDefault.scan.trigger_action),
untrigger_action: actionSchema
.or(actionSchema.array())
.nullable()
.default(viewConfigDefault.scan.untrigger_action),
untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds), untrigger_seconds: z.number().default(viewConfigDefault.scan.untrigger_seconds),
untrigger_reset: z.boolean().default(viewConfigDefault.scan.untrigger_reset),
}); });
export type ScanOptions = z.infer<typeof scanSchema>; export type ScanOptions = z.infer<typeof scanSchema>;
+2 -4
View File
@@ -77,10 +77,8 @@ export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const;
export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const;
export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = export const CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS =
`${CONF_VIEW_SCAN}.show_trigger_status` as const; `${CONF_VIEW_SCAN}.trigger_show_status` as const;
export const CONF_VIEW_SCAN_UNTRIGGER_RESET =
`${CONF_VIEW_SCAN}.untrigger_reset` as const;
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS = export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
`${CONF_VIEW_SCAN}.untrigger_seconds` as const; `${CONF_VIEW_SCAN}.untrigger_seconds` as const;
+4 -9
View File
@@ -182,8 +182,7 @@ import {
CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT,
CONF_VIEW_SCAN, CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ENABLED, CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS,
CONF_VIEW_SCAN_UNTRIGGER_RESET,
CONF_VIEW_SCAN_UNTRIGGER_SECONDS, CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_CYCLE_CAMERA,
@@ -902,16 +901,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
)} )}
${this._renderSwitch( ${this._renderSwitch(
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS,
this._defaults.view.scan.show_trigger_status, this._defaults.view.scan.trigger_show_status,
{ {
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), label: localize(`config.${CONF_VIEW_SCAN_TRIGGER_SHOW_STATUS}`),
}, },
)} )}
${this._renderSwitch(
CONF_VIEW_SCAN_UNTRIGGER_RESET,
this._defaults.view.scan.untrigger_reset,
)}
${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, { ${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, {
default: this._defaults.view.scan.untrigger_seconds, default: this._defaults.view.scan.untrigger_seconds,
})} })}
+1 -1
View File
@@ -387,7 +387,7 @@
"scan": { "scan": {
"enabled": "Scan mode enabled", "enabled": "Scan mode enabled",
"scan_mode": "Scan mode", "scan_mode": "Scan mode",
"show_trigger_status": "Show pulsing border when triggered", "trigger_show_status": "Show pulsing border when triggered",
"untrigger_reset": "Reset the view to default after untrigger", "untrigger_reset": "Reset the view to default after untrigger",
"untrigger_seconds": "Seconds after inactive state change to untrigger" "untrigger_seconds": "Seconds after inactive state change to untrigger"
}, },
+1 -1
View File
@@ -383,7 +383,7 @@
"scan": { "scan": {
"enabled": "Modalità di scansione abilitata", "enabled": "Modalità di scansione abilitata",
"scan_mode": "Modalità di scansione", "scan_mode": "Modalità di scansione",
"show_trigger_status": "Mostra bordo pulsante quando attivato", "trigger_show_status": "Mostra bordo pulsante quando attivato",
"untrigger_reset": "Reset the view to default after untrigger", "untrigger_reset": "Reset the view to default after untrigger",
"untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione" "untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione"
}, },
+1 -1
View File
@@ -386,7 +386,7 @@
"scan": { "scan": {
"enabled": "Modo scan ativado", "enabled": "Modo scan ativado",
"scan_mode": "Modo scan", "scan_mode": "Modo scan",
"show_trigger_status": "Pulsar borda quando acionado", "trigger_show_status": "Pulsar borda quando acionado",
"untrigger_reset": "Redefinir a visualização para o padrão após desacionar", "untrigger_reset": "Redefinir a visualização para o padrão após desacionar",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
}, },
+1 -1
View File
@@ -376,7 +376,7 @@
"scan": { "scan": {
"enabled": "Modo scan ativado", "enabled": "Modo scan ativado",
"scan_mode": "Modo scan", "scan_mode": "Modo scan",
"show_trigger_status": "Exibir estado do gatilho", "trigger_show_status": "Exibir estado do gatilho",
"untrigger_reset": "Redefinir a visualização para o padrão após desacionar", "untrigger_reset": "Redefinir a visualização para o padrão após desacionar",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar" "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
}, },
+108 -30
View File
@@ -9,6 +9,7 @@ import {
import { FrigateCardMediaPlayer } from '../../src/types'; import { FrigateCardMediaPlayer } from '../../src/types';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
getActionConfigGivenAction, getActionConfigGivenAction,
} from '../../src/utils/action.js'; } from '../../src/utils/action.js';
@@ -245,7 +246,7 @@ describe('ActionsManager.executeAction', () => {
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
card_id: 'NOT_foo', card_id: 'NOT_foo',
frigate_card_action: 'default', frigate_card_action: 'default',
@@ -259,7 +260,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'default', frigate_card_action: 'default',
})!, })!,
@@ -283,7 +284,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: viewName, frigate_card_action: viewName,
})!, })!,
@@ -301,7 +302,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'download', frigate_card_action: 'download',
})!, })!,
@@ -314,7 +315,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_ui', frigate_card_action: 'camera_ui',
})!, })!,
@@ -327,7 +328,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'expand', frigate_card_action: 'expand',
})!, })!,
@@ -340,7 +341,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'fullscreen', frigate_card_action: 'fullscreen',
})!, })!,
@@ -353,7 +354,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'menu_toggle', frigate_card_action: 'menu_toggle',
})!, })!,
@@ -370,7 +371,7 @@ describe('ActionsManager.executeAction', () => {
vi.mocked(api.getViewManager().getView).mockReturnValue(createView()); vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_select', frigate_card_action: 'camera_select',
camera: 'camera', camera: 'camera',
@@ -397,7 +398,7 @@ describe('ActionsManager.executeAction', () => {
); );
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_select', frigate_card_action: 'camera_select',
camera: 'camera', camera: 'camera',
@@ -430,7 +431,7 @@ describe('ActionsManager.executeAction', () => {
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true); vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_select', frigate_card_action: 'camera_select',
camera: 'camera', camera: 'camera',
@@ -445,11 +446,55 @@ describe('ActionsManager.executeAction', () => {
); );
}); });
it('with triggered camera', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(
api.getTriggersManager().getMostRecentlyTriggeredCameraID,
).mockReturnValue('camera');
await manager.executeFrigateAction(
createAction({
frigate_card_action: 'camera_select',
triggered: true,
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: 'live',
cameraID: 'camera',
}),
);
});
it('without camera or triggered camera', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
vi.mocked(
api.getTriggersManager().getMostRecentlyTriggeredCameraID,
).mockReturnValue('camera');
await manager.executeFrigateAction(
createAction({
frigate_card_action: 'camera_select',
})!,
);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('without a current view', async () => { it('without a current view', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_select', frigate_card_action: 'camera_select',
camera: 'camera', camera: 'camera',
@@ -469,7 +514,7 @@ describe('ActionsManager.executeAction', () => {
}), }),
); );
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'camera_select', frigate_card_action: 'camera_select',
camera: 'camera', camera: 'camera',
@@ -490,7 +535,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'live_substream_select', frigate_card_action: 'live_substream_select',
camera: 'substream', camera: 'substream',
@@ -504,7 +549,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'live_substream_off', frigate_card_action: 'live_substream_off',
})!, })!,
@@ -517,7 +562,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'live_substream_on', frigate_card_action: 'live_substream_on',
})!, })!,
@@ -531,7 +576,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'media_player', frigate_card_action: 'media_player',
media_player_action: 'stop', media_player_action: 'stop',
@@ -552,7 +597,7 @@ describe('ActionsManager.executeAction', () => {
); );
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'media_player', frigate_card_action: 'media_player',
media_player_action: 'play', media_player_action: 'play',
@@ -576,7 +621,7 @@ describe('ActionsManager.executeAction', () => {
vi.mocked(api.getViewManager().getView).mockReturnValue(view); vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'media_player', frigate_card_action: 'media_player',
media_player_action: 'play', media_player_action: 'play',
@@ -599,7 +644,7 @@ describe('ActionsManager.executeAction', () => {
); );
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'media_player', frigate_card_action: 'media_player',
media_player_action: 'play', media_player_action: 'play',
@@ -615,7 +660,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'diagnostics', frigate_card_action: 'diagnostics',
})!, })!,
@@ -632,7 +677,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'microphone_mute', frigate_card_action: 'microphone_mute',
})!, })!,
@@ -645,7 +690,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'microphone_unmute', frigate_card_action: 'microphone_unmute',
})!, })!,
@@ -670,7 +715,7 @@ describe('ActionsManager.executeAction', () => {
); );
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: action, frigate_card_action: action,
})!, })!,
@@ -684,7 +729,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'screenshot', frigate_card_action: 'screenshot',
})!, })!,
@@ -697,7 +742,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'display_mode_select', frigate_card_action: 'display_mode_select',
display_mode: 'grid', display_mode: 'grid',
@@ -715,7 +760,7 @@ describe('ActionsManager.executeAction', () => {
createView({ camera: 'camera.office' }), createView({ camera: 'camera.office' }),
); );
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'ptz', frigate_card_action: 'ptz',
ptz_action: 'left', ptz_action: 'left',
@@ -736,7 +781,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'ptz', frigate_card_action: 'ptz',
ptz_action: 'left', ptz_action: 'left',
@@ -751,7 +796,7 @@ describe('ActionsManager.executeAction', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeAction( await manager.executeFrigateAction(
createAction({ createAction({
frigate_card_action: 'show_ptz', frigate_card_action: 'show_ptz',
show_ptz: true, show_ptz: true,
@@ -768,7 +813,7 @@ describe('ActionsManager.executeAction', () => {
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true); const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
await manager.executeAction( await manager.executeFrigateAction(
// Have to manually create the action (vs using `createAction()`) since // Have to manually create the action (vs using `createAction()`) since
// it's malformed. // it's malformed.
{ {
@@ -781,3 +826,36 @@ describe('ActionsManager.executeAction', () => {
); );
}); });
}); });
describe('ActionsManager.executeActions', () => {
it('should execute actions', async () => {
const api = createCardAPI();
const hass = createHASS();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new ActionsManager(api);
const action = createAction({
frigate_card_action: 'default',
})!;
manager.executeActions(action);
expect(frigateCardHandleAction).toBeCalledWith(element, hass, {}, action);
});
it('should not execute actions without hass', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
const manager = new ActionsManager(api);
manager.executeActions(
createAction({
frigate_card_action: 'default',
})!,
);
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
+2 -2
View File
@@ -72,10 +72,10 @@ describe('HASSManager', () => {
const originalHASS = createHASS(); const originalHASS = createHASS();
manager.setHASS(originalHASS); manager.setHASS(originalHASS);
expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(null); expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(null);
manager.setHASS(createHASS()); manager.setHASS(createHASS());
expect(api.getTriggersManager().updateTriggeredCameras).toBeCalledWith(originalHASS); expect(api.getTriggersManager().updateTriggerHAState).toBeCalledWith(originalHASS);
}); });
describe('should handle connection state change when', () => { describe('should handle connection state change when', () => {
@@ -30,7 +30,6 @@ describe('InteractionManager', () => {
manager.reportInteraction(); manager.reportInteraction();
expect(api.getTriggersManager().untrigger).toBeCalled();
expect(manager.hasInteraction()).toBeTruthy(); expect(manager.hasInteraction()).toBeTruthy();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
@@ -271,7 +271,6 @@ describe('MediaPlayerManager', () => {
}, },
]), ]),
); );
vi.mocked(api.getQueryStringManager().generateQueryString).mockReturnValue('');
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api); const manager = new MediaPlayerManager(api);
@@ -28,7 +28,7 @@ describe('QueryStringManager', () => {
manager.executeAll(); manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(manager.hasViewRelatedActions()).toBeFalsy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
}); });
@@ -76,7 +76,7 @@ describe('QueryStringManager', () => {
manager.executeAll(); manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(manager.hasViewRelatedActions()).toBeFalsy();
expect(api.getActionsManager().executeAction).toBeCalledWith({ expect(api.getActionsManager().executeFrigateAction).toBeCalledWith({
action: 'fire-dom-event', action: 'fire-dom-event',
card_id: 'id', card_id: 'id',
frigate_card_action: action, frigate_card_action: action,
@@ -97,7 +97,7 @@ describe('QueryStringManager', () => {
expect(api.getViewManager().setViewDefault).toBeCalled(); expect(api.getViewManager().setViewDefault).toBeCalled();
expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
}); });
@@ -114,7 +114,7 @@ describe('QueryStringManager', () => {
}); });
expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
@@ -131,7 +131,7 @@ describe('QueryStringManager', () => {
}); });
expect(manager.hasViewRelatedActions()).toBeTruthy(); expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
@@ -147,7 +147,7 @@ describe('QueryStringManager', () => {
manager.executeAll(); manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(manager.hasViewRelatedActions()).toBeFalsy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
}, },
@@ -165,7 +165,7 @@ describe('QueryStringManager', () => {
manager.executeAll(); manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeFalsy(); expect(manager.hasViewRelatedActions()).toBeFalsy();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
@@ -212,7 +212,7 @@ describe('QueryStringManager', () => {
manager.executeAll(); manager.executeAll();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
}); });
@@ -296,77 +296,7 @@ describe('QueryStringManager', () => {
manager.executeViewRelated(); manager.executeViewRelated();
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeFrigateAction).not.toBeCalled();
});
});
describe('should generate query string', () => {
describe('that require no arguments', () => {
it.each([
['camera_ui' as const],
['clip' as const],
['clips' as const],
['default' as const],
['diagnostics' as const],
['download' as const],
['expand' as const],
['image' as const],
['live' as const],
['menu_toggle' as const],
['recording' as const],
['recordings' as const],
['snapshot' as const],
['snapshots' as const],
['timeline' as const],
])('%s', (actionName: FrigateCardGeneralAction | FrigateCardUserSpecifiedView) => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: actionName,
}),
).toBe(`frigate-card-action.${actionName}=`);
});
});
describe('that require camera argument', () => {
it.each([['camera_select' as const], ['live_substream_select' as const]])(
'%s',
(actionName: 'camera_select' | 'live_substream_select') => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: actionName,
camera: 'camera',
}),
).toBe(`frigate-card-action.${actionName}=camera`);
},
);
});
it('that include a card_id', () => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card-id',
}),
).toBe(`frigate-card-action.card-id.clips=`);
});
it('that include an unsupported action', () => {
const spy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: 'microphone_unmute',
}),
).toBeNull();
expect(spy).toBeCalled();
}); });
}); });
}); });
+238 -56
View File
@@ -15,6 +15,28 @@ import {
createView, createView,
} from '../test-utils'; } from '../test-utils';
const baseScanConfig: Partial<ScanOptions> = {
enabled: true,
untrigger_seconds: 10,
interaction_mode: 'inactive' as const,
trigger_filter_camera: 'all' as const,
trigger_action: [
{
action: 'fire-dom-event' as const,
frigate_card_action: 'camera_select' as const,
triggered: true,
},
{
action: 'fire-dom-event' as const,
frigate_card_action: 'live' as const,
},
],
untrigger_action: {
action: 'fire-dom-event' as const,
frigate_card_action: 'default' as const,
},
};
// Creating and mocking a trigger API is a lot of boilerplate, this convenience // Creating and mocking a trigger API is a lot of boilerplate, this convenience
// function reduces it. // function reduces it.
const createTriggerAPI = (options?: { const createTriggerAPI = (options?: {
@@ -26,11 +48,7 @@ const createTriggerAPI = (options?: {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
view: { view: {
scan: options?.config ?? { scan: options?.config ?? baseScanConfig,
enabled: true,
untrigger_reset: true,
untrigger_seconds: 10,
},
}, },
}), }),
); );
@@ -83,11 +101,28 @@ describe('TriggersManager', () => {
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.updateTriggeredCameras(null); manager.updateTriggerHAState(null);
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
}); });
it('should not trigger without a scan config', () => {
const api = createTriggerAPI({
hassStates: hassActiveState,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera_1' as const,
}),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
const manager = new TriggersManager(api);
manager.updateView(null);
expect(manager.isTriggered()).toBeFalsy();
});
it('should trigger and untrigger based on entity state', () => { it('should trigger and untrigger based on entity state', () => {
const start = new Date('2023-10-01T17:14'); const start = new Date('2023-10-01T17:14');
vi.setSystemTime(start); vi.setSystemTime(start);
@@ -96,22 +131,29 @@ describe('TriggersManager', () => {
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.updateTriggeredCameras(createHASS(hassInactiveState)); manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({ expect(api.getActionsManager().executeActions).toBeCalledWith([
viewName: 'live', {
cameraID: 'camera_1', action: 'fire-dom-event' as const,
}); frigate_card_action: 'camera_select' as const,
triggered: true,
},
{
action: 'fire-dom-event' as const,
frigate_card_action: 'live' as const,
},
]);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue( vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
createHASS(hassInactiveState), createHASS(hassInactiveState),
); );
manager.updateTriggeredCameras(createHASS(hassActiveState)); manager.updateTriggerHAState(createHASS(hassActiveState));
// Intentional state update with no change. // Intentional state update with no change.
manager.updateTriggeredCameras(createHASS(hassActiveState)); manager.updateTriggerHAState(createHASS(hassActiveState));
// Will still be triggered, but untrigger timer will be running. // Will still be triggered, but untrigger timer will be running.
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
@@ -120,26 +162,10 @@ describe('TriggersManager', () => {
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should trigger and set view if current view is wrong', () => { expect(api.getActionsManager().executeActions).toBeCalledWith({
const api = createTriggerAPI({ action: 'fire-dom-event' as const,
hassStates: hassActiveState, frigate_card_action: 'default' as const,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
// Correct camera, but wrong view.
view: 'clips',
camera: 'camera_1',
}),
);
const manager = new TriggersManager(api);
manager.updateTriggeredCameras(null);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: 'live',
cameraID: 'camera_1',
}); });
}); });
@@ -150,40 +176,196 @@ describe('TriggersManager', () => {
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
manager.updateTriggeredCameras(null); manager.updateTriggerHAState(null);
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
}); });
it('should untrigger manually', () => { it('should take no actions with human interactions', () => {
const api = createTriggerAPI({ const start = new Date('2023-10-01T17:14');
hassStates: hassActiveState,
});
const manager = new TriggersManager(api);
// Untriggering when not triggered.
manager.untrigger();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
manager.updateTriggeredCameras(null);
expect(manager.isTriggered()).toBeTruthy();
manager.untrigger();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should take no actions when automated actions are not allowed', () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
hassStates: hassActiveState, hassStates: hassActiveState,
// Interaction present. // Interaction present.
interaction: true, interaction: true,
}); });
const manager = new TriggersManager(api); const manager = new TriggersManager(api);
manager.updateTriggeredCameras(null); manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
manager.untrigger(); expect(api.getActionsManager().executeActions).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
createHASS(hassInactiveState),
);
manager.updateTriggerHAState(createHASS(hassActiveState));
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getActionsManager().executeActions).not.toBeCalled();
});
it('should take actions with human interactions when interaction mode is active', () => {
const start = new Date('2023-10-01T17:14');
const api = createTriggerAPI({
hassStates: hassActiveState,
// Interaction present.
interaction: true,
config: {
...baseScanConfig,
interaction_mode: 'active',
},
});
const manager = new TriggersManager(api);
manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeTruthy();
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
createHASS(hassInactiveState),
);
manager.updateTriggerHAState(createHASS(hassActiveState));
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getActionsManager().executeActions).toBeCalledTimes(2);
});
it('should report multiple triggered cameras', () => {
const start = new Date('2023-10-01T17:14');
const bothOnState = {
'binary_sensor.one': createStateEntity({ state: 'on' }),
'binary_sensor.two': createStateEntity({ state: 'on' }),
};
const api = createTriggerAPI({
hassStates: bothOnState,
});
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.one'],
},
}),
},
{
cameraID: 'camera_2',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.two'],
},
}),
},
]),
);
const manager = new TriggersManager(api);
expect(manager.isTriggered()).toBeFalsy();
expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
manager.updateTriggerHAState(null);
expect(manager.isTriggered()).toBeTruthy();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_1', 'camera_2']));
// Either is the most recently triggered.
expect(['camera_1', 'camera_2']).toContain(
manager.getMostRecentlyTriggeredCameraID(),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
createHASS({
'binary_sensor.one': createStateEntity({ state: 'off' }),
'binary_sensor.two': createStateEntity({ state: 'on' }),
}),
);
manager.updateTriggerHAState(createHASS(bothOnState));
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2']));
expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2');
});
it('should filter triggers by camera', () => {
const start = new Date('2023-10-01T17:14');
const api = createTriggerAPI({
config: {
...baseScanConfig,
// Filter triggers to selected camera only.
trigger_filter_camera: 'selected' as const,
},
hassStates: hassActiveState,
});
const manager = new TriggersManager(api);
manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeFalsy();
const otherCameraSelected = createView({
camera: 'camera_SOME_OTHER_CAMERA' as const,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
manager.updateView(null);
expect(manager.isTriggered()).toBeFalsy();
const thisCameraSelected = createView({
camera: 'camera_1' as const,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.updateView(otherCameraSelected);
expect(manager.isTriggered()).toBeTruthy();
// Ensure a view change to the same camera stays triggered.
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.updateView(thisCameraSelected);
expect(manager.isTriggered()).toBeTruthy();
});
it('should not untrigger triggers by camera', () => {
const start = new Date('2023-10-01T17:14');
const api = createTriggerAPI({
config: {
...baseScanConfig,
// Filter triggers to selected camera only.
trigger_filter_camera: 'selected' as const,
},
hassStates: hassActiveState,
});
const manager = new TriggersManager(api);
manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeFalsy();
const otherCameraSelected = createView({
camera: 'camera_SOME_OTHER_CAMERA' as const,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(otherCameraSelected);
manager.updateView(null);
expect(manager.isTriggered()).toBeFalsy();
const thisCameraSelected = createView({
camera: 'camera_1' as const,
});
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.updateView(otherCameraSelected);
expect(manager.isTriggered()).toBeTruthy();
// Ensure a view change to the same camera stays triggered.
vi.mocked(api.getViewManager().getView).mockReturnValue(thisCameraSelected);
manager.updateView(thisCameraSelected);
expect(manager.isTriggered()).toBeTruthy();
}); });
}); });
+18 -2
View File
@@ -279,9 +279,25 @@ describe('config defaults', () => {
default: 'live', default: 'live',
scan: { scan: {
enabled: false, enabled: false,
show_trigger_status: true, interaction_mode: 'inactive',
untrigger_reset: true, trigger_show_status: true,
untrigger_seconds: 0, untrigger_seconds: 0,
trigger_action: [
{
action: 'fire-dom-event',
frigate_card_action: 'camera_select',
triggered: true,
},
{
action: 'fire-dom-event',
frigate_card_action: 'live',
},
],
trigger_filter_camera: 'all',
untrigger_action: {
action: 'fire-dom-event',
frigate_card_action: 'default',
},
}, },
timeout_seconds: 300, timeout_seconds: 300,
update_cycle_camera: false, update_cycle_camera: false,
+3 -3
View File
@@ -10,10 +10,10 @@ export default defineConfig({
// Thresholds will automatically be updated as coverage improves to avoid // Thresholds will automatically be updated as coverage improves to avoid
// back-sliding. // back-sliding.
thresholdAutoUpdate: true, thresholdAutoUpdate: true,
statements: 71.65, statements: 71.74,
branches: 60.62, branches: 60.62,
functions: 72.78, functions: 72.84,
lines: 71.54, lines: 71.63,
}, },
}, },
}); });