fix: Substream only cameras should not be triggerable (#1848)

- Closes #1846
This commit is contained in:
Dermot Duffy
2025-01-21 07:17:09 -08:00
committed by GitHub
parent 596a724714
commit 0212840688
13 changed files with 100 additions and 9 deletions
+17
View File
@@ -64,6 +64,7 @@ cameras:
| `seek` | Clips can be seeked / scrubbed by the timeline. |
| `snapshots` | Snapshots can be fetched from the camera. |
| `substream` | The camera can be used as a substream on another camera. |
| `trigger` | The camera can be triggered. |
## `cast`
@@ -415,6 +416,22 @@ cameras:
dynamic: true
ssl_verification: auto
ssl_ciphers: auto
- camera_entity: camera.capabilities_reference
capabilities:
disable_except:
- clips
- favorite-events
- favorite-recordings
- live
- menu
- ptz
- recordings
- seek
- snapshots
- substream
- trigger
disable:
# Capabilities to selectively disable.
cameras_global:
triggers:
motion: false
+2 -2
View File
@@ -29,7 +29,7 @@ not providing this information, please do not be offended. I want to help, just
need to prioritize my limited development time well. Feel free to re-open your
issue, providing the requested information.
## Misconceptions About open source
## Misconceptions About Open Source
- The fixes aren't fast enough!
- The features aren't good enough!
@@ -52,6 +52,6 @@ hobbiest-level skills and ... at best ... hobbiest level support.
If you find yourself complaining, your expectations are incorrect! This is
undoubtedly frustrating (and I have often shared in this frustration), but it is
nonetheless just "the way open source is".
nonetheless just the way open source is. Take it or leave it.
Thank you for understanding!
+1 -1
View File
@@ -25,7 +25,7 @@ export class BrowseMediaCamera extends Camera {
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
}
this._entity = entity;
return this;
return await super.initialize(options);
}
public getEntity(): Entity | null {
+6 -4
View File
@@ -34,10 +34,12 @@ export class Camera {
}
async initialize(options: CameraInitializationOptions): Promise<Camera> {
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
);
if (this._capabilities?.has('trigger')) {
options.stateWatcher.subscribe(
this._stateChangeHandler,
this._config.triggers.entities,
);
}
this._onDestroy(() => options.stateWatcher.unsubscribe(this._stateChangeHandler));
return this;
}
+6 -1
View File
@@ -49,7 +49,11 @@ export class FrigateCamera extends Camera {
public async initialize(options: FrigateCameraInitializationOptions): Promise<Camera> {
await this._initializeConfig(options.hass, options.entityRegistryManager);
await this._initializeCapabilities(options.hass);
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
if (this._capabilities?.has('trigger')) {
await this._subscribeToEvents(options.hass, options.frigateEventWatcher);
}
return await super.initialize(options);
}
@@ -142,6 +146,7 @@ export class FrigateCamera extends Camera {
live: true,
menu: true,
substream: true,
trigger: true,
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
},
{
@@ -70,6 +70,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
seek: false,
snapshots: false,
substream: true,
trigger: true,
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
},
{
@@ -90,6 +90,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
seek: false,
snapshots: true,
substream: true,
trigger: true,
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
},
{
@@ -144,6 +144,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
seek: false,
snapshots: false,
substream: true,
trigger: true,
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
},
{
+3
View File
@@ -106,6 +106,8 @@ export interface CapabilitiesRaw {
ptz?: PTZCapabilities;
menu?: boolean;
trigger?: boolean;
}
export type CapabilityKey = keyof CapabilitiesRaw;
@@ -120,6 +122,7 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
'seek',
'snapshots',
'substream',
'trigger',
] as const;
export interface Icon {
+33 -1
View File
@@ -81,6 +81,9 @@ describe('Camera', () => {
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: true }),
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
@@ -117,7 +120,10 @@ describe('Camera', () => {
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{ eventCallback: eventCallback },
{
capabilities: createCapabilities({ trigger: true }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
@@ -125,6 +131,8 @@ describe('Camera', () => {
stateWatcher: stateWatcher,
});
expect(stateWatcher.subscribe).toBeCalled();
const diff = {
entityID: 'sensor.force_update',
oldState: createStateEntity({ state: stateFrom }),
@@ -138,6 +146,30 @@ describe('Camera', () => {
});
},
);
it('should not trigger without trigger capability', async () => {
const eventCallback = vi.fn();
const camera = new Camera(
createCameraConfig({
id: 'camera_1',
triggers: {
entities: ['binary_sensor.foo'],
},
}),
new GenericCameraManagerEngine(mock<StateWatcherSubscriptionInterface>()),
{
capabilities: createCapabilities({ trigger: false }),
eventCallback: eventCallback,
},
);
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
await camera.initialize({
stateWatcher: stateWatcher,
});
expect(stateWatcher.subscribe).not.toBeCalled();
});
});
describe('should get proxy config', () => {
@@ -162,6 +162,7 @@ describe('FrigateCamera', () => {
expect(camera.getCapabilities()?.has('live')).toBeTruthy();
expect(camera.getCapabilities()?.has('snapshots')).toBeTruthy();
expect(camera.getCapabilities()?.has('recordings')).toBeTruthy();
expect(camera.getCapabilities()?.has('trigger')).toBeTruthy();
expect(vi.mocked(getPTZInfo)).toBeCalled();
});
@@ -188,6 +189,7 @@ describe('FrigateCamera', () => {
expect(camera.getCapabilities()?.has('live')).toBeTruthy();
expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy();
expect(camera.getCapabilities()?.has('recordings')).toBeFalsy();
expect(camera.getCapabilities()?.has('trigger')).toBeTruthy();
expect(vi.mocked(getPTZInfo)).not.toBeCalled();
});
@@ -344,6 +346,31 @@ describe('FrigateCamera', () => {
expect(eventWatcher.subscribe).not.toBeCalled();
});
it('should not subscribe without trigger capability', async () => {
const camera = new FrigateCamera(
createCameraConfig({
frigate: {
client_id: 'CLIENT_ID',
camera_name: 'CAMERA',
},
capabilities: {
disable: ['trigger'],
},
}),
mock<CameraManagerEngine>(),
);
const hass = createHASS();
const eventWatcher = mock<FrigateEventWatcher>();
await camera.initialize({
hass: hass,
entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: eventWatcher,
});
expect(eventWatcher.subscribe).not.toBeCalled();
});
it('should not subscribe with no camera name', async () => {
const camera = new FrigateCamera(
createCameraConfig({
@@ -37,6 +37,7 @@ describe('GenericCameraManagerEngine', () => {
expect(camera.getCapabilities()?.has('clips')).toBeFalsy();
expect(camera.getCapabilities()?.has('recordings')).toBeFalsy();
expect(camera.getCapabilities()?.has('snapshots')).toBeFalsy();
expect(camera.getCapabilities()?.has('trigger')).toBeTruthy();
});
it('should generate default event query', () => {
@@ -248,6 +248,7 @@ describe('ReolinkCameraManagerEngine', () => {
seek: false,
snapshots: false,
substream: true,
trigger: true,
});
});