diff --git a/README.md b/README.md
index 3d3de4e9..59e61b70 100644
--- a/README.md
+++ b/README.md
@@ -939,6 +939,25 @@ item, that has both of the following parameters set:
| `conditions` | | :heavy_multiplication_x: | A set of conditions that must evaluate to `true` in order for the overrides to be applied. See [Frigate Card Conditions](#frigate-card-conditions). |
| `overrides` | | :heavy_multiplication_x: |Configuration overrides to be applied. Any configuration parameter described in this documentation as 'Overridable' is supported. |
+
+
+### Automation Optionns
+
+All configuration is a list under:
+
+```yaml
+automations:
+ - [conditions:]
+ [actions:]
+ [actions_not:]
+```
+
+| Option | Default | Overridable | Description |
+| - | - | - | - |
+| `conditions` | | :heavy_multiplication_x: | A set of conditions that will trigger the automation. See [Frigate Card Conditions](#frigate-card-conditions). |
+| `actions` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `true`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
+| `actions_not` | | :heavy_multiplication_x: | An optional list of actions that will be run when the conditions evaluate `false`. Actions can be [stock Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) or [Frigate card actions](#frigate-card-actions).|
+
### Media Layout
@@ -1229,6 +1248,8 @@ Parameters for the `custom:frigate-card-ptz` element:
| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). |
| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. |
+
+
### Special Actions
#### `custom:frigate-card-action`
@@ -1236,7 +1257,7 @@ Parameters for the `custom:frigate-card-ptz` element:
| Parameter | Description |
| - | - |
| `action` | Must be `custom:frigate-card-action`. |
-| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`|
+| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`|
@@ -2629,6 +2650,24 @@ performance:
```
+
+ Expand: Automation section
+
+Reference: [Automation Options](#automation-options).
+
+```yaml
+automations:
+ - conditions:
+ fullscreen: true
+ actions:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_on
+ actions_not:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_off
+```
+
+
Expand: Other options
@@ -3610,6 +3649,31 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
```
+
+### Automation actions
+
+The card can automatically execute actions when certain conditions are met.
+
+
+ Expand: Automatically selecting a high-definition substream in fullscreen mode
+
+This example will automatically turn on the first configured substream when the
+card is put in fullscreen mode, and turn off the substream when exiting
+fullscreen mode.
+
+```yaml
+automations:
+ - conditions:
+ fullscreen: true
+ actions:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_on
+ actions_not:
+ - action: custom:frigate-card-action
+ frigate_card_action: live_substream_off
+```
+
+
## Card Refreshes
diff --git a/src/card.ts b/src/card.ts
index e5f72a8e..0fdaa0ac 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -21,6 +21,7 @@ import 'web-dialog';
import { z } from 'zod';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
+import { AutomationsController } from './automations';
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
import { CameraManager } from './camera-manager/manager.js';
import './components/elements.js';
@@ -91,8 +92,13 @@ import { FrigateCardInitializer } from './utils/initializer.js';
import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { MicrophoneController } from './utils/microphone';
import { getActionsFromQueryString } from './utils/querystring.js';
+import {
+ createViewWithNextStream,
+ createViewWithoutSubstream,
+ createViewWithSelectedSubstream,
+ hasSubstream,
+} from './utils/substream';
import { View } from './view/view.js';
-import { AutomationsController } from './automations';
/** A note on media callbacks:
*
@@ -485,12 +491,9 @@ class FrigateCard extends LitElement {
title: localize('config.menu.buttons.substreams'),
...this._getConfig().menu.buttons.substreams,
type: 'custom:frigate-card-menu-icon',
- tap_action: createFrigateCardCustomAction('live_substream_select', {
- camera:
- override === undefined || override === dependencies[0]
- ? dependencies[1]
- : dependencies[0],
- }) as FrigateCardCustomAction,
+ tap_action: createFrigateCardCustomAction(
+ hasSubstream(this._view) ? 'live_substream_off' : 'live_substream_on',
+ ) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
const menuItems = Array.from(dependencies, (cameraID) => {
@@ -1478,7 +1481,7 @@ class FrigateCard extends LitElement {
}
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
- if (!this._view) {
+ if (!this._view || !this._cameraManager) {
return;
}
@@ -1550,16 +1553,24 @@ class FrigateCard extends LitElement {
});
}
break;
- case 'live_substream_select':
- const overrides: Map =
- this._view.context?.live?.overrides ?? new Map();
- overrides.set(this._view.camera, frigateCardAction.camera);
- this._changeView({
- view: this._view.clone().mergeInContext({
- live: { overrides: overrides },
- }),
- });
+ case 'live_substream_select': {
+ const view = createViewWithSelectedSubstream(
+ this._view,
+ frigateCardAction.camera,
+ );
+ view && this._changeView({ view: view });
break;
+ }
+ case 'live_substream_off': {
+ const view = createViewWithoutSubstream(this._view);
+ view && this._changeView({ view: view });
+ break;
+ }
+ case 'live_substream_on': {
+ const view = createViewWithNextStream(this._cameraManager, this._view);
+ view && this._changeView({ view: view });
+ break;
+ }
case 'media_player':
this._mediaPlayerAction(
frigateCardAction.media_player,
diff --git a/src/types.ts b/src/types.ts
index cc0d4e6d..5b9b065d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -219,6 +219,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image',
'live',
'menu_toggle',
+ 'live_substream_on',
+ 'live_substream_off',
'microphone_mute',
'microphone_unmute',
'recording',
diff --git a/src/utils/camera.ts b/src/utils/camera.ts
index 2982c501..a9b8c6d7 100644
--- a/src/utils/camera.ts
+++ b/src/utils/camera.ts
@@ -28,12 +28,21 @@ export function getCameraID(
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @param cameraID ID of the target camera.
- * @returns A set of dependent cameraIDs or null.
+ * @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
+ * the first item in the set is guaranteed to be the cameraID itself).
*/
-export const getAllDependentCameras = (
+export function getAllDependentCameras(
+ cameraManager: CameraManager,
+ cameraID: string,
+): Set;
+export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
-): Set | null => {
+): Set | null;
+export function getAllDependentCameras(
+ cameraManager?: CameraManager,
+ cameraID?: string,
+): Set | null {
if (!cameraManager || !cameraID) {
return null;
}
@@ -45,9 +54,7 @@ export const getAllDependentCameras = (
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set = new Set();
- (cameraConfig.dependencies.cameras || []).forEach((item) =>
- dependentCameras.add(item),
- );
+ cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
@@ -62,4 +69,4 @@ export const getAllDependentCameras = (
getDependentCameras(cameraID);
}
return cameraIDs;
-};
+}
diff --git a/src/utils/substream.ts b/src/utils/substream.ts
new file mode 100644
index 00000000..715e022b
--- /dev/null
+++ b/src/utils/substream.ts
@@ -0,0 +1,48 @@
+import { CameraManager } from '../camera-manager/manager';
+import { View } from '../view/view';
+import { getAllDependentCameras } from './camera';
+
+export const createViewWithSelectedSubstream = (
+ view: View,
+ substreamID: string,
+): View | null => {
+ const overrides: Map = view.context?.live?.overrides ?? new Map();
+ overrides.set(view.camera, substreamID);
+ return view.clone().mergeInContext({
+ live: { overrides: overrides },
+ });
+};
+
+export const createViewWithoutSubstream = (view: View): View => {
+ const newView = view.clone();
+ const overrides: Map | undefined = newView.context?.live?.overrides;
+ if (overrides && overrides.has(view.camera)) {
+ newView.context?.live?.overrides?.delete(view.camera);
+ }
+ return newView;
+};
+
+export const hasSubstream = (view: View): boolean => {
+ const override = view?.context?.live?.overrides?.get(view.camera);
+ return !!override && override !== view.camera;
+};
+
+export const createViewWithNextStream = (
+ cameraManager: CameraManager,
+ view: View,
+): View => {
+ const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
+ if (dependencies.length <= 1) {
+ return view.clone();
+ }
+
+ const newView = view.clone();
+ const overrides: Map = newView.context?.live?.overrides ?? new Map();
+ const currentOverride = overrides.get(newView.camera) ?? newView.camera;
+ const currentIndex = dependencies.indexOf(currentOverride);
+ const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
+ overrides.set(view.camera, dependencies[newIndex]);
+ newView.mergeInContext({ live: { overrides: overrides } });
+
+ return newView;
+};
diff --git a/tests/test-utils.ts b/tests/test-utils.ts
index f3fc336c..7e14ebdc 100644
--- a/tests/test-utils.ts
+++ b/tests/test-utils.ts
@@ -11,7 +11,7 @@ import {
} from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types';
-export const createCameraConfig = (config: Partial): CameraConfig => {
+export const createCameraConfig = (config: unknown): CameraConfig => {
return cameraConfigSchema.parse(config);
};
diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts
new file mode 100644
index 00000000..1b7c9689
--- /dev/null
+++ b/tests/utils/camera.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
+import { CameraManager } from '../../src/camera-manager/manager.js';
+import { CameraManagerStore } from '../../src/camera-manager/store.js';
+import { CameraConfigs } from '../../src/camera-manager/types.js';
+import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js';
+import { createCameraConfig } from '../test-utils.js';
+
+vi.mock('../../src/camera-manager/manager.js');
+
+describe('getCameraID', () => {
+ it('should get camera id with id', () => {
+ const config = createCameraConfig({ id: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with camera_entity', () => {
+ const config = createCameraConfig({ camera_entity: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with webrtc entity', () => {
+ const config = createCameraConfig({ webrtc_card: { entity: 'foo' } });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with frigate camera_name', () => {
+ const config = createCameraConfig({
+ frigate: { client_id: 'bar', camera_name: 'foo' },
+ });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get blank id without anything', () => {
+ const config = createCameraConfig({});
+ expect(getCameraID(config)).toBe('');
+ });
+});
+
+describe('getAllDependentCameras', () => {
+ it('should return null without cameraManager', () => {
+ expect(getAllDependentCameras()).toBeNull();
+ });
+ it('should return null without cameraID', () => {
+ expect(getAllDependentCameras(mock())).toBeNull();
+ });
+ it('should return dependent cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ cameras: ['two', 'three'],
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+ it('should return all cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ all_cameras: true,
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+});
diff --git a/tests/utils/debug.test.ts b/tests/utils/debug.test.ts
new file mode 100644
index 00000000..7a3b8f2e
--- /dev/null
+++ b/tests/utils/debug.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it, vi } from 'vitest';
+import { log } from '../../src/utils/debug.js';
+
+describe('log', () => {
+ it('should do nothing without debug logging set', () => {
+ const spy = vi.spyOn(global.console, 'debug');
+ log({}, 'foo');
+ expect(spy).not.toBeCalled();
+ });
+ it('should log debug when appropriately configured', () => {
+ const spy = vi.spyOn(global.console, 'debug');
+ log({ debug: { logging: true } }, 'foo');
+ expect(spy).toBeCalledWith('foo');
+ });
+});
diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts
new file mode 100644
index 00000000..9132c764
--- /dev/null
+++ b/tests/utils/substream.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CameraManager } from '../../src/camera-manager/manager';
+import { getAllDependentCameras } from '../../src/utils/camera';
+import {
+ createViewWithNextStream,
+ createViewWithSelectedSubstream,
+ createViewWithoutSubstream,
+ hasSubstream,
+} from '../../src/utils/substream';
+import { View } from '../../src/view/view';
+
+vi.mock('../../src/utils/camera');
+
+describe('createViewWithSelectedSubstream', () => {
+ it('should create view with selected substream', () => {
+ const view = new View({ view: 'live', camera: 'camera' });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+
+ it('should create view with selected substream with existing overrides', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+});
+
+describe('createViewWithoutSubstream', () => {
+ it('should create view without substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithoutSubstream(view);
+ expect(newView?.context?.live?.overrides).toEqual(new Map());
+ });
+});
+
+describe('hasSubstream', () => {
+ it('should detect substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeTruthy();
+ });
+ it('should not detect substream when absent', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+ it('should not detect substream when main stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+});
+
+describe('createViewWithNextStream', () => {
+ it('should create new equal view with no dependencies', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.camera).toBe(view.camera);
+ expect(newView.view).toBe(view.view);
+ expect(newView.context).toEqual(view.context);
+ });
+ it('should create new view with next stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
+ });
+ it('should create new view with next stream that cycles back', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+ it('should create new view with first stream with invalid substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera-that-does-not-exist']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+});