feat: Auto-resolve Frigate client_id from camera entity (#2475)

Credit goes to @Eduardo-Jaramillo for the inspiration and contribution
of https://github.com/dermotduffy/advanced-camera-card/pull/2474 .
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 9d088d7bcb
commit 261a7e1a92
8 changed files with 207 additions and 32 deletions
+7 -7
View File
@@ -35,13 +35,13 @@ cameras:
# [...] # [...]
``` ```
| Option | Default | Description | | 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. | | `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). | | `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`]. | | `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. | | `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`]. | | `zones` | | A list of Frigates zones used in the default media filter for events (clips & snapshots), e.g. [`front_door`, `front_steps`]. |
## `motioneye` ## `motioneye`
+8 -6
View File
@@ -214,8 +214,9 @@ between clicking the download button and the download starting.
### `Forbidden media source identifier` ### `Forbidden media source identifier`
- If you are using a custom `client_id` setting in your `frigate.yml` file (the - 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 configuration file for the Frigate backend itself), the card will auto-detect
about it. See [Frigate engine 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). configuration](configuration/cameras/engine.md?id=frigate).
- You must have the `Enable the media browser` option enabled for the 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 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` 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 The card auto-detects a custom `client_id` from the camera entity, so this
configuration file for the Frigate backend itself), you must tell the card about error usually means auto-detection couldn't run (no `camera_entity` configured)
it via the `client_id` parameter: or the entity doesn't expose a `client_id` attribute. Set it manually via the
`client_id` parameter:
```yaml ```yaml
cameras: cameras:
@@ -373,7 +375,7 @@ See [Frigate engine configuration](configuration/cameras/engine.md?id=frigate)
for more details. for more details.
If you're not using a custom `client_id`, your Frigate integration is likely not 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 ### `webrtc_card` unloads in the background
+38 -7
View File
@@ -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) { if (hasAutoTriggers) {
// Try to find the correct entities for the motion & occupancy sensors. // Try to find the correct entities for the motion & occupancy sensors.
// We know they are binary_sensors, and that they'll have the same // 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 stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name;
const url = const url =
this._config.go2rtc?.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 }); return getGo2RTCMetadataEndpoint(this._config, { url, stream });
} }
@@ -236,8 +251,12 @@ export class FrigateCamera extends Camera {
const url = const url =
this._config.go2rtc?.url ?? this._config.go2rtc?.url ??
// go2rtc is exposed by the Frigate integration under the 'mse' path. // 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, { return getGo2RTCStreamEndpoint(this._config, {
url, url,
stream, stream,
@@ -245,7 +264,7 @@ export class FrigateCamera extends Camera {
} }
private _getJSMPEGEndpoint(): Endpoint | null { private _getJSMPEGEndpoint(): Endpoint | null {
if (!this._config.frigate.camera_name) { if (!this._config.frigate.camera_name || !this._config.frigate.client_id) {
return null; return null;
} }
return { return {
@@ -305,7 +324,11 @@ export class FrigateCamera extends Camera {
hass: HomeAssistant, hass: HomeAssistant,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
): Promise<PTZCapabilities | null> { ): Promise<PTZCapabilities | null> {
if (!cameraConfig.frigate.camera_name || isBirdseye(cameraConfig)) { if (
!cameraConfig.frigate.camera_name ||
!cameraConfig.frigate.client_id ||
isBirdseye(cameraConfig)
) {
return null; return null;
} }
@@ -424,7 +447,11 @@ export class FrigateCamera extends Camera {
frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>, frigateEventWatcher: FrigateWatcherSubscriptionInterface<FrigateEventChange>,
): Promise<void> { ): Promise<void> {
const config = this.getConfig(); 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; return;
} }
@@ -495,7 +522,11 @@ export class FrigateCamera extends Camera {
const reviewConfig = config.triggers.reviews; const reviewConfig = config.triggers.reviews;
// Must have at least one severity configured and a camera name to subscribe // 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; return;
} }
+21 -4
View File
@@ -188,6 +188,9 @@ export class FrigateCameraManagerEngine
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
media: ViewMedia, media: ViewMedia,
): Promise<Endpoint | null> { ): Promise<Endpoint | null> {
if (!cameraConfig.frigate.client_id) {
return null;
}
if (FrigateViewMediaClassifier.isFrigateEvent(media)) { if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
return { return {
endpoint: endpoint:
@@ -342,7 +345,10 @@ export class FrigateCameraManagerEngine
media: ViewMedia, media: ViewMedia,
favorite: boolean, favorite: boolean,
): Promise<void> { ): Promise<void> {
if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { if (
!FrigateViewMediaClassifier.isFrigateEvent(media) ||
!cameraConfig.frigate.client_id
) {
return; return;
} }
@@ -356,7 +362,10 @@ export class FrigateCameraManagerEngine
media: ViewMedia, media: ViewMedia,
reviewed: boolean, reviewed: boolean,
): Promise<void> { ): Promise<void> {
if (!FrigateViewMediaClassifier.isFrigateReview(media)) { if (
!FrigateViewMediaClassifier.isFrigateReview(media) ||
!cameraConfig.frigate.client_id
) {
return; return;
} }
@@ -590,7 +599,11 @@ export class FrigateCameraManagerEngine
} }
const cameraConfig = this._getQueryableCameraConfig(store, cameraID); const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) { if (
!cameraConfig ||
!cameraConfig.frigate.camera_name ||
!cameraConfig.frigate.client_id
) {
return; return;
} }
@@ -669,7 +682,11 @@ export class FrigateCameraManagerEngine
): Promise<void> => { ): Promise<void> => {
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
const cameraConfig = this._getQueryableCameraConfig(store, cameraID); const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) { if (
!cameraConfig ||
!cameraConfig.frigate.camera_name ||
!cameraConfig.frigate.client_id
) {
return; return;
} }
+2 -4
View File
@@ -129,9 +129,7 @@ export const cameraConfigDefault = {
cameras: [], cameras: [],
}, },
engine: 'auto' as const, engine: 'auto' as const,
frigate: { frigate: {},
client_id: 'frigate' as const,
},
live_provider: 'auto' as const, live_provider: 'auto' as const,
motioneye: { motioneye: {
images: { images: {
@@ -273,7 +271,7 @@ export const cameraConfigSchema = z
frigate: z frigate: z
.object({ .object({
url: z.string().optional(), url: z.string().optional(),
client_id: z.string().default(cameraConfigDefault.frigate.client_id), client_id: z.string().optional(),
camera_name: z.string().optional(), camera_name: z.string().optional(),
labels: z.string().array().optional(), labels: z.string().array().optional(),
zones: z.string().array().optional(), zones: z.string().array().optional(),
+118 -1
View File
@@ -24,7 +24,12 @@ import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types'; import { Entity, EntityRegistryManager } from '../../../src/ha/registry/entity/types';
import { ViewMediaType } from '../../../src/view/item'; import { ViewMediaType } from '../../../src/view/item';
import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; 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'); vi.mock('../../../src/camera-manager/frigate/requests');
@@ -168,6 +173,118 @@ describe('FrigateCamera', () => {
expect(camera.getConfig().frigate.camera_name).toBeUndefined(); 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<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS({
'camera.front_door': createStateEntity({
entity_id: 'camera.front_door',
attributes: { client_id: 'remote_frigate' },
}),
}),
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
});
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<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS({
'camera.front_door': createStateEntity({
entity_id: 'camera.front_door',
attributes: {},
}),
}),
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
});
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<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS(),
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
});
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<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS({
'camera.front_door': createStateEntity({
entity_id: 'camera.front_door',
attributes: { client_id: 'something_else' },
}),
}),
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
});
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<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS({
'camera.front_door': createStateEntity({
entity_id: 'camera.front_door',
state: 'unavailable',
attributes: {},
}),
}),
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
});
expect(camera.getConfig().frigate.client_id).toBeUndefined();
});
});
}); });
describe('should detect capabilities', () => { describe('should detect capabilities', () => {
@@ -113,6 +113,7 @@ const createFrigateCameraConfig = (
return createCameraConfig({ return createCameraConfig({
frigate: { frigate: {
camera_name: 'camera-1', camera_name: 'camera-1',
client_id: 'frigate',
}, },
camera_entity: 'camera.office', camera_entity: 'camera.office',
...config, ...config,
@@ -355,6 +356,17 @@ describe('FrigateCameraManagerEngine', () => {
); );
expect(endpoint).toBeNull(); 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', () => { describe('generateDefaultEventQuery', () => {
+1 -3
View File
@@ -20,9 +20,7 @@ describe('config defaults', () => {
cameras: [], cameras: [],
}, },
engine: 'auto', engine: 'auto',
frigate: { frigate: {},
client_id: 'frigate',
},
go2rtc: { go2rtc: {
metadata_fetch_timeout_seconds: 2, metadata_fetch_timeout_seconds: 2,
}, },