Add update trigger action.
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ view:
|
||||
untrigger_seconds: 0
|
||||
actions:
|
||||
interaction_mode: inactive
|
||||
trigger: default
|
||||
trigger: update
|
||||
untrigger: none
|
||||
keyboard_shortcuts:
|
||||
enabled: true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('config defaults', () => {
|
||||
show_trigger_status: false,
|
||||
untrigger_seconds: 0,
|
||||
actions: {
|
||||
trigger: 'default',
|
||||
trigger: 'update',
|
||||
untrigger: 'none',
|
||||
interaction_mode: 'inactive',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user