Add update trigger action.

This commit is contained in:
Dermot Duffy
2024-06-11 19:48:39 -07:00
parent 4c73c8faa0
commit f193f9925f
9 changed files with 266 additions and 137 deletions
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -120,7 +120,7 @@ view:
untrigger_seconds: 0
actions:
interaction_mode: inactive
trigger: default
trigger: update
untrigger: none
keyboard_shortcuts:
enabled: true
+1 -1
View File
@@ -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
+15 -2
View File
@@ -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,
+4
View File
@@ -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'
};
+8 -9
View File
@@ -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<typeof triggersSchema>;
+234 -122
View File
@@ -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<TriggersOptions> = {
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<TriggersOptions> = {
// function reduces it.
const createTriggerAPI = (options?: {
config?: Partial<TriggersOptions>;
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', () => {
@@ -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',
});
});
+1 -1
View File
@@ -324,7 +324,7 @@ describe('config defaults', () => {
show_trigger_status: false,
untrigger_seconds: 0,
actions: {
trigger: 'default',
trigger: 'update',
untrigger: 'none',
interaction_mode: 'inactive',
},