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:
Dermot Duffy
2026-01-19 13:32:50 -08:00
committed by GitHub
parent 6eb0a87ca8
commit fc32727860
215 changed files with 14491 additions and 6160 deletions
@@ -10,6 +10,7 @@ import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { CardWideConfig } from '../../src/config/schema/types.js';
import { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { ResolvedMediaCache } from '../../src/ha/resolved-media.js';
import { EntityRegistryManagerMock } from '../ha/registry/entity/mock.js';
import {
@@ -28,6 +29,7 @@ const createFactory = (options?: {
}): CameraManagerEngineFactory => {
return new CameraManagerEngineFactory(
options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
mock<DeviceRegistryManager>(),
);
};
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();
});
});
+101 -1
View File
@@ -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,
}),
);
});
});
});
+110
View File
@@ -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');
});
});
@@ -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',
);
});
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { GenericCameraManagerEngine } from '../../../src/camera-manager/generic/engine-generic';
import { Engine, QueryResultsType, QueryType } from '../../../src/camera-manager/types';
import { QuerySource } from '../../../src/query-source';
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
import { CameraConfig } from '../../../src/config/schema/cameras';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
@@ -43,6 +44,14 @@ describe('GenericCameraManagerEngine', () => {
expect(camera.getCapabilities()?.has('trigger')).toBeTruthy();
});
it('should get default query parameters', async () => {
const config = createGenericCameraConfig();
const camera = await createEngine().createCamera(createHASS(), config);
expect(createEngine().getDefaultQueryParameters(camera, QueryType.Event)).toEqual(
{},
);
});
it('should generate default event query', () => {
const engine = createEngine();
expect(
@@ -82,7 +91,11 @@ describe('GenericCameraManagerEngine', () => {
await engine.getEvents(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']) },
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
@@ -93,7 +106,11 @@ describe('GenericCameraManagerEngine', () => {
await engine.getRecordings(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{ type: QueryType.Recording, cameraIDs: new Set(['camera-1']) },
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
@@ -114,6 +131,32 @@ describe('GenericCameraManagerEngine', () => {
).toBeNull();
});
it('should generate default review query', () => {
const engine = createEngine();
expect(
engine.generateDefaultReviewQuery(
createStore([{ cameraID: 'camera-1', engine: engine }]),
new Set(['camera-1']),
{},
),
).toBeNull();
});
it('should get reviews', async () => {
const engine = createEngine();
expect(
await engine.getReviews(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set(['camera-1']),
},
),
).toBeNull();
});
it('should generate media from events', async () => {
const engine = createEngine();
expect(
@@ -121,6 +164,7 @@ describe('GenericCameraManagerEngine', () => {
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['camera-1']),
},
@@ -139,6 +183,7 @@ describe('GenericCameraManagerEngine', () => {
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Recording,
cameraIDs: new Set(['camera-1']),
start: new Date(),
@@ -152,6 +197,25 @@ describe('GenericCameraManagerEngine', () => {
).toBeNull();
});
it('should generate media from reviews', async () => {
const engine = createEngine();
expect(
engine.generateMediaFromReviews(
createHASS(),
createStore([{ cameraID: 'camera-1', engine: engine }]),
{
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set(['camera-1']),
},
{
type: QueryResultsType.Review,
engine: Engine.Generic,
},
),
).toBeNull();
});
it('should get media download path', async () => {
expect(
await createEngine().getMediaDownloadPath(
@@ -173,6 +237,17 @@ describe('GenericCameraManagerEngine', () => {
).toBeUndefined();
});
it('should review media', async () => {
expect(
await createEngine().reviewMedia(
createHASS(),
createGenericCameraConfig(),
new TestViewMedia(),
true,
),
).toBeUndefined();
});
it('should get query result max age', () => {
expect(
createEngine().getQueryResultMaxAge({
+108 -11
View File
@@ -11,8 +11,6 @@ import {
QueryResultClassifier,
} from '../../src/camera-manager/manager.js';
import {
CameraEndpoints,
CameraEndpointsContext,
CameraEvent,
CameraManagerCameraMetadata,
Engine,
@@ -27,23 +25,24 @@ import { CardController } from '../../src/card-controller/controller.js';
import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js';
import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source.js';
import { Endpoint, PTZMovementType } from '../../src/types.js';
import { ViewFolder, ViewItem, ViewMedia } from '../../src/view/item.js';
import { ViewItemCapabilities } from '../../src/view/types.js';
import {
TestViewMedia,
createInitializedCamera,
createCameraConfig,
createCapabilities,
createCardAPI,
createConfig,
createFolder,
createHASS,
createInitializedCamera,
generateViewMediaArray,
} from '../test-utils.js';
describe('QueryClassifier', async () => {
it('should classify event query', async () => {
describe('QueryClassifier', () => {
it('should classify event query', () => {
expect(CameraQueryClassifier.isEventQuery({ type: QueryType.Event })).toBeTruthy();
expect(
CameraQueryClassifier.isEventQuery({ type: QueryType.Recording }),
@@ -55,7 +54,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isEventQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording query', async () => {
it('should classify recording query', () => {
expect(
CameraQueryClassifier.isRecordingQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -69,7 +68,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify recording segments query', async () => {
it('should classify recording segments query', () => {
expect(
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -85,7 +84,7 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isRecordingSegmentsQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
});
it('should classify media metadata query', async () => {
it('should classify media metadata query', () => {
expect(
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.Event }),
).toBeFalsy();
@@ -99,6 +98,19 @@ describe('QueryClassifier', async () => {
CameraQueryClassifier.isMediaMetadataQuery({ type: QueryType.MediaMetadata }),
).toBeTruthy();
});
it('should classify review query', () => {
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Event })).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.Recording }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.RecordingSegments }),
).toBeFalsy();
expect(
CameraQueryClassifier.isReviewQuery({ type: QueryType.MediaMetadata }),
).toBeFalsy();
expect(CameraQueryClassifier.isReviewQuery({ type: QueryType.Review })).toBeTruthy();
});
});
describe('QueryResultClassifier', async () => {
@@ -195,9 +207,32 @@ describe('QueryResultClassifier', async () => {
),
).toBeTruthy();
});
it('should classify review query result', async () => {
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Event)),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.Recording),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.RecordingSegments),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(
createResults(QueryResultsType.MediaMetadata),
),
).toBeFalsy();
expect(
QueryResultClassifier.isReviewQueryResult(createResults(QueryResultsType.Review)),
).toBeTruthy();
});
});
describe('CameraManager', async () => {
describe('CameraManager', () => {
const baseCameraConfig = {
id: 'id',
camera_entity: 'camera.foo',
@@ -205,6 +240,7 @@ describe('CameraManager', async () => {
};
const baseEventQuery: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event as const,
cameraIDs: new Set(['id']),
};
@@ -215,6 +251,7 @@ describe('CameraManager', async () => {
};
const baseRecordingQuery = {
source: QuerySource.Camera as const,
type: QueryType.Recording as const,
cameraIDs: new Set(['id']),
};
@@ -266,7 +303,7 @@ describe('CameraManager', async () => {
return new CameraManager(api, { factory: mockFactory });
};
it('should construct', async () => {
it('should construct', () => {
const manager = new CameraManager(createCardAPI());
expect(manager.getStore()).toBeTruthy();
});
@@ -440,6 +477,11 @@ describe('CameraManager', async () => {
'generateDefaultRecordingSegmentsQuery',
'generateDefaultRecordingSegmentsQueries',
],
[
QueryType.Review as const,
'generateDefaultReviewQuery',
'generateDefaultReviewQueries',
],
])(
'basic %s',
async (
@@ -482,6 +524,33 @@ describe('CameraManager', async () => {
});
});
describe('getDefaultQueryParameters', () => {
it('should return empty object for non-existent camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api, mock<CameraManagerEngine>());
expect(
manager.getDefaultQueryParameters('not_a_camera', QueryType.Event),
).toEqual({});
});
it('should return parameters from engine for existing camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
engine.getDefaultQueryParameters.mockReturnValue({ what: new Set(['person']) });
expect(manager.getDefaultQueryParameters('id', QueryType.Event)).toEqual({
what: new Set(['person']),
});
});
});
it('should merge defaults correctly', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -590,6 +659,34 @@ describe('CameraManager', async () => {
});
});
describe('should review media', () => {
it('without camera', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = createCameraManager(api);
const media = new TestViewMedia();
await manager.reviewMedia(media, true);
});
it('successfully', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const engine = mock<CameraManagerEngine>();
vi.mocked(engine.getEngineType).mockReturnValue(Engine.Generic);
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const media = new TestViewMedia({ cameraID: 'id' });
await manager.reviewMedia(media, true);
expect(engine.reviewMedia).toBeCalledWith(hass, expect.anything(), media, true);
});
});
describe('should get recordings', () => {
it('successfully', async () => {
const api = createCardAPI();
@@ -740,7 +837,7 @@ describe('CameraManager', async () => {
new TestViewMedia({
startTime: add(dateBase, { days: 2 }),
}),
new ViewFolder(createFolder()),
new ViewFolder(createFolder(), []),
];
it('without hass', async () => {
@@ -23,6 +23,7 @@ import {
QueryReturnType,
QueryType,
} from '../../../src/camera-manager/types';
import { QuerySource } from '../../../src/query-source';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types';
import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker';
@@ -340,6 +341,7 @@ describe('ReolinkCameraManagerEngine', () => {
expect(
await engine.getEvents(createHASS(), createStore(), {
...query,
source: QuerySource.Camera,
cameraIDs: new Set(['office']),
type: QueryType.Event,
}),
@@ -360,6 +362,7 @@ describe('ReolinkCameraManagerEngine', () => {
createHASS(),
store,
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -377,6 +380,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -473,6 +477,7 @@ describe('ReolinkCameraManagerEngine', () => {
createHASS(),
store,
{
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -510,6 +515,7 @@ describe('ReolinkCameraManagerEngine', () => {
const hass = createHASS();
await engine.getEvents(hass, store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -561,6 +567,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -574,6 +581,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -603,6 +611,7 @@ describe('ReolinkCameraManagerEngine', () => {
const store = await createStoreWithReolinkCamera(engine);
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -616,6 +625,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -638,6 +648,7 @@ describe('ReolinkCameraManagerEngine', () => {
const hass = createHASS();
const events = await engine.getEvents(hass, store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
});
@@ -647,6 +658,7 @@ describe('ReolinkCameraManagerEngine', () => {
[
{
cameraIDs: new Set(['office']),
source: 'camera',
type: 'event-query',
},
{
@@ -678,6 +690,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -691,6 +704,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -731,6 +745,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -744,6 +759,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -792,6 +808,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -805,6 +822,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -851,6 +869,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -864,6 +883,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -910,6 +930,7 @@ describe('ReolinkCameraManagerEngine', () => {
});
const events = await engine.getEvents(createHASS(), store, {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -923,6 +944,7 @@ describe('ReolinkCameraManagerEngine', () => {
cameraIDs: new Set(['office']),
end: new Date('2024-11-04T22:00:00'),
start: new Date('2024-11-04T21:00:00'),
source: 'camera',
type: 'event-query',
},
{
@@ -958,6 +980,7 @@ describe('ReolinkCameraManagerEngine', () => {
describe('should generate media from events', () => {
it('should generate media successfully', () => {
const query: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -1004,6 +1027,7 @@ describe('ReolinkCameraManagerEngine', () => {
it('should reject non-reolink results', () => {
const query: EventQuery = {
source: QuerySource.Camera,
type: QueryType.Event,
cameraIDs: new Set(['office']),
start: new Date('2024-11-04T21:00:00'),
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { supports2WayAudio } from '../../../src/camera-manager/utils/go2rtc/audio';
import { homeAssistantSignAndFetch } from '../../../src/ha/fetch';
import { HomeAssistant } from '../../../src/ha/types';
import { createProxiedEndpointIfNecessary } from '../../../src/ha/web-proxy';
import { Endpoint } from '../../../src/types';
vi.mock('../../../src/ha/fetch');
vi.mock('../../../src/ha/web-proxy');
describe('supports2WayAudio', () => {
const hass = mock<HomeAssistant>();
const endpoint: Endpoint = { endpoint: 'http://go2rtc', sign: true };
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false if no endpoint provided', async () => {
expect(await supports2WayAudio(hass, null)).toBe(false);
});
it('should return false if fetch fails', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockRejectedValue(new Error('fetch error'));
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await supports2WayAudio(hass, endpoint);
expect(result).toBe(false);
expect(spy).toHaveBeenCalledWith('fetch error');
spy.mockRestore();
});
it('should return false if stream info has no producers', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({ producers: undefined });
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
it('should return false if no producer supports audio', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['video,sendonly,h264'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
it('should return true if producer supports audio and sendonly', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['audio,sendonly,opus'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
});
it('should return true if producer supports audio and sendrecv', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: ['audio,sendrecv,pcmu'],
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(true);
});
it('should handle missing medias in producer', async () => {
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(endpoint);
vi.mocked(homeAssistantSignAndFetch).mockResolvedValue({
producers: [
{
medias: undefined,
},
],
});
expect(await supports2WayAudio(hass, endpoint)).toBe(false);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import {
getConfiguredPTZMovementType,
getPTZCapabilitiesFromCameraConfig,
} from '../../../src/camera-manager/utils/ptz';
import { PTZAction } from '../../../src/config/ptz';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { createCameraConfig } from '../../test-utils';
const action = {