From f193f9925f557f9275c258ceed70f7e01e2a8aca Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 8 Jun 2024 11:40:05 -0700 Subject: [PATCH] Add `update` trigger action. --- docs/configuration/profiles.md | 2 +- docs/configuration/view.md | 2 +- docs/troubleshooting.md | 2 +- src/card-controller/triggers-manager.ts | 17 +- src/config/profiles/low-performance.ts | 4 + src/config/types.ts | 17 +- .../card-controller/triggers-manager.test.ts | 356 ++++++++++++------ tests/config/profiles/low-performance.test.ts | 1 + tests/config/types.test.ts | 2 +- 9 files changed, 266 insertions(+), 137 deletions(-) diff --git a/docs/configuration/profiles.md b/docs/configuration/profiles.md index 287e7e07..a3b3a845 100644 --- a/docs/configuration/profiles.md +++ b/docs/configuration/profiles.md @@ -28,7 +28,7 @@ Principles used in the selection of options set by `low-profile` profile mode: - Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card. - Do not break the visual aesthetic of the card. -See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of options set by this profile. +See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of defaults set by this profile. ## `scrubbing` diff --git a/docs/configuration/view.md b/docs/configuration/view.md index f71ce88f..c9436084 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -120,7 +120,7 @@ view: untrigger_seconds: 0 actions: interaction_mode: inactive - trigger: default + trigger: update untrigger: none keyboard_shortcuts: enabled: true diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5d51a4ab..e76eee91 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -56,7 +56,7 @@ This could be for any number of reasons. Chromecast devices can be quite picky on network, DNS and certificate issues, as well as audio and video codecs. Check your Home Assistant log as there may be more information in there. -!>: In particular, for Frigate to support casting of clips, the default ffmpeg +!> In particular, for Frigate to support casting of clips, the default ffmpeg settings for Frigate must be modified, i.e. Frigate does not encode clips in a Chromecast compatible format out of the box (specifically: audio must be enabled in the AAC codec, whether your camera supports audio or not). See the [Frigate diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index 46de4752..67d54eab 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -82,7 +82,8 @@ export class TriggersManager { // If this is a high-fidelity event where we are certain about new media, // don't take action unless it's to change to live (Frigate engine may pump - // out events where there's no new media to show). + // out events where there's no new media to show). Other trigger actions + // (e.g. media, update) do not make sense without having some new media. if ( ev.fidelity === 'high' && !ev.snapshot && @@ -96,7 +97,19 @@ export class TriggersManager { } if (this._hasAllowableInteractionStateForAction()) { - if (triggerAction === 'live') { + if (triggerAction === 'update') { + const view = this._api.getViewManager().getView()?.evolve({ + // Reset the media queries to catch media to be refetched in the + // current view. + query: null, + queryResults: null, + }); + /* istanbul ignore else: the else path cannot be reached, as the camera + cannot be triggered without a view -- @preserve */ + if (view) { + this._api.getViewManager().setView(view.clone()); + } + } else if (triggerAction === 'live') { this._api.getViewManager().setViewByParameters({ viewName: 'live', cameraID: ev.cameraID, diff --git a/src/config/profiles/low-performance.ts b/src/config/profiles/low-performance.ts index 570c8771..92fcea8f 100644 --- a/src/config/profiles/low-performance.ts +++ b/src/config/profiles/low-performance.ts @@ -46,6 +46,7 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_SHOW_RECORDINGS, + CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, } from '../../const.js'; export const LOW_PERFORMANCE_PROFILE = { @@ -134,4 +135,7 @@ export const LOW_PERFORMANCE_PROFILE = { // Refresh the live camera image every 10 seconds (same as stock Home // Assistant Picture Glance). [CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10, + + // No trigger actions. + [CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none' }; diff --git a/src/config/types.ts b/src/config/types.ts index 09d4dab8..8289ecc3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1406,7 +1406,7 @@ const viewConfigDefault = { filter_selected_camera: true, actions: { interaction_mode: 'inactive' as const, - trigger: 'default' as const, + trigger: 'update' as const, untrigger: 'none' as const, }, untrigger_seconds: 0, @@ -1415,26 +1415,25 @@ const viewConfigDefault = { }; export const triggersSchema = z.object({ - filter_selected_camera: z - .boolean() - .default(viewConfigDefault.triggers.filter_selected_camera), - show_trigger_status: z - .boolean() - .default(viewConfigDefault.triggers.show_trigger_status), - actions: z .object({ interaction_mode: z .enum(['all', 'inactive', 'active']) .default(viewConfigDefault.triggers.actions.interaction_mode), trigger: z - .enum(['live', 'default', 'media', 'none']) + .enum(['default', 'live', 'media', 'none', 'update']) .default(viewConfigDefault.triggers.actions.trigger), untrigger: z .enum(['default', 'none']) .default(viewConfigDefault.triggers.actions.untrigger), }) .default(viewConfigDefault.triggers.actions), + filter_selected_camera: z + .boolean() + .default(viewConfigDefault.triggers.filter_selected_camera), + show_trigger_status: z + .boolean() + .default(viewConfigDefault.triggers.show_trigger_status), untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds), }); export type TriggersOptions = z.infer; diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 5ae6b064..e81e8ecb 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -2,7 +2,11 @@ import { add } from 'date-fns'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CardController } from '../../src/card-controller/controller'; import { TriggersManager } from '../../src/card-controller/triggers-manager'; -import { TriggersOptions, triggersSchema } from '../../src/config/types'; +import { + FrigateCardView, + TriggersOptions, + triggersSchema, +} from '../../src/config/types'; import { createCameraConfig, createCameraManager, @@ -16,11 +20,12 @@ vi.mock('lodash-es/throttle', () => ({ default: vi.fn((fn) => fn), })); -const baseTriggersConfig: Partial = { +const baseTriggersConfig: TriggersOptions = { untrigger_seconds: 10, filter_selected_camera: false, + show_trigger_status: false, actions: { - trigger: 'live' as const, + trigger: 'update' as const, untrigger: 'default' as const, interaction_mode: 'inactive' as const, }, @@ -30,6 +35,7 @@ const baseTriggersConfig: Partial = { // function reduces it. const createTriggerAPI = (options?: { config?: Partial; + default?: FrigateCardView; interaction?: boolean; }): CardController => { const api = createCardAPI(); @@ -39,6 +45,7 @@ const createTriggerAPI = (options?: { triggers: options?.config ? triggersSchema.parse(options.config) : baseTriggersConfig, + ...(options?.default && { default: options.default }), }, }), ); @@ -101,161 +108,266 @@ describe('TriggersManager', () => { expect(manager.isTriggered()).toBeFalsy(); }); - it('should trigger and untrigger based on low fidelity event', () => { - const api = createTriggerAPI(); - const manager = new TriggersManager(api); - - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - }); - - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ - triggered: new Set(['camera_1']), - }); - - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - viewName: 'live' as const, - cameraID: 'camera_1' as const, - }); - - vi.mocked(api.getConditionsManager().getState).mockReturnValue({ - triggered: new Set(['camera_1']), - }); - - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'end', - }); - - // Will still be triggered, but untrigger timer will be running. - expect(manager.isTriggered()).toBeTruthy(); - - vi.setSystemTime(add(start, { seconds: 10 })); - vi.runOnlyPendingTimers(); - - expect(manager.isTriggered()).toBeFalsy(); - expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ - triggered: undefined, - }); - - expect(api.getViewManager().setViewDefault).toBeCalled(); - }); - - describe('should treat high fidelity events appropriately', () => { - it('with no media', () => { - const api = createTriggerAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default: 'clips', + describe('trigger actions', () => { + it('update', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'update', }, - }), - ); + }, + }); const manager = new TriggersManager(api); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', - fidelity: 'high', - - // Intentionally blank. }); expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setView).toBeCalled(); + }); + + it('default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'default', + }, + }, + }); + + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewDefault).toBeCalledWith({ + cameraID: 'camera_1', + }); + }); + + it('live', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'live', + }, + }, + }); + + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + viewName: 'live', + cameraID: 'camera_1', + }); + }); + + describe('media', () => { + it.each([ + [false, false, null], + [false, true, 'clip' as const], + [true, false, 'snapshot' as const], + [true, true, 'clip' as const], + ])( + 'with snapshot %s and clip %s', + async ( + hasSnapshot: boolean, + hasClip: boolean, + viewName: 'clip' | 'snapshot' | null, + ) => { + const api = createTriggerAPI({ + config: { + actions: { + interaction_mode: 'all', + trigger: 'media', + untrigger: 'none', + }, + }, + }); + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ + cameraID: 'camera_1', + type: 'new', + fidelity: 'high', + snapshot: hasSnapshot, + clip: hasClip, + }); + + if (!viewName) { + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + } else { + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + cameraID: 'camera_1', + viewName: viewName, + }); + } + }, + ); + }); + + it('none', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + }, + }, + }); + + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(manager.isTriggered()).toBeTruthy(); + expect(api.getViewManager().setView).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + }); + + describe('untrigger actions', () => { + it('none', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + untrigger: 'none', + }, + }, + }); + + const manager = new TriggersManager(api); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + + expect(api.getViewManager().setView).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); - it('with media', () => { - const api = createTriggerAPI(); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - view: { - default: 'clips', + it('default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'none', + untrigger: 'default', }, - }), - ); - - const manager = new TriggersManager(api); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - fidelity: 'high', - clip: true, + }, }); - expect(manager.isTriggered()).toBeTruthy(); + const manager = new TriggersManager(api); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(manager.isTriggered()).toBeFalsy(); + expect(api.getViewManager().setViewDefault).toBeCalled(); }); }); - it('should change to default view when suitably configured', () => { + it('should manage condition state', () => { const api = createTriggerAPI({ config: { + ...baseTriggersConfig, actions: { - interaction_mode: 'all', - trigger: 'default', + ...baseTriggersConfig.actions, + trigger: 'none', untrigger: 'none', }, }, }); + const manager = new TriggersManager(api); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' }); + + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: new Set(['camera_1']), + }); + vi.mocked(api.getConditionsManager().getState).mockReturnValue({ + triggered: new Set(['camera_1']), }); - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewDefault).toBeCalledWith({ - cameraID: 'camera_1', + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' }); + + vi.setSystemTime(add(start, { seconds: 10 })); + vi.runOnlyPendingTimers(); + + expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({ + triggered: undefined, }); }); - describe('should change to media view', () => { - it.each([ - [false, false, null], - [false, true, 'clip' as const], - [true, false, 'snapshot' as const], - [true, true, 'clip' as const], - ])( - 'with snapshot %s and clip %s', - async ( - hasSnapshot: boolean, - hasClip: boolean, - viewName: 'clip' | 'snapshot' | null, - ) => { - const api = createTriggerAPI({ - config: { - actions: { - interaction_mode: 'all', - trigger: 'media', - untrigger: 'none', - }, + describe('should take no actions with high-fidelity event', () => { + it('with non-live action', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'media', }, - }); - const manager = new TriggersManager(api); + }, + default: 'live', + }); - manager.handleCameraEvent({ - cameraID: 'camera_1', - type: 'new', - fidelity: 'high', - snapshot: hasSnapshot, - clip: hasClip, - }); + const manager = new TriggersManager(api); - if (!viewName) { - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - } else { - expect(manager.isTriggered()).toBeTruthy(); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - cameraID: 'camera_1', - viewName: viewName, - }); - } - }, - ); + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); + + expect(api.getViewManager().setView).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('with non-live default', () => { + const api = createTriggerAPI({ + config: { + ...baseTriggersConfig, + actions: { + ...baseTriggersConfig.actions, + trigger: 'default', + }, + }, + default: 'clips', + }); + + const manager = new TriggersManager(api); + + manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' }); + + expect(api.getViewManager().setView).not.toBeCalled(); + expect(api.getViewManager().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); }); it('should take no actions with human interactions', () => { diff --git a/tests/config/profiles/low-performance.test.ts b/tests/config/profiles/low-performance.test.ts index c048d020..30832789 100644 --- a/tests/config/profiles/low-performance.test.ts +++ b/tests/config/profiles/low-performance.test.ts @@ -50,5 +50,6 @@ it('low performance profile', () => { 'timeline.controls.thumbnails.show_favorite_control': false, 'timeline.controls.thumbnails.show_timeline_control': false, 'timeline.show_recordings': false, + 'view.triggers.actions.trigger': 'none', }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 6e90f23c..62ee482e 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -324,7 +324,7 @@ describe('config defaults', () => { show_trigger_status: false, untrigger_seconds: 0, actions: { - trigger: 'default', + trigger: 'update', untrigger: 'none', interaction_mode: 'inactive', },