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 |
| ------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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`
+8 -6
View File
@@ -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
+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) {
// 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<PTZCapabilities | null> {
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<FrigateEventChange>,
): Promise<void> {
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;
}
+21 -4
View File
@@ -188,6 +188,9 @@ export class FrigateCameraManagerEngine
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<Endpoint | null> {
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<void> {
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<void> {
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<void> => {
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;
}
+2 -4
View File
@@ -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(),
+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 { 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<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', () => {
@@ -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', () => {
+1 -3
View File
@@ -20,9 +20,7 @@ describe('config defaults', () => {
cameras: [],
},
engine: 'auto',
frigate: {
client_id: 'frigate',
},
frigate: {},
go2rtc: {
metadata_fetch_timeout_seconds: 2,
},