feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FrigateEventViewMedia,
|
||||
FrigateReviewViewMedia,
|
||||
} from '../../../src/camera-manager/frigate/media';
|
||||
import { ViewMediaType } from '../../../src/view/item';
|
||||
import { createFrigateEvent, createFrigateReview } from '../../test-utils';
|
||||
|
||||
describe('FrigateReviewViewMedia', () => {
|
||||
it('should get description when scene is present', () => {
|
||||
const review = createFrigateReview({
|
||||
data: {
|
||||
objects: [],
|
||||
zones: [],
|
||||
metadata: {
|
||||
scene: 'A person walking',
|
||||
title: 'Title',
|
||||
},
|
||||
},
|
||||
});
|
||||
const media = new FrigateReviewViewMedia(
|
||||
'camera',
|
||||
review,
|
||||
'content_id',
|
||||
'thumbnail',
|
||||
);
|
||||
expect(media.getDescription()).toBe('A person walking');
|
||||
});
|
||||
|
||||
it('should get null description when scene is absent', () => {
|
||||
const review = createFrigateReview({
|
||||
data: {
|
||||
objects: [],
|
||||
zones: [],
|
||||
metadata: {
|
||||
title: 'Title',
|
||||
// scene is absent.
|
||||
},
|
||||
},
|
||||
});
|
||||
const media = new FrigateReviewViewMedia(
|
||||
'camera',
|
||||
review,
|
||||
'content_id',
|
||||
'thumbnail',
|
||||
);
|
||||
expect(media.getDescription()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FrigateEventViewMedia', () => {
|
||||
it('should get description when description is present', () => {
|
||||
const event = createFrigateEvent({
|
||||
data: {
|
||||
description: 'A person walking',
|
||||
},
|
||||
});
|
||||
const media = new FrigateEventViewMedia(
|
||||
ViewMediaType.Clip,
|
||||
'camera',
|
||||
event,
|
||||
'content_id',
|
||||
'thumbnail',
|
||||
);
|
||||
expect(media.getDescription()).toBe('A person walking');
|
||||
});
|
||||
|
||||
it('should get null description when data is absent', () => {
|
||||
const event = createFrigateEvent();
|
||||
const media = new FrigateEventViewMedia(
|
||||
ViewMediaType.Clip,
|
||||
'camera',
|
||||
event,
|
||||
'content_id',
|
||||
'thumbnail',
|
||||
);
|
||||
expect(media.getDescription()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,22 @@ import {
|
||||
getPTZInfo,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
getReviews,
|
||||
retainEvent,
|
||||
setReviewsReviewed,
|
||||
} from '../../../src/camera-manager/frigate/requests';
|
||||
import {
|
||||
EventSummary,
|
||||
eventSummarySchema,
|
||||
FrigateEvent,
|
||||
frigateEventsSchema,
|
||||
FrigateReview,
|
||||
frigateReviewsSchema,
|
||||
ptzInfoSchema,
|
||||
recordingSegmentsSchema,
|
||||
recordingSummarySchema,
|
||||
retainResultSchema,
|
||||
reviewResultSchema,
|
||||
} from '../../../src/camera-manager/frigate/types';
|
||||
import { RecordingSegment } from '../../../src/camera-manager/types';
|
||||
import { homeAssistantWSRequest } from '../../../src/ha/ws-request';
|
||||
@@ -197,7 +202,7 @@ describe('frigate requests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should get PTZ info', async () => {
|
||||
it('should get PTZInfo', async () => {
|
||||
const ptzInfo = [
|
||||
{
|
||||
name: 'camera.office',
|
||||
@@ -219,4 +224,99 @@ describe('frigate requests', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should get reviews', async () => {
|
||||
const reviews: FrigateReview[] = [
|
||||
{
|
||||
id: 'review_id',
|
||||
camera: 'camera',
|
||||
start_time: 0,
|
||||
end_time: 1,
|
||||
severity: 'alert',
|
||||
thumb_path: 'thumb.jpg',
|
||||
data: {
|
||||
objects: [],
|
||||
zones: [],
|
||||
},
|
||||
has_been_reviewed: false,
|
||||
},
|
||||
];
|
||||
const hass = createHASS();
|
||||
vi.mocked(homeAssistantWSRequest).mockResolvedValue(reviews);
|
||||
expect(
|
||||
await getReviews(hass, {
|
||||
instance_id: 'clientID',
|
||||
cameras: ['camera'],
|
||||
labels: ['person'],
|
||||
zones: ['zone'],
|
||||
severity: 'alert',
|
||||
after: 0,
|
||||
before: 1,
|
||||
limit: 10,
|
||||
reviewed: false,
|
||||
}),
|
||||
).toBe(reviews);
|
||||
expect(homeAssistantWSRequest).toBeCalledWith(
|
||||
hass,
|
||||
frigateReviewsSchema,
|
||||
expect.objectContaining({
|
||||
type: 'frigate/reviews/get',
|
||||
instance_id: 'clientID',
|
||||
cameras: ['camera'],
|
||||
labels: ['person'],
|
||||
zones: ['zone'],
|
||||
severity: 'alert',
|
||||
after: 0,
|
||||
before: 1,
|
||||
limit: 10,
|
||||
reviewed: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('should set reviews reviewed', async () => {
|
||||
it('successfully', async () => {
|
||||
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'success',
|
||||
});
|
||||
|
||||
const hass = createHASS();
|
||||
setReviewsReviewed(hass, 'clientID', ['review_id'], true);
|
||||
|
||||
expect(homeAssistantWSRequest).toBeCalledWith(
|
||||
hass,
|
||||
reviewResultSchema,
|
||||
expect.objectContaining({
|
||||
type: 'frigate/reviews/viewed',
|
||||
instance_id: 'clientID',
|
||||
ids: ['review_id'],
|
||||
viewed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unsuccessfully', async () => {
|
||||
vi.mocked(homeAssistantWSRequest).mockResolvedValue({
|
||||
success: false,
|
||||
message: 'failed',
|
||||
});
|
||||
|
||||
const hass = createHASS();
|
||||
await expect(
|
||||
setReviewsReviewed(hass, 'clientID', ['review_id'], true),
|
||||
).rejects.toThrowError(/Failed to receive response from Home Assistant/);
|
||||
expect(homeAssistantWSRequest).toBeCalledWith(
|
||||
hass,
|
||||
reviewResultSchema,
|
||||
expect.objectContaining({
|
||||
type: 'frigate/reviews/viewed',
|
||||
instance_id: 'clientID',
|
||||
ids: ['review_id'],
|
||||
viewed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,17 @@ import {
|
||||
getRecordingID,
|
||||
getRecordingMediaContentID,
|
||||
getRecordingTitle,
|
||||
getReviewMediaContentID,
|
||||
getReviewSeverity,
|
||||
getReviewThumbnailURL,
|
||||
getReviewTitle,
|
||||
} from '../../../src/camera-manager/frigate/util';
|
||||
import { CameraConfig } from '../../../src/config/schema/cameras';
|
||||
import {
|
||||
createCameraConfig,
|
||||
createFrigateEvent,
|
||||
createFrigateRecording,
|
||||
createFrigateReview,
|
||||
} from '../../test-utils';
|
||||
|
||||
describe('getEventTitle', () => {
|
||||
@@ -152,3 +157,108 @@ describe('getRecordingID', () => {
|
||||
).toBe('//1682776800000/1682780399000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewTitle', () => {
|
||||
const start = new Date('2023-05-06T10:43:00');
|
||||
const end = new Date('2023-05-06T10:44:12');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should get finished review title without objects', () => {
|
||||
expect(
|
||||
getReviewTitle(
|
||||
createFrigateReview({
|
||||
start_time: start.getTime() / 1000,
|
||||
end_time: end.getTime() / 1000,
|
||||
data: {
|
||||
objects: [],
|
||||
zones: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('2023-05-06 10:43 [72s]');
|
||||
});
|
||||
|
||||
it('should get finished review title with objects', () => {
|
||||
expect(
|
||||
getReviewTitle(
|
||||
createFrigateReview({
|
||||
start_time: start.getTime() / 1000,
|
||||
end_time: end.getTime() / 1000,
|
||||
data: {
|
||||
objects: ['person', 'dog'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('Person, Dog');
|
||||
});
|
||||
|
||||
it('should get in-progress review title', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(add(start, { seconds: 60 }));
|
||||
|
||||
expect(
|
||||
getReviewTitle(
|
||||
createFrigateReview({
|
||||
start_time: start.getTime() / 1000,
|
||||
end_time: null,
|
||||
data: {
|
||||
objects: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('2023-05-06 10:43 [60s]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewMediaContentID', () => {
|
||||
it('should get review content ID', () => {
|
||||
expect(
|
||||
getReviewMediaContentID(
|
||||
'clientid',
|
||||
'kitchen',
|
||||
createFrigateReview({
|
||||
start_time: new Date('2023-04-29T14:00:00').getTime() / 1000,
|
||||
}),
|
||||
),
|
||||
).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewThumbnailURL', () => {
|
||||
it('should get thumbnail URL', () => {
|
||||
expect(
|
||||
getReviewThumbnailURL(
|
||||
'clientid',
|
||||
createFrigateReview({
|
||||
thumb_path: '/media/frigate/thumb.jpg',
|
||||
}),
|
||||
),
|
||||
).toBe('/api/frigate/clientid/thumb.jpg');
|
||||
});
|
||||
|
||||
it('should return null when no thumb path', () => {
|
||||
expect(
|
||||
getReviewThumbnailURL(
|
||||
'clientid',
|
||||
createFrigateReview({
|
||||
thumb_path: null,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReviewSeverity', () => {
|
||||
it('should get alert severity', () => {
|
||||
expect(getReviewSeverity('alert')).toBe('high');
|
||||
});
|
||||
it('should get detection severity', () => {
|
||||
expect(getReviewSeverity('detection')).toBe('medium');
|
||||
});
|
||||
it('should get significant_motion severity', () => {
|
||||
expect(getReviewSeverity('significant_motion')).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
+137
-5
@@ -1,6 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher.js';
|
||||
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types.js';
|
||||
import {
|
||||
FrigateEventChange,
|
||||
FrigateReviewChange,
|
||||
} from '../../../src/camera-manager/frigate/types.js';
|
||||
import {
|
||||
FrigateEventWatcher,
|
||||
FrigateReviewWatcher,
|
||||
} from '../../../src/camera-manager/frigate/watcher.js';
|
||||
import { HomeAssistant } from '../../../src/ha/types.js';
|
||||
import { createHASS } from '../../test-utils.js';
|
||||
|
||||
@@ -25,7 +31,41 @@ const createEventChange = (): FrigateEventChange => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createReviewChange = (): FrigateReviewChange => {
|
||||
return {
|
||||
type: 'new',
|
||||
before: {
|
||||
id: '123',
|
||||
camera: 'front_door',
|
||||
severity: 'alert',
|
||||
start_time: 123,
|
||||
end_time: null,
|
||||
thumb_path: null,
|
||||
has_been_reviewed: false,
|
||||
data: {
|
||||
metadata: {
|
||||
title: 'Title before',
|
||||
scene: 'Scene before',
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
id: '123',
|
||||
camera: 'front_door',
|
||||
severity: 'alert',
|
||||
start_time: 123,
|
||||
end_time: null,
|
||||
thumb_path: null,
|
||||
has_been_reviewed: false,
|
||||
data: {
|
||||
metadata: {
|
||||
title: 'Title after',
|
||||
scene: 'Scene after',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
const callHASubscribeMessageCallback = (
|
||||
hass: HomeAssistant,
|
||||
data: unknown,
|
||||
@@ -99,7 +139,7 @@ describe('FrigateEventWatcher', () => {
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
'Received non-JSON payload as Frigate event',
|
||||
'Received non-JSON payload from subscription: frigate/events/subscribe',
|
||||
'NOT_JSON',
|
||||
);
|
||||
});
|
||||
@@ -122,7 +162,7 @@ describe('FrigateEventWatcher', () => {
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
'Received malformed Frigate event from Home Assistant',
|
||||
'Received malformed message from subscription: frigate/events/subscribe',
|
||||
data,
|
||||
);
|
||||
});
|
||||
@@ -199,3 +239,95 @@ describe('FrigateEventWatcher', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FrigateReviewWatcher', () => {
|
||||
it('should subscribe to a given topic once', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
await stateWatcher.subscribe(hass, {
|
||||
instanceID: 'frigate',
|
||||
callback: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hass.connection.subscribeMessage).toBeCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
type: 'frigate/reviews/subscribe',
|
||||
}),
|
||||
);
|
||||
expect(hass.connection.subscribeMessage).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should call handler', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('with a review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with a genai review change', async () => {
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
|
||||
const reviewChange = createReviewChange();
|
||||
reviewChange.type = 'genai';
|
||||
|
||||
callHASubscribeMessageCallback(hass, JSON.stringify(reviewChange));
|
||||
|
||||
expect(callback).toBeCalledWith(reviewChange);
|
||||
});
|
||||
|
||||
it('with invalid JSON', async () => {
|
||||
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
|
||||
|
||||
const stateWatcher = new FrigateReviewWatcher();
|
||||
const hass = createHASS();
|
||||
|
||||
const callback = vi.fn();
|
||||
const request = {
|
||||
instanceID: 'frigate',
|
||||
callback: callback,
|
||||
};
|
||||
|
||||
await stateWatcher.subscribe(hass, request);
|
||||
callHASubscribeMessageCallback(hass, 'NOT_JSON');
|
||||
|
||||
expect(callback).not.toBeCalled();
|
||||
expect(spy).toBeCalledWith(
|
||||
'Received non-JSON payload from subscription: frigate/reviews/subscribe',
|
||||
'NOT_JSON',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user