diff --git a/README.md b/README.md index 658322e4..f9b95600 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) |Engine / Live Provider|`ha`|`image`|`jsmpeg`|`go2rtc`|`webrtc-card`| | - | - | - | - | - | - | |`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | -|`motioneye`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | +|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :white_check_mark: | +|`motioneye`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | @@ -249,7 +249,8 @@ cameras: | Option | Default | Overridable | Description | | - | - | - | - | | `modes` | `[webrtc, mse, mp4, mjpeg]` | :white_check_mark: | An ordered array of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | -| `stream` | Determind by camera engine (e.g. `frigate` camera name). | :white_check_mark: | A valid `go2rtc` stream name. | +| `stream` | Determined by camera engine (e.g. `frigate` camera name). | :white_check_mark: | A valid `go2rtc` stream name. | +| `url` | Determined by camera engine (e.g. the `frigate` engine will automatically generate a URL for the go2rtc backend that runs in the Frigate container). | :white_check_mark: | The root `go2rtc` URL the card should stream the video from. This is only needed for advanced usecases. Example: `http://my-custom-go2rtc:1984` | #### Live Provider: Camera WebRTC Card configuration @@ -1745,6 +1746,7 @@ cameras: - mp4 - mjpeg stream: sitting_room + url: 'https://my.custom.go2rtc.backend' - camera_entity: camera.sitting_room_webrtc_card live_provider: webrtc_card webrtc_card: @@ -1816,6 +1818,7 @@ cameras_global: - mp4 - mjpeg stream: sitting_room + url: 'https://my.custom.go2rtc.backend' webrtc_card: # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . entity: camera.sitting_room_rtsp diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 87b0e471..99dca886 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -10,7 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach import { CameraManagerEngine } from './engine'; import { CameraInitializationError } from './error'; import { Engine } from './types'; -import { getCameraEntityFromConfig } from './util'; +import { getCameraEntityFromConfig } from './utils'; export class CameraManagerEngineFactory { protected _entityRegistryManager: EntityRegistryManager; diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 6301cd93..adea7998 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -62,7 +62,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; -import { getCameraEntityFromConfig } from '../util'; +import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js'; import frigateLogo from './assets/frigate-logo-dark.svg'; import { FrigateViewMediaFactory } from './media'; import { FrigateViewMediaClassifier } from './media-classifier'; @@ -1147,20 +1147,6 @@ export class FrigateCameraManagerEngine }; }; - const getGo2RTC = (): CameraEndpoint | null => { - return { - endpoint: - `/api/frigate/${cameraConfig.frigate.client_id}` + - // go2rtc is exposed by the integration under the (slightly - // misleading) 'mse' path, even though that path can serve all go2rtc - // modes. - `/mse/api/ws?src=${ - cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name - }`, - sign: true, - }; - }; - const getJSMPEG = (): CameraEndpoint | null => { return { endpoint: @@ -1183,11 +1169,20 @@ export class FrigateCameraManagerEngine }; const ui = getUIEndpoint(); - const go2rtc = getGo2RTC(); + const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig, { + url: + cameraConfig.go2rtc?.url ?? + // go2rtc is exposed by the Frigate integration under the (slightly + // misleading) 'mse' path, even though that path can serve all go2rtc + // modes. + `/api/frigate/${cameraConfig.frigate.client_id}/mse`, + stream: cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name, + }); const jsmpeg = getJSMPEG(); const webrtcCard = getWebRTCCard(); return { + ...super.getCameraEndpoints(cameraConfig, context), ...(ui && { ui: ui }), ...(go2rtc && { go2rtc: go2rtc }), ...(jsmpeg && { jsmpeg: jsmpeg }), diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index dff7966c..c7d38e78 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -31,6 +31,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; +import { getDefaultGo2RTCEndpoint } from '../utils.js'; export class GenericCameraManagerEngine implements CameraManagerEngine { public getEngineType(): Engine { @@ -165,10 +166,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? cameraConfig.id ?? '', - icon: - cameraConfig?.icon ?? - getEntityIcon(hass, cameraConfig.camera_entity) ?? - 'mdi:video', + icon: cameraConfig?.icon ?? getEntityIcon(hass, cameraConfig.camera_entity), }; } @@ -191,9 +189,14 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { } public getCameraEndpoints( - _cameraConfig: CameraConfig, + cameraConfig: CameraConfig, _context?: CameraEndpointsContext, ): CameraEndpoints | null { - return null; + const go2rtc = getDefaultGo2RTCEndpoint(cameraConfig); + return go2rtc + ? { + go2rtc: go2rtc, + } + : null; } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 3a6f3719..86df2f06 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -3,12 +3,12 @@ import add from 'date-fns/add'; import cloneDeep from 'lodash-es/cloneDeep'; import merge from 'lodash-es/merge.js'; import sum from 'lodash-es/sum'; +import { CardCameraAPI } from '../card-controller/types.js'; import { CameraConfig, CamerasConfig } from '../config/types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { allPromises, arrayify, setify } from '../utils/basic.js'; import { getCameraID } from '../utils/camera.js'; -import { CardCameraAPI } from '../card-controller/types.js'; import { log } from '../utils/debug.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { ViewMedia } from '../view/media.js'; @@ -51,7 +51,7 @@ import { RecordingSegmentsQueryResultsMap, ResultsMap, } from './types.js'; -import { sortMedia } from './util.js'; +import { sortMedia } from './utils.js'; class QueryClassifier { public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index fbb2e5f8..a220a948 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -384,8 +384,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine public getCameraEndpoints( cameraConfig: CameraConfig, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _context?: CameraEndpointsContext, + context?: CameraEndpointsContext, ): CameraEndpoints | null { const getUIEndpoint = (): CameraEndpoint | null => { return cameraConfig.motioneye?.url @@ -394,9 +393,9 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine } : null; }; - const ui = getUIEndpoint(); return { + ...super.getCameraEndpoints(cameraConfig, context), ...(ui && { ui: ui }), }; } diff --git a/src/camera-manager/util.ts b/src/camera-manager/utils.ts similarity index 76% rename from src/camera-manager/util.ts rename to src/camera-manager/utils.ts index d8a5effc..dfa33d8b 100644 --- a/src/camera-manager/util.ts +++ b/src/camera-manager/utils.ts @@ -8,6 +8,7 @@ import uniqBy from 'lodash-es/uniqBy'; import { CameraConfig } from '../config/types'; import { ViewMedia } from '../view/media'; import { DateRange } from './range'; +import { CameraEndpoint } from './types'; export const convertRangeToCacheFriendlyTimes = ( range: DateRange, @@ -58,3 +59,26 @@ export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => { return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null; }; + +export const getDefaultGo2RTCEndpoint = ( + cameraConfig: CameraConfig, + options?: { + url?: string; + stream?: string; + }, +): CameraEndpoint | null => { + const url = options?.url ?? cameraConfig.go2rtc?.url; + const stream = options?.stream ?? cameraConfig.go2rtc?.stream; + + if (!url || !stream) { + return null; + } + const endpoint = `${url}/api/ws?src=${stream}`; + + return { + endpoint: endpoint, + + // Only sign the endpoint if it's local to HA. + sign: endpoint.startsWith('/'), + }; +}; diff --git a/src/components-lib/timeline-source.ts b/src/components-lib/timeline-source.ts index 7e9d10c9..3aac95b4 100644 --- a/src/components-lib/timeline-source.ts +++ b/src/components-lib/timeline-source.ts @@ -9,10 +9,10 @@ import { compressRanges, } from '../camera-manager/range'; import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types'; -import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; +import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/utils'; import { ClipsOrSnapshotsOrAll } from '../types'; -import { ViewMedia } from '../view/media'; import { ModifyInterface, errorToConsole } from '../utils/basic.js'; +import { ViewMedia } from '../view/media'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 1602e774..be722aba 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraConfig, MicrophoneConfig } from '../../config/types.js'; import { localize } from '../../localize/localize'; -import liveMSEStyle from '../../scss/live-go2rtc.scss'; +import liveGo2RTCStyle from '../../scss/live-go2rtc.scss'; import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { setControlsOnVideo } from '../../utils/media.js'; @@ -167,7 +167,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } static get styles(): CSSResultGroup { - return unsafeCSS(liveMSEStyle); + return unsafeCSS(liveGo2RTCStyle); } } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index d9c0a72e..b0d62eb8 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -29,7 +29,7 @@ import { import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; -import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; +import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils'; import { FrigateCardTimelineItem, TimelineDataSource, diff --git a/src/config/types.ts b/src/config/types.ts index 88574ce7..5618ebb6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -774,6 +774,11 @@ const microphoneConfigSchema = z export type MicrophoneConfig = z.infer; const go2rtcConfigSchema = z.object({ + url: z + .string() + .transform((input) => input.replace(/\/+$/, '')) + .optional(), + host: z.string().optional(), modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), stream: z.string().optional(), }); diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index da11bcb7..81f7ebd1 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { RecordingSegmentsCache, RequestCache } from '../../../src/camera-manager/cache'; import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate'; import { @@ -6,9 +6,13 @@ import { FrigateRecordingViewMedia, } from '../../../src/camera-manager/frigate/media'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; -import { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types'; +import { + CameraConfig, + FrigateCardView, + RawFrigateCardConfig, +} from '../../../src/config/types'; import { ViewMedia } from '../../../src/view/media'; -import { createCameraConfig, createHASS } from '../../test-utils'; +import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils'; const createEngine = (): FrigateCameraManagerEngine => { return new FrigateCameraManagerEngine( @@ -80,8 +84,6 @@ const createFrigateCameraConfig = (config?: RawFrigateCardConfig): CameraConfig }; describe('getMediaDownloadPath', () => { - afterEach(() => {}); - it('should get event with clip download path', async () => { const endpoint = await createEngine().getMediaDownloadPath( createHASS(), @@ -131,3 +133,262 @@ describe('getMediaDownloadPath', () => { expect(endpoint).toBeNull(); }); }); + +describe('getCameraEndpoints', () => { + it('should get basic endpoints', () => { + const endpoints = createEngine().getCameraEndpoints(createFrigateCameraConfig()); + + expect(endpoints).toEqual({ + go2rtc: { + endpoint: '/api/frigate/frigate/mse/api/ws?src=camera-1', + sign: true, + }, + jsmpeg: { + endpoint: '/api/frigate/frigate/jsmpeg/camera-1', + sign: true, + }, + webrtcCard: { + endpoint: 'camera-1', + }, + }); + }); + + describe('should get overridden go2rtc url', () => { + it('when local HA path', () => { + const endpoints = createEngine().getCameraEndpoints( + createFrigateCameraConfig({ + go2rtc: { + url: '/local/path', + }, + }), + ); + + expect(endpoints).toEqual( + expect.objectContaining({ + go2rtc: { + endpoint: '/local/path/api/ws?src=camera-1', + sign: true, + }, + }), + ); + }); + + it('when remote', () => { + const endpoints = createEngine().getCameraEndpoints( + createFrigateCameraConfig({ + go2rtc: { + url: 'https://my.custom.go2rtc', + }, + }), + ); + + expect(endpoints).toEqual( + expect.objectContaining({ + go2rtc: { + endpoint: 'https://my.custom.go2rtc/api/ws?src=camera-1', + sign: false, + }, + }), + ); + }); + }); + + it('should not set webrtc_card endpoint without camera name', () => { + const endpoints = createEngine().getCameraEndpoints(createCameraConfig()); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + webrtcCard: expect.anything(), + }), + ); + }); + + describe('should include UI endpoint', () => { + it('with basic url', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + }, + }), + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate', + }, + }), + ); + }); + + it('with camera name', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/cameras/my-camera', + }, + }), + ); + }); + + describe('with event media type', () => { + it.each([['clip' as const], ['snapshot' as const]])( + '%s', + (mediaType: 'clip' | 'snapshot') => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: mediaType }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/events?camera=my-camera', + }, + }), + ); + }, + ); + }); + + describe('with recording media type', () => { + it('with start time', () => { + const startTime = new Date('2023-10-07T16:42:00'); + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: 'recording', startTime: startTime }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/2023-10-07/16', + }, + }), + ); + }); + + it('without start time', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + media: new TestViewMedia({ mediaType: 'recording' }), + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/', + }, + }), + ); + }); + }); + + describe('with view', () => { + it('live', () => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: 'live', + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/cameras/my-camera', + }, + }), + ); + }); + + it.each([ + ['clip' as const], + ['clips' as const], + ['snapshot' as const], + ['snapshots' as const], + ])('%s', (viewName: FrigateCardView) => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: viewName, + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/events?camera=my-camera', + }, + }), + ); + }); + + it.each([['recording' as const], ['recordings' as const]])( + '%s', + (viewName: FrigateCardView) => { + const endpoints = createEngine().getCameraEndpoints( + createCameraConfig({ + frigate: { + url: 'http://my.frigate', + camera_name: 'my-camera', + }, + }), + { + view: viewName, + }, + ); + + expect(endpoints).not.toEqual( + expect.objectContaining({ + ui: { + url: 'http://my.frigate/recording/my-camera/', + }, + }), + ); + }, + ); + }); + }); +}); diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts new file mode 100644 index 00000000..b6fe7cf5 --- /dev/null +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -0,0 +1,294 @@ +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 { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types'; +import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry'; +import { + TestViewMedia, + createCameraConfig, + createHASS, + createStateEntity, +} from '../../test-utils'; + +const createEngine = (): GenericCameraManagerEngine => { + return new GenericCameraManagerEngine(); +}; + +const createGenericCameraConfig = (config?: RawFrigateCardConfig): CameraConfig => { + return createCameraConfig(config); +}; + +describe('GenericCameraManagerEngine', () => { + it('should get engine type', () => { + expect(createEngine().getEngineType()).toBe(Engine.Generic); + }); + + it('should initialize camera', async () => { + const config = createGenericCameraConfig(); + expect( + await createEngine().initializeCamera( + createHASS(), + mock(), + config, + ), + ).toEqual(config); + }); + + it('should generate default event query', () => { + expect( + createEngine().generateDefaultEventQuery( + new Map([['camera-1', createGenericCameraConfig()]]), + new Set(['camera-1']), + {}, + ), + ).toBeNull(); + }); + + it('should generate default recording query', () => { + expect( + createEngine().generateDefaultRecordingQuery( + new Map([['camera-1', createGenericCameraConfig()]]), + new Set(['camera-1']), + {}, + ), + ).toBeNull(); + }); + + it('should generate default recording segments query', () => { + expect( + createEngine().generateDefaultRecordingSegmentsQuery( + new Map([['camera-1', createGenericCameraConfig()]]), + new Set(['camera-1']), + {}, + ), + ).toBeNull(); + }); + + it('should get events', async () => { + expect( + await createEngine().getEvents( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { type: QueryType.Event, cameraIDs: new Set(['camera-1']) }, + ), + ).toBeNull(); + }); + + it('should get recordings', async () => { + expect( + await createEngine().getRecordings( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { type: QueryType.Recording, cameraIDs: new Set(['camera-1']) }, + ), + ).toBeNull(); + }); + + it('should get recording segments', async () => { + expect( + await createEngine().getRecordingSegments( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { + type: QueryType.RecordingSegments, + cameraIDs: new Set(['camera-1']), + start: new Date(), + end: new Date(), + }, + ), + ).toBeNull(); + }); + + it('should generate media from events', async () => { + expect( + createEngine().generateMediaFromEvents( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + }, + { + type: QueryResultsType.Event, + engine: Engine.Generic, + }, + ), + ).toBeNull(); + }); + + it('should generate media from recordings', async () => { + expect( + createEngine().generateMediaFromRecordings( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { + type: QueryType.Recording, + cameraIDs: new Set(['camera-1']), + start: new Date(), + end: new Date(), + }, + { + type: QueryResultsType.Recording, + engine: Engine.Generic, + }, + ), + ).toBeNull(); + }); + + it('should get media download path', async () => { + expect( + await createEngine().getMediaDownloadPath( + createHASS(), + createGenericCameraConfig(), + new TestViewMedia(), + ), + ).toBeNull(); + }); + + it('should favorite media', async () => { + expect( + await createEngine().favoriteMedia( + createHASS(), + createGenericCameraConfig(), + new TestViewMedia(), + true, + ), + ).toBeUndefined(); + }); + + it('should get query result max age', () => { + expect( + createEngine().getQueryResultMaxAge({ + type: QueryType.Event, + cameraIDs: new Set(['camera-1']), + }), + ).toBeNull(); + }); + + it('should get media seek time', async () => { + expect( + await createEngine().getMediaSeekTime( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + new TestViewMedia(), + new Date(), + ), + ).toBeNull(); + }); + + it('should get media metadata', async () => { + expect( + await createEngine().getMediaMetadata( + createHASS(), + new Map([['camera-1', createGenericCameraConfig()]]), + { type: QueryType.MediaMetadata, cameraIDs: new Set(['camera-1']) }, + ), + ).toBeNull(); + }); + + describe('should get camera metadata', () => { + it('with empty config', async () => { + expect( + createEngine().getCameraMetadata(createHASS(), createGenericCameraConfig()), + ).toEqual({ + icon: 'mdi:bookmark', + title: '', + }); + }); + + it('with configured title', async () => { + expect( + createEngine().getCameraMetadata( + createHASS(), + createGenericCameraConfig({ + title: 'My Camera', + }), + ), + ).toEqual({ + icon: 'mdi:bookmark', + title: 'My Camera', + }); + }); + + describe('with entity title', () => { + it('camera_entity', async () => { + expect( + createEngine().getCameraMetadata( + createHASS({ + 'camera.test': createStateEntity({ + attributes: { friendly_name: 'My Entity Camera' }, + }), + }), + createGenericCameraConfig({ + camera_entity: 'camera.test', + }), + ), + ).toEqual({ + icon: 'mdi:bookmark', + title: 'My Entity Camera', + }); + }); + + it('webrtc_card.entity', async () => { + expect( + createEngine().getCameraMetadata( + createHASS({ + 'camera.test': createStateEntity({ + attributes: { friendly_name: 'My Entity Camera' }, + }), + }), + createGenericCameraConfig({ + webrtc_card: { + entity: 'camera.test', + }, + }), + ), + ).toEqual({ + icon: 'mdi:bookmark', + title: 'My Entity Camera', + }); + }); + }); + }); + + it('should get camera capabilities metadata', async () => { + expect(createEngine().getCameraCapabilities(createGenericCameraConfig())).toEqual({ + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }); + }); + + it('should get media capabilities', () => { + expect(createEngine().getMediaCapabilities(new TestViewMedia())).toBeNull(); + }); + + describe('should get camera endpoints', () => { + it('default', () => { + expect(createEngine().getCameraEndpoints(createGenericCameraConfig())).toBeNull(); + }); + + it('for go2rtc', () => { + expect( + createEngine().getCameraEndpoints( + createGenericCameraConfig({ + go2rtc: { + stream: 'stream', + url: '/local/path', + }, + }), + ), + ).toEqual({ + go2rtc: { + endpoint: '/local/path/api/ws?src=stream', + sign: true, + }, + }); + }); + }); +}); diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index a4efec66..e5a58d0b 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -3,10 +3,11 @@ import { capEndDate, convertRangeToCacheFriendlyTimes, getCameraEntityFromConfig, + getDefaultGo2RTCEndpoint, sortMedia, -} from '../../src/camera-manager/util.js'; +} from '../../src/camera-manager/utils.js'; import { CameraConfig, cameraConfigSchema } from '../../src/config/types.js'; -import { TestViewMedia } from '../test-utils.js'; +import { TestViewMedia, createCameraConfig } from '../test-utils.js'; describe('convertRangeToCacheFriendlyTimes', () => { it('should return cache friendly within hour range', () => { @@ -134,3 +135,41 @@ describe('getCameraEntityFromConfig', () => { expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull(); }); }); + +describe('getDefaultGo2RTCEndpoint', () => { + it('with local configuration', () => { + expect( + getDefaultGo2RTCEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: '/local/path', + }, + }), + ), + ).toEqual({ + endpoint: '/local/path/api/ws?src=stream', + sign: true, + }); + }); + + it('with remote configuration', () => { + expect( + getDefaultGo2RTCEndpoint( + createCameraConfig({ + go2rtc: { + stream: 'stream', + url: 'https://my-custom-go2rtc', + }, + }), + ), + ).toEqual({ + endpoint: 'https://my-custom-go2rtc/api/ws?src=stream', + sign: false, + }); + }); + + it('without configuration', () => { + expect(getDefaultGo2RTCEndpoint(createCameraConfig())).toBeNull(); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 4f49e905..6793101b 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -435,3 +435,17 @@ it('should not require title controls to specify all options', () => { }), ).toBeTruthy(); }); + +it('should strip trailing slashes from go2rtc url', () => { + const config = createConfig({ + cameras: [ + { + go2rtc: { + url: 'https://my-custom-go2rtc//', + }, + }, + ], + }); + expect(config).toBeTruthy(); + expect(config.cameras[0].go2rtc.url).toBe('https://my-custom-go2rtc'); +});