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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user