diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index 97eead63..adeeb8c4 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -35,13 +35,13 @@ cameras: # [...] ``` -| Option | Default | Description | -| ------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `camera_name` | Autodetected from `camera_entity` if that is specified. | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. | -| `client_id` | `frigate` | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant//#multiple-instance-support). | -| `labels` | | A list of Frigate labels used in the default media filter for events (clips & snapshots), e.g. [`person`, `car`]. | -| `url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Camera UI` menu button. All other communication with Frigate goes via Home Assistant. | -| `zones` | | A list of Frigates zones used in the default media filter for events (clips & snapshots), e.g. [`front_door`, `front_steps`]. | +| Option | Default | Description | +| ------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `camera_name` | Autodetected from `camera_entity` if that is specified. | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view. | +| `client_id` | Autodetected from `camera_entity` if that is specified, otherwise `frigate`. | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant//#multiple-instance-support). | +| `labels` | | A list of Frigate labels used in the default media filter for events (clips & snapshots), e.g. [`person`, `car`]. | +| `url` | | The URL of the frigate server. If set, this value will be (exclusively) used for a `Camera UI` menu button. All other communication with Frigate goes via Home Assistant. | +| `zones` | | A list of Frigates zones used in the default media filter for events (clips & snapshots), e.g. [`front_door`, `front_steps`]. | ## `motioneye` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85a02bd2..fa5e38cf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -214,8 +214,9 @@ between clicking the download button and the download starting. ### `Forbidden media source identifier` - If you are using a custom `client_id` setting in your `frigate.yml` file (the - configuration file for the Frigate backend itself), you must tell the card - about it. See [Frigate engine + configuration file for the Frigate backend itself), the card will auto-detect + it from the camera entity. If auto-detection fails (e.g. no `camera_entity` + is configured), set it manually — see [Frigate engine configuration](configuration/cameras/engine.md?id=frigate). - You must have the `Enable the media browser` option enabled for the Frigate integration, in order for media fetches to work for the card. Media fetches @@ -358,9 +359,10 @@ entity provided by HACS. The entity is usually called e.g. `API error whilst subscribing to events for unknown Frigate instance frigate` -If you are using a custom `client_id` setting in your `frigate.yml` file (the -configuration file for the Frigate backend itself), you must tell the card about -it via the `client_id` parameter: +The card auto-detects a custom `client_id` from the camera entity, so this +error usually means auto-detection couldn't run (no `camera_entity` configured) +or the entity doesn't expose a `client_id` attribute. Set it manually via the +`client_id` parameter: ```yaml cameras: @@ -373,7 +375,7 @@ See [Frigate engine configuration](configuration/cameras/engine.md?id=frigate) for more details. If you're not using a custom `client_id`, your Frigate integration is likely not -installed correct. +installed correctly. ### `webrtc_card` unloads in the background diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 13ea710b..80b641db 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -133,6 +133,16 @@ export class FrigateCamera extends Camera { } } + if (!this._config.frigate.client_id) { + const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined; + const clientID = stateEntity?.attributes?.client_id; + if (typeof clientID === 'string' && clientID) { + this._config.frigate.client_id = clientID; + } else if (stateEntity?.state !== 'unavailable') { + this._config.frigate.client_id = 'frigate'; + } + } + if (hasAutoTriggers) { // Try to find the correct entities for the motion & occupancy sensors. // We know they are binary_sensors, and that they'll have the same @@ -227,7 +237,12 @@ export class FrigateCamera extends Camera { const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name; const url = this._config.go2rtc?.url ?? - `/api/frigate/${this._config.frigate.client_id}/go2rtc`; + (this._config.frigate.client_id + ? `/api/frigate/${this._config.frigate.client_id}/go2rtc` + : null); + if (!url) { + return null; + } return getGo2RTCMetadataEndpoint(this._config, { url, stream }); } @@ -236,8 +251,12 @@ export class FrigateCamera extends Camera { const url = this._config.go2rtc?.url ?? // go2rtc is exposed by the Frigate integration under the 'mse' path. - `/api/frigate/${this._config.frigate.client_id}/mse`; - + (this._config.frigate.client_id + ? `/api/frigate/${this._config.frigate.client_id}/mse` + : null); + if (!url) { + return null; + } return getGo2RTCStreamEndpoint(this._config, { url, stream, @@ -245,7 +264,7 @@ export class FrigateCamera extends Camera { } private _getJSMPEGEndpoint(): Endpoint | null { - if (!this._config.frigate.camera_name) { + if (!this._config.frigate.camera_name || !this._config.frigate.client_id) { return null; } return { @@ -305,7 +324,11 @@ export class FrigateCamera extends Camera { hass: HomeAssistant, cameraConfig: CameraConfig, ): Promise { - if (!cameraConfig.frigate.camera_name || isBirdseye(cameraConfig)) { + if ( + !cameraConfig.frigate.camera_name || + !cameraConfig.frigate.client_id || + isBirdseye(cameraConfig) + ) { return null; } @@ -424,7 +447,11 @@ export class FrigateCamera extends Camera { frigateEventWatcher: FrigateWatcherSubscriptionInterface, ): Promise { const config = this.getConfig(); - if (!config.triggers.events.length || !config.frigate.camera_name) { + if ( + !config.triggers.events.length || + !config.frigate.camera_name || + !config.frigate.client_id + ) { return; } @@ -495,7 +522,11 @@ export class FrigateCamera extends Camera { const reviewConfig = config.triggers.reviews; // Must have at least one severity configured and a camera name to subscribe - if (!reviewConfig.severities.length || !config.frigate.camera_name) { + if ( + !reviewConfig.severities.length || + !config.frigate.camera_name || + !config.frigate.client_id + ) { return; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 1d5fbe60..acedbc62 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -188,6 +188,9 @@ export class FrigateCameraManagerEngine cameraConfig: CameraConfig, media: ViewMedia, ): Promise { + if (!cameraConfig.frigate.client_id) { + return null; + } if (FrigateViewMediaClassifier.isFrigateEvent(media)) { return { endpoint: @@ -342,7 +345,10 @@ export class FrigateCameraManagerEngine media: ViewMedia, favorite: boolean, ): Promise { - if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { + if ( + !FrigateViewMediaClassifier.isFrigateEvent(media) || + !cameraConfig.frigate.client_id + ) { return; } @@ -356,7 +362,10 @@ export class FrigateCameraManagerEngine media: ViewMedia, reviewed: boolean, ): Promise { - if (!FrigateViewMediaClassifier.isFrigateReview(media)) { + if ( + !FrigateViewMediaClassifier.isFrigateReview(media) || + !cameraConfig.frigate.client_id + ) { return; } @@ -590,7 +599,11 @@ export class FrigateCameraManagerEngine } const cameraConfig = this._getQueryableCameraConfig(store, cameraID); - if (!cameraConfig || !cameraConfig.frigate.camera_name) { + if ( + !cameraConfig || + !cameraConfig.frigate.camera_name || + !cameraConfig.frigate.client_id + ) { return; } @@ -669,7 +682,11 @@ export class FrigateCameraManagerEngine ): Promise => { const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; const cameraConfig = this._getQueryableCameraConfig(store, cameraID); - if (!cameraConfig || !cameraConfig.frigate.camera_name) { + if ( + !cameraConfig || + !cameraConfig.frigate.camera_name || + !cameraConfig.frigate.client_id + ) { return; } diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 4666e88c..5916c1bb 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -129,9 +129,7 @@ export const cameraConfigDefault = { cameras: [], }, engine: 'auto' as const, - frigate: { - client_id: 'frigate' as const, - }, + frigate: {}, live_provider: 'auto' as const, motioneye: { images: { @@ -273,7 +271,7 @@ export const cameraConfigSchema = z frigate: z .object({ url: z.string().optional(), - client_id: z.string().default(cameraConfigDefault.frigate.client_id), + client_id: z.string().optional(), camera_name: z.string().optional(), labels: z.string().array().optional(), zones: z.string().array().optional(), diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts index 333aa2d6..810c5d6b 100644 --- a/tests/camera-manager/frigate/camera.test.ts +++ b/tests/camera-manager/frigate/camera.test.ts @@ -24,7 +24,12 @@ import { CameraTriggerEventType } from '../../../src/config/schema/cameras'; import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; import { ViewMediaType } from '../../../src/view/item'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; -import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; +import { + createCameraConfig, + createHASS, + createRegistryEntity, + createStateEntity, +} from '../../test-utils'; vi.mock('../../../src/camera-manager/frigate/requests'); @@ -168,6 +173,118 @@ describe('FrigateCamera', () => { expect(camera.getConfig().frigate.camera_name).toBeUndefined(); }); }); + + describe('should resolve client_id', () => { + it('from a non-default value on the entity attributes', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.front_door', + frigate: { camera_name: 'front_door' }, + }), + mock(), + ); + await camera.initialize({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: { client_id: 'remote_frigate' }, + }), + }), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }); + expect(camera.getConfig().frigate.client_id).toBe('remote_frigate'); + }); + + it('falls back to "frigate" when the entity attribute is missing', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.front_door', + frigate: { camera_name: 'front_door' }, + }), + mock(), + ); + await camera.initialize({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: {}, + }), + }), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }); + expect(camera.getConfig().frigate.client_id).toBe('frigate'); + }); + + it('falls back to "frigate" when no camera_entity is configured', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + frigate: { camera_name: 'front_door' }, + }), + mock(), + ); + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }); + expect(camera.getConfig().frigate.client_id).toBe('frigate'); + }); + + it('preserves an explicit value', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.front_door', + frigate: { camera_name: 'front_door', client_id: 'remote_x' }, + }), + mock(), + ); + await camera.initialize({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + attributes: { client_id: 'something_else' }, + }), + }), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }); + expect(camera.getConfig().frigate.client_id).toBe('remote_x'); + }); + + it('leaves client_id untouched when the camera entity is unavailable', async () => { + const camera = new FrigateCamera( + createCameraConfig({ + camera_entity: 'camera.front_door', + frigate: { camera_name: 'front_door' }, + }), + mock(), + ); + await camera.initialize({ + hass: createHASS({ + 'camera.front_door': createStateEntity({ + entity_id: 'camera.front_door', + state: 'unavailable', + attributes: {}, + }), + }), + entityRegistryManager: mock(), + stateWatcher: mock(), + frigateEventWatcher: mock(), + frigateReviewWatcher: mock(), + }); + expect(camera.getConfig().frigate.client_id).toBeUndefined(); + }); + }); }); describe('should detect capabilities', () => { diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts index 7555fc08..9d4bd36e 100644 --- a/tests/camera-manager/frigate/engine-frigate.test.ts +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -113,6 +113,7 @@ const createFrigateCameraConfig = ( return createCameraConfig({ frigate: { camera_name: 'camera-1', + client_id: 'frigate', }, camera_entity: 'camera.office', ...config, @@ -355,6 +356,17 @@ describe('FrigateCameraManagerEngine', () => { ); expect(endpoint).toBeNull(); }); + + it('should get no path when client_id is unresolved', async () => { + const endpoint = await createEngine().getMediaDownloadPath( + createHASS(), + createFrigateCameraConfig({ + frigate: { camera_name: 'camera-1' }, + }), + createClipMedia(), + ); + expect(endpoint).toBeNull(); + }); }); describe('generateDefaultEventQuery', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index fd567f78..0abeea58 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -20,9 +20,7 @@ describe('config defaults', () => { cameras: [], }, engine: 'auto', - frigate: { - client_id: 'frigate', - }, + frigate: {}, go2rtc: { metadata_fetch_timeout_seconds: 2, },