Merge pull request #1351 from dermotduffy/advanced-scan

Allow scan mode to filter triggered camera and to be used in automations
This commit is contained in:
Dermot Duffy
2024-01-27 17:03:06 -08:00
committed by GitHub
43 changed files with 1252 additions and 467 deletions
+98 -15
View File
@@ -400,8 +400,9 @@ See the [fully expanded view configuration example](#config-expanded-view) for h
| - | - | - | - | | - | - | - | - |
| `default` | `live` | :white_check_mark: | The view to show in the card by default. The default camera is the first one listed. See [views](#views) below.| | `default` | `live` | :white_check_mark: | The view to show in the card by default. The default camera is the first one listed. See [views](#views) below.|
| `camera_select` | `current` | :white_check_mark: | The view to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. Other acceptable values may be seen at [views](#views) below.| | `camera_select` | `current` | :white_check_mark: | The view to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. Other acceptable values may be seen at [views](#views) below.|
| `dark_mode` | `off` | :white_check_mark: | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `timeout_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.| | `dark_mode` | `off` | :white_check_mark: | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`.|
| `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | | `interaction_seconds` | `300` | :white_check_mark: | After a mouse/touch interaction with the Frigate card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used in conditions with the `interaction` parameter of a [Frigate card condition](#frigate-card-condition) or with `reset_after_interaction` (below). `0` means no interactions are reported / acted upon. |
| `reset_after_interaction` | `true` | :white_check_mark: | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
| `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| | `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.|
| `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| | `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.|
| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). See [card updates](#card-updates) below for behavior and usecases.| | `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `triggers` for the selected camera instead, see [Trigger Configuration](#camera-triggers-configuration)). See [card updates](#card-updates) below for behavior and usecases.|
@@ -421,20 +422,27 @@ 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). | | `filter_selected_camera` | `false` | :white_check_mark: | If set to `true` will only trigger on the currently selected camera.|
| `untrigger_reset` | `true` | :white_check_mark: | Whether or not to reset the view to the default after untriggering. | | `show_trigger_status` | `true` | :white_check_mark: | Whether or not the `live` view should show a visual indication that it is triggered (a pulsing border around the camera edge). |
| `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. |
| `actions` | | :white_check_mark: | The actions to take when scan mode triggers (see below). |
#### View: Scan Mode Actions configuration
| `trigger` | `live` | :white_check_mark | When `live` the trigger will select the triggered camera in `live` view, when `none` will take no action. |
| `untrigger` | `default` | :white_check_mark | When `default` the untrigger will return to the default view and camera, when `none` will take no action. |
| `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.interaction_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 `show_trigger_status` is true) but rather just prevents the actions being performed.|
### Menu Options ### Menu Options
@@ -1276,12 +1284,14 @@ All variables listed are under a `conditions:` section.
| Condition | Description | | Condition | Description |
| ------------- | --------------------------------------------- | | ------------- | --------------------------------------------- |
| `view` | A list of [views](#views) in which this condition is satified (e.g. `clips`) | | `view` | A list of [views](#views) in which this condition is satified (e.g. `clips`) |
| `camera` | A list of camera ids in which this condition is satisfied. See [camera IDs](#camera-ids).| | `camera` | A list of camera IDs in which this condition is satisfied. See [camera IDs](#camera-ids).|
| `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.|
| `expand` | If `true` the condition is satisfied if the card is in expanded mode (in a dialog/popup). If `false` the condition is satisfied if the card is **NOT** in expanded mode (in a dialog/popup).| | `expand` | If `true` the condition is satisfied if the card is in expanded mode (in a dialog/popup). If `false` the condition is satisfied if the card is **NOT** in expanded mode (in a dialog/popup).|
| `state` | A list of state conditions to compare with Home Assistant state. See below. | | `state` | A list of state conditions to compare with Home Assistant state. See below. |
| `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).| | `media_loaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).|
| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).| | `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).|
| `interacted` | If `true` the condition is satisfied if the card has had human interaction within `view.interaction_seconds` elapsed seconds. If `false` the condition is satisfied if the card has **NOT** had human interaction in that time. |
| `triggered` | A list of camera IDs which, if triggered in [scan mode](#scan-mode), satisfy the condition.|
See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used.
@@ -1422,7 +1432,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. |
@@ -1449,7 +1459,7 @@ This card supports several different views:
|`clip`|Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon.| |`clip`|Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon.|
|`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.| |`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.|
|`recording`|Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.| |`recording`|Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` menu icon.|
|`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.timeout_seconds`).| |`image`|Shows a static image specified by the `image` parameter, can be used as a discrete default view or a screensaver (via `view.interaction_seconds`).|
|`timeline`|Shows an event timeline.| |`timeline`|Shows an event timeline.|
### Navigating From A Snapshot To A Clip ### Navigating From A Snapshot To A Clip
@@ -1901,7 +1911,7 @@ Reference: [View Options](#view-options).
view: view:
default: live default: live
camera_select: current camera_select: current
timeout_seconds: 300 interaction_seconds: 300
update_seconds: 0 update_seconds: 0
update_force: false update_force: false
update_cycle_camera: false update_cycle_camera: false
@@ -1913,8 +1923,12 @@ view:
scan: scan:
enabled: false enabled: false
show_trigger_status: true show_trigger_status: true
untrigger_reset: true filter_selected_camera: false
untrigger_seconds: 0 untrigger_seconds: 0
actions:
interaction_mode: 'inactive' as const,
trigger: 'live' as const,
untrigger: 'default' as const,
actions: actions:
entity: light.office_main_lights entity: light.office_main_lights
tap_action: tap_action:
@@ -2849,7 +2863,7 @@ overrides:
view: view:
default: live default: live
camera_select: current camera_select: current
timeout_seconds: 300 interaction_seconds: 300
update_seconds: 0 update_seconds: 0
update_force: false update_force: false
update_cycle_camera: false update_cycle_camera: false
@@ -3652,6 +3666,37 @@ view:
``` ```
</details> </details>
<details>
<summary>Expand: Showing an alarm menu button when a camera is triggered</summary>
This example adds a menu button to optionally activate a siren when the camera is triggered.
```yaml
type: custom:frigate-card
cameras:
- camera_entity: camera.office
elements:
- type: custom:frigate-card-conditional
elements:
- type: custom:frigate-card-menu-icon
icon: mdi:alarm-bell
title: Activate alarm
style:
color: red
tap_action:
action: call-service
service: homeassistant.toggle
data:
entity_id: siren.siren
conditions:
triggered:
- camera.office
view:
scan:
enabled: true
```
</details>
<a name="media-layout-examples"></a> <a name="media-layout-examples"></a>
### Changing the Media Layout ### Changing the Media Layout
@@ -4040,6 +4085,44 @@ views:
</details> </details>
### Interaction conditions
The card can automatically execute actions when the card is interacted with (mouse or touch).
<details>
<summary>Expand: Automatically show a high-bandwidth stream on interaction</summary>
This example will automatically use a HD live substream when the mouse cursor interacts with the card.
```yaml
type: custom:frigate-card
cameras:
- live_provider: go2rtc
camera_entity: camera.office
triggers:
entities:
- switch.office_detect
dependencies:
cameras:
- camera.office_hd
- camera_entity: camera.office_hd
live_provider: go2rtc
hide: true
automations:
- 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
conditions:
interaction: true
view:
scan:
enabled: true
```
</details>
<a name="media-layout-examples"></a> <a name="media-layout-examples"></a>
## Card Refreshes ## Card Refreshes
@@ -4055,7 +4138,7 @@ Note that no (other) automated updates are permitted when [scan mode](#scan-mode
In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `triggers.entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `triggers.occupancy` and `triggers.motion` options are enabled, see [Trigger Configuration](#camera-triggers-configuration)). In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `triggers.entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `triggers.occupancy` and `triggers.motion` options are enabled, see [Trigger Configuration](#camera-triggers-configuration)).
| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior | | `view . update_seconds` | `view . interaction_seconds` | `view . update_force` | Trigger Entities | Behavior |
| :-: | :-: | :-: | :-: | - | | :-: | :-: | :-: | :-: | - |
| `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. |
| `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. |
@@ -4097,7 +4180,7 @@ view:
```yaml ```yaml
view: view:
default: clip default: clip
timeout_seconds: 30 interaction_seconds: 30
``` ```
<a name="query-string-actions"></a> <a name="query-string-actions"></a>
+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;
@@ -54,6 +54,13 @@ export class CardElementManager {
} }
public elementConnected(): void { public elementConnected(): void {
// Set initial condition state. Must be done after the element is connected to
// allow callbacks to interact with the card.
this._api.getInteractionManager().initialize();
this._api.getFullscreenManager().initialize();
this._api.getExpandManager().initialize();
this._api.getMediaLoadedInfoManager().initialize();
// Whether or not the card is in panel mode on the dashboard. // Whether or not the card is in panel mode on the dashboard.
setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel'); setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel');
+13 -2
View File
@@ -6,7 +6,7 @@ import {
frigateConditionalSchema, frigateConditionalSchema,
OverrideConfigurationKey, OverrideConfigurationKey,
RawFrigateCardConfig, RawFrigateCardConfig,
ViewDisplayMode ViewDisplayMode,
} from '../config/types'; } from '../config/types';
import { CardConditionAPI } from './types'; import { CardConditionAPI } from './types';
@@ -18,6 +18,8 @@ interface ConditionState {
state?: HassEntities; state?: HassEntities;
media_loaded?: boolean; media_loaded?: boolean;
displayMode?: ViewDisplayMode; displayMode?: ViewDisplayMode;
triggered?: Set<string>;
interaction?: boolean;
} }
export class ConditionEvaluateRequestEvent extends Event { export class ConditionEvaluateRequestEvent extends Event {
@@ -133,7 +135,7 @@ export class ConditionsManager {
this._listeners = [ this._listeners = [
() => this._api.getConfigManager().computeOverrideConfig(), () => this._api.getConfigManager().computeOverrideConfig(),
() => this._api.getAutomationsManager().execute(), () => this._api.getAutomationsManager().execute(),
...(listener ? [listener] : []) ...(listener ? [listener] : []),
]; ];
} }
@@ -243,6 +245,15 @@ export class ConditionsManager {
if (condition.display_mode) { if (condition.display_mode) {
result &&= !!state.displayMode && condition.display_mode === state.displayMode; result &&= !!state.displayMode && condition.display_mode === state.displayMode;
} }
if (condition.triggered?.length) {
result &&= condition.triggered.some((triggeredCameraID) =>
state.triggered?.has(triggeredCameraID),
);
}
if (condition.interaction !== undefined) {
result &&=
state.interaction !== undefined && condition.interaction === state.interaction;
}
return result; return result;
} }
+11 -3
View File
@@ -8,6 +8,10 @@ export class ExpandManager {
this._api = api; this._api = api;
} }
public initialize(): void {
this._setConditionState();
}
public isExpanded(): boolean { public isExpanded(): boolean {
return this._expanded; return this._expanded;
} }
@@ -23,9 +27,13 @@ export class ExpandManager {
} }
this._expanded = expanded; this._expanded = expanded;
this._api.getConditionsManager()?.setState({ this._setConditionState();
expand: expanded,
});
this._api.getCardElementManager().update(); this._api.getCardElementManager().update();
} }
protected _setConditionState(): void {
this._api.getConditionsManager()?.setState({
expand: this._expanded,
});
}
} }
+11 -3
View File
@@ -8,6 +8,10 @@ export class FullscreenManager {
this._api = api; this._api = api;
} }
public initialize(): void {
this._setConditionState();
}
public connect(): void { public connect(): void {
if (screenfull.isEnabled) { if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler); screenfull.on('change', this._fullscreenHandler);
@@ -32,12 +36,16 @@ export class FullscreenManager {
screenfull.exit(); screenfull.exit();
} }
protected _fullscreenHandler = (): void => { protected _setConditionState(): void {
this._api.getExpandManager().setExpanded(false);
this._api.getConditionsManager()?.setState({ this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(), fullscreen: this.isInFullscreen(),
}); });
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._setConditionState();
// Re-render after a change to fullscreen mode to take advantage of // Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked // the expanded screen real-estate (vs staying in aspect-ratio locked
+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 });
+14 -12
View File
@@ -16,35 +16,37 @@ export class InteractionManager {
this._reportInteraction(); this._reportInteraction();
}, 1 * 1000); }, 1 * 1000);
public initialize(): void {
this._api.getConditionsManager().setState({ interaction: false });
}
public hasInteraction(): boolean { public hasInteraction(): boolean {
return this._timer.isRunning(); return this._timer.isRunning();
} }
/** /**
* Start the user interaction ('screensaver') timer to reset the view to * Start the user interaction ('screensaver') timer to reset the view to
* default `view.timeout_seconds` after user interaction. * default `view.interaction_seconds` after user interaction.
*/ */
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.interaction_seconds;
if (timeoutSeconds) { if (timeoutSeconds) {
this._api.getConditionsManager().setState({ interaction: true });
this._timer.start(timeoutSeconds, () => { this._timer.start(timeoutSeconds, () => {
if (this._isAutomatedUpdateAllowed()) { this._api.getConditionsManager().setState({ interaction: false });
this._api.getViewManager().setViewDefault();
if (!this._api.getTriggersManager().isTriggered()) {
if (this._api.getConfigManager().getConfig()?.view.reset_after_interaction) {
this._api.getViewManager().setViewDefault();
}
this._api.getStyleManager().setLightOrDarkMode(); this._api.getStyleManager().setLightOrDarkMode();
} }
}); });
} }
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedUpdateAllowed(): boolean {
return !this._api.getTriggersManager().isTriggered();
} }
} }
@@ -12,6 +12,10 @@ export class MediaLoadedInfoManager {
this._api = api; this._api = api;
} }
public initialize(): void {
this.clear();
}
public set(mediaInfo: MediaLoadedInfo): void { public set(mediaInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaInfo)) { if (!isValidMediaLoadedInfo(mediaInfo)) {
return; return;
+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),
); );
} }
+104 -58
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,120 @@ 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 { public updateView(oldView?: View | null): void {
const wasTriggered = this.isTriggered(); if (oldView?.camera !== this._api.getViewManager().getView()?.camera) {
this._triggers.clear(); // If the view changes, a new camera may have been selected, which may
this._untriggerTimer.stop(); // mean a trigger is required (in the case that `filter_selected_camera`
// is true).
if (wasTriggered) { this._evaluateTriggers();
this._untriggerAction();
} }
} }
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 (
!this._triggeredCameras.has(cameraID) &&
(!scanConfig.filter_selected_camera ||
cameraID === this._api.getViewManager().getView()?.camera)
) {
this._triggeredCameras.set(cameraID, now);
this._setConditionState();
this._triggerAction(cameraID);
}
}
for (const cameraID of this._triggeredCameras.keys()) {
if (!this._triggeredState.has(cameraID)) {
this._startUntriggerTimer(cameraID);
}
}
}
protected _hasAllowableInteractionStateForAction(): boolean {
const scanConfig = this._api.getConfigManager().getConfig()?.view.scan;
const hasInteraction = this._api.getInteractionManager().hasInteraction();
return (
!!scanConfig &&
(scanConfig.actions.interaction_mode === 'all' ||
(scanConfig.actions.interaction_mode === 'active' && hasInteraction) ||
(scanConfig.actions.interaction_mode === 'inactive' && !hasInteraction))
);
}
protected _triggerAction(cameraID: string): void { protected _triggerAction(cameraID: string): void {
const view = this._api.getViewManager().getView(); const action = this._api.getConfigManager().getConfig()?.view.scan.actions.trigger;
if (
this._isAutomatedViewUpdateAllowed() && if (action === 'live' && this._hasAllowableInteractionStateForAction()) {
(view?.camera !== cameraID || !view?.is('live'))
) {
this._api.getViewManager().setViewByParameters({ this._api.getViewManager().setViewByParameters({
viewName: 'live', viewName: 'live',
cameraID: cameraID, cameraID: cameraID,
}); });
} }
// Must update master element to add border pulsing.
this._api.getCardElementManager().update();
} }
protected _untriggerAction(): void { protected _setConditionState(): void {
if ( this._api.getConditionsManager().setState({
!this.isTriggered() && triggered: this._triggeredCameras.size
this._isAutomatedViewUpdateAllowed() && ? new Set(this._triggeredCameras.keys())
this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset : undefined,
) { });
}
protected _untriggerAction(cameraID: string): void {
const action = this._api.getConfigManager().getConfig()?.view.scan.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewDefault(); this._api.getViewManager().setViewDefault();
} }
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
this._setConditionState();
// Must update master element to remove border pulsing.
this._api.getCardElementManager().update();
} }
protected _isAutomatedViewUpdateAllowed(): boolean { protected _startUntriggerTimer(cameraID: string): void {
return ( this._deleteTimer(cameraID);
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
protected _shouldTrackTriggers(): boolean { const timer = new Timer();
return !!this._api.getConfigManager().getConfig()?.view.scan.enabled; this._triggeredCameraTimers.set(cameraID, timer);
} timer.start(
protected _startUntriggerTimer(): void {
this._untriggerTimer.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;
} }
} }
+19 -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 {
@@ -99,6 +100,7 @@ export interface CardDownloadAPI {
export interface CardElementAPI { export interface CardElementAPI {
getActionsManager(): ActionsManager; getActionsManager(): ActionsManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager; getFullscreenManager(): FullscreenManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager; getMediaLoadedInfoManager(): MediaLoadedInfoManager;
@@ -106,15 +108,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;
} }
@@ -146,6 +148,7 @@ export interface CardInitializerAPI {
} }
export interface CardInteractionAPI { export interface CardInteractionAPI {
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getStyleManager(): StyleManager; getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager; getTriggersManager(): TriggersManager;
@@ -153,23 +156,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 +182,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 {
@@ -197,6 +200,8 @@ export interface CardStyleAPI {
export interface CardTriggersAPI { export interface CardTriggersAPI {
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
@@ -206,11 +211,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();
} }
+3 -6
View File
@@ -238,11 +238,6 @@ class FrigateCard extends LitElement {
const cardStyle = { const cardStyle = {
'aspect-ratio': this._controller.getStyleManager().getAspectRatioStyle(), 'aspect-ratio': this._controller.getStyleManager().getAspectRatioStyle(),
}; };
const cardClasses = {
triggered:
!!this._controller.getTriggersManager().isTriggered() &&
!!this._config?.view.scan.show_trigger_status,
};
const mainClasses = { const mainClasses = {
main: true, main: true,
'curve-top': 'curve-top':
@@ -264,7 +259,6 @@ class FrigateCard extends LitElement {
hasHold: frigateCardHasAction(actions.hold_action), hasHold: frigateCardHasAction(actions.hold_action),
hasDoubleClick: frigateCardHasAction(actions.double_tap_action), hasDoubleClick: frigateCardHasAction(actions.double_tap_action),
})} })}
class="${classMap(cardClasses)}"
style="${styleMap(cardStyle)}" style="${styleMap(cardStyle)}"
@frigate-card:message=${(ev: CustomEvent<Message>) => @frigate-card:message=${(ev: CustomEvent<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)} this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@@ -313,6 +307,9 @@ class FrigateCard extends LitElement {
?.getEpoch()} ?.getEpoch()}
.hide=${!!this._controller.getMessageManager().hasMessage()} .hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneStream=${this._controller.getMicrophoneManager()?.getStream()} .microphoneStream=${this._controller.getMicrophoneManager()?.getStream()}
.triggeredCameraIDs=${this._config?.view.scan.show_trigger_status
? this._controller.getTriggersManager().getTriggeredCameraIDs()
: undefined}
></frigate-card-views>`} ></frigate-card-views>`}
${ ${
// Keep message rendering to last to show messages that may have been // Keep message rendering to last to show messages that may have been
+8 -2
View File
@@ -3,10 +3,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../config/types'; import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import liveImageStyle from '../../scss/live-image.scss'; import liveImageStyle from '../../scss/live-image.scss';
import { FrigateCardMediaPlayer } from '../../types.js'; import { FrigateCardMediaPlayer } from '../../types.js';
import '../image.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj'; import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
import '../image.js';
@customElement('frigate-card-live-image') @customElement('frigate-card-live-image')
export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer { export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer {
@@ -79,7 +80,12 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
> >
</frigate-card-image> </frigate-card-image>
${this.watermark ? html`<ha-icon icon="${this.watermark}"></ha-icon>` : ''} ${this.watermark
? html`<ha-icon
title=${localize('error.awaiting_live')}
icon="${this.watermark}"
></ha-icon>`
: ''}
`; `;
} }
+13 -1
View File
@@ -31,6 +31,7 @@ import {
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import basicBlockStyle from '../../scss/basic-block.scss'; import basicBlockStyle from '../../scss/basic-block.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveGridStyle from '../../scss/live-grid.scss';
import liveProviderStyle from '../../scss/live-provider.scss'; import liveProviderStyle from '../../scss/live-provider.scss';
import { import {
ExtendedHomeAssistant, ExtendedHomeAssistant,
@@ -114,6 +115,9 @@ export class FrigateCardLive extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public microphoneStream?: MediaStream; public microphoneStream?: MediaStream;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
// Whether or not the live view is currently in the background (i.e. preloaded // Whether or not the live view is currently in the background (i.e. preloaded
// but not visible) // but not visible)
@state() @state()
@@ -219,6 +223,7 @@ export class FrigateCardLive extends LitElement {
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.microphoneStream=${this.microphoneStream} .microphoneStream=${this.microphoneStream}
.triggeredCameraIDs=${this.triggeredCameraIDs}
@frigate-card:message=${(ev: CustomEvent<Message>) => { @frigate-card:message=${(ev: CustomEvent<Message>) => {
this._renderKey++; this._renderKey++;
this._messageReceivedPostRender = true; this._messageReceivedPostRender = true;
@@ -283,7 +288,12 @@ export class FrigateCardLiveGrid extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public microphoneStream?: MediaStream; public microphoneStream?: MediaStream;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
protected _renderCarousel(cameraID?: string): TemplateResult { protected _renderCarousel(cameraID?: string): TemplateResult {
const triggeredCameraID = cameraID ?? this.view?.camera;
return html` return html`
<frigate-card-live-carousel <frigate-card-live-carousel
grid-id=${ifDefined(cameraID)} grid-id=${ifDefined(cameraID)}
@@ -297,6 +307,8 @@ export class FrigateCardLiveGrid extends LitElement {
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.microphoneStream=${this.microphoneStream} .microphoneStream=${this.microphoneStream}
?triggered=${triggeredCameraID &&
!!this.triggeredCameraIDs?.has(triggeredCameraID)}
> >
</frigate-card-live-carousel> </frigate-card-live-carousel>
`; `;
@@ -351,7 +363,7 @@ export class FrigateCardLiveGrid extends LitElement {
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(basicBlockStyle); return unsafeCSS(liveGridStyle);
} }
} }
+2 -1
View File
@@ -43,6 +43,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
public fadeThumbnails = false; public fadeThumbnails = false;
protected _thumbnailSlides: TemplateResult[] = []; protected _thumbnailSlides: TemplateResult[] = [];
protected _plugins = [AutoSize()];
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) { if (changedProps.has('config')) {
@@ -137,7 +138,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
return html`<frigate-card-carousel return html`<frigate-card-carousel
direction=${direction} direction=${direction}
.plugins=${[AutoSize()]} .plugins=${this._plugins}
.selected=${this._getSelectedSlide() ?? 0} .selected=${this._getSelectedSlide() ?? 0}
.dragFree=${true} .dragFree=${true}
> >
+4
View File
@@ -62,6 +62,9 @@ export class FrigateCardViews extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public microphoneStream?: MediaStream; public microphoneStream?: MediaStream;
@property({ attribute: false })
public triggeredCameraIDs?: Set<string>;
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) { if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) { if (this.view?.is('live') || this._shouldLivePreload()) {
@@ -239,6 +242,7 @@ export class FrigateCardViews extends LitElement {
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.microphoneStream=${this.microphoneStream} .microphoneStream=${this.microphoneStream}
.triggeredCameraIDs=${this.triggeredCameraIDs}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
> >
</frigate-card-live> </frigate-card-live>
+11
View File
@@ -13,6 +13,8 @@ import {
CONF_MEDIA_GALLERY, CONF_MEDIA_GALLERY,
CONF_MENU_BUTTONS_CAMERA_UI, CONF_MENU_BUTTONS_CAMERA_UI,
CONF_OVERRIDES, CONF_OVERRIDES,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
} from './const'; } from './const';
import { arrayify } from './utils/basic'; import { arrayify } from './utils/basic';
@@ -474,4 +476,13 @@ const UPGRADES = [
); );
}, },
upgradePTZElementsToLive(), upgradePTZElementsToLive(),
upgradeMoveToWithOverrides(
'view.scan.untrigger_reset',
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
{
// Delete the value if it's set to the default.
transform: (val) => (val ? 'default' : null),
},
),
upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS),
]; ];
+33 -6
View File
@@ -241,7 +241,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 =
@@ -488,6 +489,8 @@ export const frigateCardConditionSchema = z.object({
state: stateConditions.optional(), state: stateConditions.optional(),
media_query: z.string().optional(), media_query: z.string().optional(),
display_mode: viewDisplayModeSchema.optional(), display_mode: viewDisplayModeSchema.optional(),
triggered: z.string().array().optional(),
interaction: z.boolean().optional(),
}); });
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>; export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
@@ -1112,7 +1115,8 @@ export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
const viewConfigDefault = { const viewConfigDefault = {
default: FRIGATE_CARD_VIEW_DEFAULT, default: FRIGATE_CARD_VIEW_DEFAULT,
camera_select: 'current' as const, camera_select: 'current' as const,
timeout_seconds: 300, interaction_seconds: 300,
reset_after_interaction: true,
update_seconds: 0, update_seconds: 0,
update_force: false, update_force: false,
update_cycle_camera: false, update_cycle_camera: false,
@@ -1120,16 +1124,36 @@ const viewConfigDefault = {
scan: { scan: {
enabled: false, enabled: false,
show_trigger_status: true, show_trigger_status: true,
filter_selected_camera: false,
actions: {
interaction_mode: 'inactive' as const,
trigger: 'live' as const,
untrigger: 'default' as const,
},
untrigger_seconds: 0, untrigger_seconds: 0,
untrigger_reset: true,
}, },
}; };
const scanSchema = z.object({ export const scanSchema = z.object({
enabled: z.boolean().default(viewConfigDefault.scan.enabled), enabled: z.boolean().default(viewConfigDefault.scan.enabled),
filter_selected_camera: z
.boolean()
.default(viewConfigDefault.scan.filter_selected_camera),
show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status),
actions: z
.object({
interaction_mode: z
.enum(['all', 'inactive', 'active'])
.default(viewConfigDefault.scan.actions.interaction_mode),
trigger: z.enum(['live', 'none']).default(viewConfigDefault.scan.actions.trigger),
untrigger: z
.enum(['default', 'none'])
.default(viewConfigDefault.scan.actions.untrigger),
})
.default(viewConfigDefault.scan.actions),
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>;
@@ -1141,7 +1165,10 @@ const viewConfigSchema = z
camera_select: z camera_select: z
.enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current'])
.default(viewConfigDefault.camera_select), .default(viewConfigDefault.camera_select),
timeout_seconds: z.number().default(viewConfigDefault.timeout_seconds), interaction_seconds: z.number().default(viewConfigDefault.interaction_seconds),
reset_after_interaction: z
.boolean()
.default(viewConfigDefault.reset_after_interaction),
update_seconds: z.number().default(viewConfigDefault.update_seconds), update_seconds: z.number().default(viewConfigDefault.update_seconds),
update_force: z.boolean().default(viewConfigDefault.update_force), update_force: z.boolean().default(viewConfigDefault.update_force),
update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera), update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera),
+11 -3
View File
@@ -71,18 +71,26 @@ const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const; export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const;
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const; export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
export const CONF_VIEW_TIMEOUT_SECONDS = `${CONF_VIEW}.timeout_seconds` as const; export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const;
export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const; export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const;
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; 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_RESET_AFTER_INTERACTION = `${CONF_VIEW}.reset_after_interaction` 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_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_SCAN}.show_trigger_status` as const; `${CONF_VIEW_SCAN}.show_trigger_status` as const;
export const CONF_VIEW_SCAN_UNTRIGGER_RESET = export const CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA =
`${CONF_VIEW_SCAN}.untrigger_reset` as const; `${CONF_VIEW_SCAN}.filter_selected_camera` 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;
export const CONF_VIEW_SCAN_ACTIONS = `${CONF_VIEW_SCAN}.actions` as const;
export const CONF_VIEW_SCAN_ACTIONS_TRIGGER =
`${CONF_VIEW_SCAN_ACTIONS}.trigger` as const;
export const CONF_VIEW_SCAN_ACTIONS_UNTRIGGER =
`${CONF_VIEW_SCAN_ACTIONS}.untrigger` as const;
export const CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_SCAN_ACTIONS}.interaction_mode` as const;
export const CONF_MEDIA_GALLERY = 'media_gallery' as const; export const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE = export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
+110 -47
View File
@@ -22,9 +22,9 @@ import {
} from './config-mgmt.js'; } from './config-mgmt.js';
import { import {
BUTTON_SIZE_MIN, BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FrigateCardConfig, FrigateCardConfig,
frigateCardConfigDefaults, frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
RawFrigateCardConfig, RawFrigateCardConfig,
RawFrigateCardConfigArray, RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
@@ -157,8 +157,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT, CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS, CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION, CONF_MENU_POSITION,
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -180,12 +180,17 @@ import {
CONF_VIEW_CAMERA_SELECT, CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE, CONF_VIEW_DARK_MODE,
CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_RESET_AFTER_INTERACTION,
CONF_VIEW_SCAN, CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ACTIONS,
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
CONF_VIEW_SCAN_ACTIONS_TRIGGER,
CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
CONF_VIEW_SCAN_ENABLED, CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_SCAN_UNTRIGGER_RESET,
CONF_VIEW_SCAN_UNTRIGGER_SECONDS, CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS, CONF_VIEW_UPDATE_SECONDS,
@@ -238,6 +243,7 @@ const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style'; const MENU_PERFORMANCE_STYLE = 'performance.style';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_SCAN = 'scan'; const MENU_VIEW_SCAN = 'scan';
const MENU_VIEW_SCAN_ACTIONS = 'scan.actions';
interface EditorOptionsSet { interface EditorOptionsSet {
icon: string; icon: string;
@@ -621,6 +627,46 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _scanActionsInteractionModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'all',
label: localize('config.view.scan.actions.interaction_modes.all'),
},
{
value: 'inactive',
label: localize('config.view.scan.actions.interaction_modes.inactive'),
},
{
value: 'active',
label: localize('config.view.scan.actions.interaction_modes.active'),
},
];
protected _scanActionsTrigger: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'live',
label: localize('config.view.scan.actions.triggers.live'),
},
{
value: 'none',
label: localize('config.view.scan.actions.triggers.none'),
},
];
protected _scanActionsUntrigger: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'default',
label: localize('config.view.scan.actions.untriggers.default'),
},
{
value: 'none',
label: localize('config.view.scan.actions.untriggers.none'),
},
];
public setConfig(config: RawFrigateCardConfig): void { public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be // Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially // partially or completely invalid. It's more useful to have a partially
@@ -877,48 +923,61 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
} }
protected _renderViewScanMenu(): TemplateResult { protected _renderViewScanMenu(): TemplateResult {
const submenuClasses = { return this._putInSubmenu(
submenu: true, MENU_VIEW_SCAN,
selected: !!this._expandedMenus[MENU_VIEW_SCAN], true,
}; `config.${CONF_VIEW_SCAN}.editor_label`,
return html` { name: 'mdi:target-account' },
<div class="${classMap(submenuClasses)}"> html`
<div ${this._renderSwitch(CONF_VIEW_SCAN_ENABLED, this._defaults.view.scan.enabled, {
class="submenu-header" label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`),
@click=${this._toggleMenu} })}
.domain=${MENU_VIEW_SCAN} ${this._renderSwitch(
.key=${true} CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA,
> this._defaults.view.scan.filter_selected_camera,
<ha-icon .icon=${'mdi:target-account'}></ha-icon> {
<span>${localize(`config.${CONF_VIEW_SCAN}.scan_mode`)}</span> label: localize(`config.${CONF_VIEW_SCAN_FILTER_SELECTED_CAMERA}`),
</div> },
${this._expandedMenus[MENU_VIEW_SCAN] )}
? html` <div class="values"> ${this._renderSwitch(
${this._renderSwitch( CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_SCAN_ENABLED, this._defaults.view.scan.show_trigger_status,
this._defaults.view.scan.enabled, {
{ label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`),
label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`), },
}, )}
)} ${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, {
${this._renderSwitch( default: this._defaults.view.scan.untrigger_seconds,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, })}
this._defaults.view.scan.show_trigger_status, ${this._putInSubmenu(
{ MENU_VIEW_SCAN_ACTIONS,
label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), true,
}, `config.${CONF_VIEW_SCAN_ACTIONS}.editor_label`,
)} { name: 'mdi:cogs' },
${this._renderSwitch( html` ${this._renderOptionSelector(
CONF_VIEW_SCAN_UNTRIGGER_RESET, CONF_VIEW_SCAN_ACTIONS_TRIGGER,
this._defaults.view.scan.untrigger_reset, this._scanActionsTrigger,
)} {
${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, { label: localize('config.view.scan.actions.trigger'),
default: this._defaults.view.scan.untrigger_seconds, },
})} )}
</div>` ${this._renderOptionSelector(
: ''} CONF_VIEW_SCAN_ACTIONS_UNTRIGGER,
</div> this._scanActionsUntrigger,
`; {
label: localize('config.view.scan.actions.untrigger'),
},
)}
${this._renderOptionSelector(
CONF_VIEW_SCAN_ACTIONS_INTERACTION_MODE,
this._scanActionsInteractionModes,
{
label: localize('config.view.scan.actions.interaction_mode'),
},
)}`,
)}
`,
);
} }
/** /**
@@ -1883,7 +1942,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
this._cameraSelectViewModes, this._cameraSelectViewModes,
)} )}
${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)}
${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)} ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)}
${this._renderSwitch(
CONF_VIEW_RESET_AFTER_INTERACTION,
this._defaults.view.reset_after_interaction,
)}
${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)}
${this._renderSwitch( ${this._renderSwitch(
CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_FORCE,
+24 -3
View File
@@ -385,13 +385,33 @@
}, },
"default": "Default view", "default": "Default view",
"scan": { "scan": {
"actions": {
"editor_label": "Scan mode actions",
"interaction_mode": "How to handle actions when the card has human interaction",
"interaction_modes": {
"active": "Only trigger actions when card has human interaction",
"all": "Trigger actions regardless of human interaction",
"inactive": "Only trigger actions when card has no human interaction"
},
"trigger": "Trigger action",
"triggers": {
"live": "Change to live",
"none": "No action"
},
"untrigger": "Untrigger action",
"untriggers": {
"default": "Change to default view/camera",
"none": "No action"
}
},
"editor_label": "Scan mode",
"filter_selected_camera": "Only trigger on selected camera",
"enabled": "Scan mode enabled", "enabled": "Scan mode enabled",
"scan_mode": "Scan mode",
"show_trigger_status": "Show pulsing border when triggered", "show_trigger_status": "Show pulsing border when triggered",
"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"
}, },
"timeout_seconds": "Reset to default view X seconds after user action (0=never)", "interaction_seconds": "Seconds after user action to remain interacted with (0=never)",
"reset_after_interaction": "Reset to the default view after user interaction",
"update_cycle_camera": "Cycle through cameras when default view updates", "update_cycle_camera": "Cycle through cameras when default view updates",
"update_force": "Force card updates (ignore user interaction)", "update_force": "Force card updates (ignore user interaction)",
"update_seconds": "Refresh default view every X seconds (0=never)", "update_seconds": "Refresh default view every X seconds (0=never)",
@@ -457,6 +477,7 @@
} }
}, },
"error": { "error": {
"awaiting_live": "Waiting for live stream to load ...",
"could_not_render_elements": "Could not render picture elements", "could_not_render_elements": "Could not render picture elements",
"could_not_resolve": "Could not resolve media URL", "could_not_resolve": "Could not resolve media URL",
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing", "diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
+39 -18
View File
@@ -18,8 +18,8 @@
"editor_label": "", "editor_label": "",
"method": "", "method": "",
"methods": { "methods": {
"standard": "", "dashboard": "",
"dashboard": "" "standard": ""
} }
}, },
"dependencies": { "dependencies": {
@@ -236,27 +236,27 @@
"editor_label": "Controlli dal vivo", "editor_label": "Controlli dal vivo",
"ptz": { "ptz": {
"editor_label": "", "editor_label": "",
"hide_home": "",
"hide_pan_tilt": "",
"hide_zoom": "",
"mode": "", "mode": "",
"modes": { "modes": {
"on": "", "auto": "",
"off": "", "off": "",
"auto": "" "on": ""
}, },
"orientation": "", "orientation": "",
"orientations": { "orientations": {
"vertical": "", "horizontal": "",
"horizontal": "" "vertical": ""
}, },
"position": "", "position": "",
"positions": { "positions": {
"top-left": "",
"top-right": "",
"bottom-left": "", "bottom-left": "",
"bottom-right": "" "bottom-right": "",
}, "top-left": "",
"hide_zoom": "", "top-right": ""
"hide_pan_tilt": "", }
"hide_home": ""
} }
}, },
"draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere",
@@ -380,14 +380,34 @@
"on": "On" "on": "On"
}, },
"default": "Visualizzazione predefinita", "default": "Visualizzazione predefinita",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": { "scan": {
"actions": {
"editor_label": "",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
},
"trigger": "",
"triggers": {
"live": "",
"none": ""
},
"untrigger": "",
"untriggers": {
"default": "",
"none": ""
}
},
"editor_label": "Modalità di scansione",
"enabled": "Modalità di scansione abilitata", "enabled": "Modalità di scansione abilitata",
"scan_mode": "Modalità di scansione", "filter_selected_camera": "",
"show_trigger_status": "Mostra bordo pulsante quando attivato", "show_trigger_status": "Mostra bordo pulsante quando attivato",
"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"
}, },
"timeout_seconds": "Ripristina la vista predefinita x secondi dopo l'azione dell'utente (0 = mai)",
"update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita", "update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita",
"update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)", "update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)",
"update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)", "update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)",
@@ -449,6 +469,7 @@
} }
}, },
"error": { "error": {
"awaiting_live": "",
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine", "could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
"could_not_resolve": "Impossibile risolvere l'URL dei media", "could_not_resolve": "Impossibile risolvere l'URL dei media",
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere", "diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
@@ -478,8 +499,8 @@
"no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", "no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta",
"reconnecting": "Riconnessione", "reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi",
"too_many_automations": "", "too_many_automations": "",
"troubleshooting": "Controllare la risoluzione dei problemi",
"unknown": "Errore sconosciuto", "unknown": "Errore sconosciuto",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive", "upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore", "webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
@@ -548,4 +569,4 @@
}, },
"select_date": "Scegli la data" "select_date": "Scegli la data"
} }
} }
+38 -17
View File
@@ -18,8 +18,8 @@
"editor_label": "", "editor_label": "",
"method": "", "method": "",
"methods": { "methods": {
"standard": "", "dashboard": "",
"dashboard": "" "standard": ""
} }
}, },
"dependencies": { "dependencies": {
@@ -236,27 +236,27 @@
"editor_label": "Controles da visualização ao vivo", "editor_label": "Controles da visualização ao vivo",
"ptz": { "ptz": {
"editor_label": "", "editor_label": "",
"hide_home": "",
"hide_pan_tilt": "",
"hide_zoom": "",
"mode": "", "mode": "",
"modes": { "modes": {
"on": "", "auto": "",
"off": "", "off": "",
"auto": "" "on": ""
}, },
"orientation": "", "orientation": "",
"orientations": { "orientations": {
"vertical": "", "horizontal": "",
"horizontal": "" "vertical": ""
}, },
"position": "", "position": "",
"positions": { "positions": {
"top-left": "",
"top-right": "",
"bottom-left": "", "bottom-left": "",
"bottom-right": "" "bottom-right": "",
}, "top-left": "",
"hide_zoom": "", "top-right": ""
"hide_pan_tilt": "", }
"hide_home": ""
} }
}, },
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
@@ -383,14 +383,34 @@
"on": "Ligado" "on": "Ligado"
}, },
"default": "Visualização padrão", "default": "Visualização padrão",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": { "scan": {
"actions": {
"editor_label": "",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
},
"trigger": "",
"triggers": {
"live": "",
"none": ""
},
"untrigger": "",
"untriggers": {
"default": "",
"none": ""
}
},
"editor_label": "Modo scan",
"enabled": "Modo scan ativado", "enabled": "Modo scan ativado",
"scan_mode": "Modo scan", "filter_selected_camera": "",
"show_trigger_status": "Pulsar borda quando acionado", "show_trigger_status": "Pulsar borda quando acionado",
"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"
}, },
"timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
"update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
"update_force": "Forçar atualizações do cartão (ignore a interação do usuário)", "update_force": "Forçar atualizações do cartão (ignore a interação do usuário)",
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
@@ -456,6 +476,7 @@
} }
}, },
"error": { "error": {
"awaiting_live": "",
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
"could_not_resolve": "Não foi possível resolver o URL de mídia", "could_not_resolve": "Não foi possível resolver o URL de mídia",
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar", "diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
@@ -558,4 +579,4 @@
}, },
"select_date": "Escolha a data" "select_date": "Escolha a data"
} }
} }
+38 -17
View File
@@ -18,8 +18,8 @@
"editor_label": "", "editor_label": "",
"method": "", "method": "",
"methods": { "methods": {
"standard": "", "dashboard": "",
"dashboard": "" "standard": ""
} }
}, },
"dependencies": { "dependencies": {
@@ -229,27 +229,27 @@
"editor_label": "Controles da visualização ao vivo", "editor_label": "Controles da visualização ao vivo",
"ptz": { "ptz": {
"editor_label": "", "editor_label": "",
"hide_home": "",
"hide_pan_tilt": "",
"hide_zoom": "",
"mode": "", "mode": "",
"modes": { "modes": {
"on": "", "auto": "",
"off": "", "off": "",
"auto": "" "on": ""
}, },
"orientation": "", "orientation": "",
"orientations": { "orientations": {
"vertical": "", "horizontal": "",
"horizontal": "" "vertical": ""
}, },
"position": "", "position": "",
"positions": { "positions": {
"top-left": "",
"top-right": "",
"bottom-left": "", "bottom-left": "",
"bottom-right": "" "bottom-right": "",
}, "top-left": "",
"hide_zoom": "", "top-right": ""
"hide_pan_tilt": "", }
"hide_home": ""
} }
}, },
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
@@ -373,14 +373,34 @@
"on": "Ligado" "on": "Ligado"
}, },
"default": "Visualização padrão", "default": "Visualização padrão",
"interaction_seconds": "",
"reset_after_interaction": "",
"scan": { "scan": {
"actions": {
"editor_label": "",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
},
"trigger": "",
"triggers": {
"live": "",
"none": ""
},
"untrigger": "",
"untriggers": {
"default": "",
"none": ""
}
},
"editor_label": "Modo scan",
"enabled": "Modo scan ativado", "enabled": "Modo scan ativado",
"scan_mode": "Modo scan", "filter_selected_camera": "",
"show_trigger_status": "Exibir estado do gatilho", "show_trigger_status": "Exibir estado do gatilho",
"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"
}, },
"timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
"update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada", "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
"update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)", "update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)",
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)", "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
@@ -442,6 +462,7 @@
} }
}, },
"error": { "error": {
"awaiting_live": "",
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem", "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
"could_not_resolve": "Não foi possível resolver o URL de mídia", "could_not_resolve": "Não foi possível resolver o URL de mídia",
"diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar", "diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar",
@@ -471,8 +492,8 @@
"no_visible_cameras": "Sem camaras visiveis", "no_visible_cameras": "Sem camaras visiveis",
"reconnecting": "A voltar a ligar", "reconnecting": "A voltar a ligar",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"troubleshooting": "Verifique a solução de problemas",
"too_many_automations": "", "too_many_automations": "",
"troubleshooting": "Verifique a solução de problemas",
"unknown": "Erro desconhecido", "unknown": "Erro desconhecido",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão", "upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro", "webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
+1 -19
View File
@@ -21,7 +21,7 @@
// The standard HA header is 56 pixels tall, so that much off the top (header) // The standard HA header is 56 pixels tall, so that much off the top (header)
// and bottom (to maintain center), before doing the calculation of // and bottom (to maintain center), before doing the calculation of
// max-height. This matters on small mobile devices in landscape orientation. // max-height. This matters on small mobile devices in landscape orientation.
--frigate-card-expand-max-height: calc( ( 100vh - (2 * 56px) ) * 0.85 ); --frigate-card-expand-max-height: calc((100vh - (2 * 56px)) * 0.85);
--frigate-card-expand-max-width: 85vw; --frigate-card-expand-max-width: 85vw;
--frigate-card-expand-width: none; --frigate-card-expand-width: none;
--frigate-card-expand-height: none; --frigate-card-expand-height: none;
@@ -52,10 +52,6 @@ div.main {
// Necessary to get Safari to show border-radius correctly. // Necessary to get Safari to show border-radius correctly.
transform: translateZ(0); transform: translateZ(0);
// Include the borders in the sizing (to keep the triggered warning pulse
// visible in fullscreen).
box-sizing: border-box;
// Hide scrollbar: Firefox // Hide scrollbar: Firefox
scrollbar-width: none; scrollbar-width: none;
// Hide scrollbar: IE and Edge // Hide scrollbar: IE and Edge
@@ -112,20 +108,6 @@ ha-card {
position: static; position: static;
color: var(--secondary-text-color, white); color: var(--secondary-text-color, white);
} }
ha-card.triggered {
@keyframes warning-pulse {
0% {
border: solid 2px rgba(0, 0, 0, 0);
}
50% {
border: solid 2px var(--warning-color);
}
100% {
border: solid 2px rgba(0, 0, 0, 0);
}
}
animation: warning-pulse 5s infinite;
}
/************ /************
* Fullscreen * Fullscreen
+1
View File
@@ -1,4 +1,5 @@
:host { :host {
display: block;
--video-max-height: none; --video-max-height: none;
} }
+20
View File
@@ -0,0 +1,20 @@
@use 'basic-block.scss';
@keyframes warning-pulse {
0% {
border: solid 2px var(--frigate-card-triggered-color-1, rgba(0, 0, 0, 0));
}
50% {
border: solid 2px var(--frigate-card-triggered-color-2, var(--warning-color));
}
100% {
border: solid 2px var(--frigate-card-triggered-color-1, rgba(0, 0, 0, 0));
}
}
frigate-card-live-carousel[triggered] {
animation: warning-pulse 5s infinite;
}
frigate-card-live-carousel[selected] {
--frigate-card-triggered-color-1: var(--primary-color);
}
+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();
});
});
@@ -136,6 +136,11 @@ describe('CardElementManager', () => {
); );
expect(windowAddEventListener).toBeCalledWith('location-changed', expect.anything()); expect(windowAddEventListener).toBeCalledWith('location-changed', expect.anything());
expect(windowAddEventListener).toBeCalledWith('popstate', expect.anything()); expect(windowAddEventListener).toBeCalledWith('popstate', expect.anything());
expect(api.getInteractionManager().initialize).toBeCalled();
expect(api.getFullscreenManager().initialize).toBeCalled();
expect(api.getExpandManager().initialize).toBeCalled();
expect(api.getMediaLoadedInfoManager().initialize).toBeCalled();
}); });
it('should disconnect', () => { it('should disconnect', () => {
@@ -360,4 +360,26 @@ describe('ConditionsManager', () => {
manager.setState({ displayMode: 'single' }); manager.setState({ displayMode: 'single' });
expect(manager.evaluateCondition(condition)).toBeFalsy(); expect(manager.evaluateCondition(condition)).toBeFalsy();
}); });
it('should evaluate conditions with triggers', () => {
const manager = new ConditionsManager(createCardAPI());
const condition: FrigateCardCondition = { triggered: ['camera_1', 'camera_2'] };
expect(manager.evaluateCondition(condition)).toBeFalsy();
manager.setState({ triggered: new Set(['camera_1']) });
expect(manager.evaluateCondition(condition)).toBeTruthy();
manager.setState({ triggered: new Set(['camera_2', 'camera_1', 'camera_3']) });
expect(manager.evaluateCondition(condition)).toBeTruthy();
manager.setState({ triggered: new Set(['camera_3']) });
expect(manager.evaluateCondition(condition)).toBeFalsy();
});
it('should evaluate conditions with interaction', () => {
const manager = new ConditionsManager(createCardAPI());
const condition: FrigateCardCondition = { interaction: true };
expect(manager.evaluateCondition(condition)).toBeFalsy();
manager.setState({ interaction: true })
expect(manager.evaluateCondition(condition)).toBeTruthy();
manager.setState({ interaction: false })
expect(manager.evaluateCondition(condition)).toBeFalsy();
});
}); });
@@ -9,6 +9,14 @@ describe('ExpandManager', () => {
expect(manager.isExpanded()).toBeFalsy(); expect(manager.isExpanded()).toBeFalsy();
}); });
it('should initialize', () => {
const api = createCardAPI();
const manager = new ExpandManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ expand: false });
});
it('should set expanded', () => { it('should set expanded', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true); vi.mocked(api.getFullscreenManager().isInFullscreen).mockReturnValue(true);
@@ -29,6 +29,17 @@ describe('FullscreenManager', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should initialize', () => {
const api = createCardAPI();
const manager = new FullscreenManager(api);
setScreenfulEnabled(true);
setScreenfulFullscreen(false);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ fullscreen: false });
});
it('should correctly determine whether in fullscreen', () => { it('should correctly determine whether in fullscreen', () => {
const manager = new FullscreenManager(createCardAPI()); const manager = new FullscreenManager(createCardAPI());
+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', () => {
@@ -15,12 +15,20 @@ describe('InteractionManager', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('should initialize', () => {
const api = createCardAPI();
const manager = new InteractionManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false });
});
it('should take action when interaction is reported', () => { it('should take action when interaction is reported', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
view: { view: {
timeout_seconds: 10, interaction_seconds: 10,
}, },
}), }),
); );
@@ -30,7 +38,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();
@@ -46,7 +53,7 @@ describe('InteractionManager', () => {
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
view: { view: {
timeout_seconds: 10, interaction_seconds: 10,
}, },
}), }),
); );
@@ -65,12 +72,12 @@ describe('InteractionManager', () => {
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
it('should not take action when not configured', () => { it('should not take action without an interaction timeout', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
view: { view: {
timeout_seconds: 0, interaction_seconds: 0,
}, },
}), }),
); );
@@ -82,4 +89,60 @@ describe('InteractionManager', () => {
// true but the default view will not have been set. // true but the default view will not have been set.
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
it('should not take action without reset_after_interaction', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
reset_after_interaction: false,
interaction_seconds: 10,
},
}),
);
const manager = new InteractionManager(api);
vi.useFakeTimers();
vi.setSystemTime(start);
manager.reportInteraction();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
// First call is blocked by triggers (above), so interaction will report
// true but the default view will not have been set.
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should set condition state', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
interaction_seconds: 10,
},
}),
);
const manager = new InteractionManager(api);
vi.useFakeTimers();
vi.setSystemTime(start);
expect(api.getConditionsManager().setState).not.toBeCalled();
manager.reportInteraction();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
interaction: true,
}),
);
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
interaction: false,
}),
);
});
}); });
@@ -3,6 +3,14 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man
import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js'; import { createCardAPI, createMediaLoadedInfo } from '../test-utils.js';
describe('MediaLoadedInfoManager', () => { describe('MediaLoadedInfoManager', () => {
it('should initialize', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ media_loaded: false });
});
it('should set', () => { it('should set', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api); const manager = new MediaLoadedInfoManager(api);
@@ -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();
}); });
}); });
}); });
+261 -54
View File
@@ -3,7 +3,7 @@ import { HassEntities } from 'home-assistant-js-websocket';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CardController } from '../../src/card-controller/controller'; import { CardController } from '../../src/card-controller/controller';
import { TriggersManager } from '../../src/card-controller/triggers-manager'; import { TriggersManager } from '../../src/card-controller/triggers-manager';
import { ScanOptions } from '../../src/config/types'; import { ScanOptions, scanSchema } from '../../src/config/types';
import { import {
createCameraConfig, createCameraConfig,
createCameraManager, createCameraManager,
@@ -15,6 +15,17 @@ import {
createView, createView,
} from '../test-utils'; } from '../test-utils';
const baseScanConfig: Partial<ScanOptions> = {
enabled: true,
untrigger_seconds: 10,
filter_selected_camera: false,
actions: {
trigger: 'live' as const,
untrigger: 'default' as const,
interaction_mode: 'inactive' 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 +37,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 ? scanSchema.parse(options.config) : baseScanConfig,
enabled: true,
untrigger_reset: true,
untrigger_seconds: 10,
},
}, },
}), }),
); );
@@ -83,11 +90,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 +120,26 @@ 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.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']),
});
expect(api.getViewManager().setViewByParameters).toBeCalledWith({ expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: 'live', viewName: 'live' as const,
cameraID: 'camera_1', cameraID: 'camera_1' 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,29 +148,13 @@ describe('TriggersManager', () => {
vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy(); expect(manager.isTriggered()).toBeFalsy();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: undefined,
});
expect(api.getViewManager().setViewDefault).toBeCalled(); expect(api.getViewManager().setViewDefault).toBeCalled();
}); });
it('should trigger and set view if current view is wrong', () => {
const api = createTriggerAPI({
hassStates: hassActiveState,
});
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',
});
});
it('should trigger when entity state is active on startup', () => { it('should trigger when entity state is active on startup', () => {
const api = createTriggerAPI({ const api = createTriggerAPI({
hassStates: hassActiveState, hassStates: hassActiveState,
@@ -150,40 +162,235 @@ 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(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
manager.untrigger(); 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.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
it('should take no actions when actions are set to none', () => {
const start = new Date('2023-10-01T17:14');
const api = createTriggerAPI({
hassStates: hassActiveState,
config: {
enabled: true,
actions: {
interaction_mode: 'all',
trigger: 'none',
untrigger: 'none',
},
},
});
const manager = new TriggersManager(api);
manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).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.getViewManager().setViewDefault).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,
actions: {
trigger: 'live' as const,
untrigger: 'default' as const,
interaction_mode: 'active',
},
},
});
const manager = new TriggersManager(api);
manager.updateTriggerHAState(createHASS(hassInactiveState));
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: 'live' as const,
cameraID: 'camera_1' as const,
});
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.getViewManager().setViewDefault).toBeCalled();
});
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.
filter_selected_camera: true,
},
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.
filter_selected_camera: true,
},
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();
});
}); });
+63
View File
@@ -1166,6 +1166,69 @@ describe('should handle version specific upgrades', () => {
type: 'custom:frigate-card', type: 'custom:frigate-card',
}); });
}); });
});
describe('should move and transform untrigger_reset', () => {
it('when true', () => {
const config = {
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
scan: {
untrigger_reset: true,
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
scan: {
actions: {
untrigger: 'default',
},
},
},
});
});
it('when false', () => {
const config = {
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
scan: {
untrigger_reset: false,
},
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
scan: {},
},
});
});
});
it('should move view.timeout_seconds', () => {
const config = {
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
timeout_seconds: 200,
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
interaction_seconds: 200,
},
});
}); });
}); });
+8 -2
View File
@@ -280,10 +280,16 @@ describe('config defaults', () => {
scan: { scan: {
enabled: false, enabled: false,
show_trigger_status: true, show_trigger_status: true,
untrigger_reset: true,
untrigger_seconds: 0, untrigger_seconds: 0,
actions: {
trigger: 'live',
untrigger: 'default',
interaction_mode: 'inactive',
},
filter_selected_camera: false,
}, },
timeout_seconds: 300, interaction_seconds: 300,
reset_after_interaction: true,
update_cycle_camera: false, update_cycle_camera: false,
update_force: false, update_force: false,
update_seconds: 0, update_seconds: 0,
+4 -4
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.95,
branches: 60.62, branches: 60.8,
functions: 72.78, functions: 73.04,
lines: 71.54, lines: 71.84,
}, },
}, },
}); });