From 867a118a16b9c9491cbca392c76b47db168e9b91 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 6 May 2023 10:42:17 -0700 Subject: [PATCH] Add better control over substreams. --- README.md | 66 +++++++++++++++- src/card.ts | 45 +++++++---- src/types.ts | 2 + src/utils/camera.ts | 21 +++-- src/utils/substream.ts | 48 ++++++++++++ tests/test-utils.ts | 2 +- tests/utils/camera.test.ts | 88 +++++++++++++++++++++ tests/utils/debug.test.ts | 15 ++++ tests/utils/substream.test.ts | 144 ++++++++++++++++++++++++++++++++++ 9 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 src/utils/substream.ts create mode 100644 tests/utils/camera.test.ts create mode 100644 tests/utils/debug.test.ts create mode 100644 tests/utils/substream.test.ts 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']])); + }); +});