fix: Don't incorrectly change camera when a dependency supports media (#2171)

- Closes: #2122
This commit is contained in:
Dermot Duffy
2025-09-01 13:29:04 -07:00
committed by GitHub
parent 2dd7f328f6
commit 79c27c43b8
7 changed files with 130 additions and 47 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import {
capabilityKeys, capabilityKeys,
PTZCapabilities, PTZCapabilities,
} from '../types'; } from '../types';
import { CapabilitySearchOptions } from './types'; import { CapabilitySearchKeys } from './types';
export class Capabilities { export class Capabilities {
private _capabilities: CapabilitiesRaw; private _capabilities: CapabilitiesRaw;
@@ -32,7 +32,7 @@ export class Capabilities {
delete this._capabilities[capability]; delete this._capabilities[capability];
} }
public matches(capability: CapabilitySearchOptions): boolean { public matches(capability: CapabilitySearchKeys): boolean {
let result = true; let result = true;
if (typeof capability === 'string') { if (typeof capability === 'string') {
result &&= this.has(capability); result &&= this.has(capability);
+46 -17
View File
@@ -4,7 +4,7 @@ import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/item'; import { ViewMedia } from '../view/item';
import { Camera } from './camera'; import { Camera } from './camera';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { CapabilitySearchOptions, Engine } from './types'; import { CapabilitySearchKeys, CapabilitySearchOptions, Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>; type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
@@ -27,11 +27,13 @@ export interface CameraManagerReadOnlyConfigStore {
getDefaultCameraID(): string | null; getDefaultCameraID(): string | null;
getCameraIDsWithCapability( getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions, capability: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string>; ): Set<string>;
getAllDependentCameras( getAllDependentCameras(
cameraID: string, cameraID: string,
capability?: CapabilityKey | CapabilitySearchOptions, capability?: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string>; ): Set<string>;
} }
@@ -121,14 +123,23 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
} }
public getCameraIDsWithCapability( public getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions, capability: CapabilityKey | CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string> { ): Set<string> {
const output: Set<string> = new Set(); const output: Set<string> = new Set();
for (const camera of this._cameras.values()) { for (const camera of this._cameras.values()) {
if (camera.getCapabilities()?.matches(capability)) { // Must use getAllDependentCameras() to recursively get all relevant
output.add(camera.getID()); // cameras respecting the capabilitiy.
} // See: https://github.com/dermotduffy/advanced-camera-card/issues/2122
this.getAllDependentCameras(camera.getID(), capability, options).forEach(
(cameraID) => {
output.add(cameraID);
},
);
} }
return output; return output;
} }
@@ -177,33 +188,51 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
*/ */
public getAllDependentCameras( public getAllDependentCameras(
cameraID: string, cameraID: string,
capability?: CapabilitySearchOptions, capabilitySearchKeys?: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string> { ): Set<string> {
const visitedCameraIDs = new Set<string>(); const visitedCameraIDs = new Set<string>();
const matchingCameraIDs: Set<string> = new Set();
const getDependentCameras = (cameraID: string): void => { const getDependentCameras = (cameraID: string): Set<string> => {
visitedCameraIDs.add(cameraID); visitedCameraIDs.add(cameraID);
const matchingCameraIDs: Set<string> = new Set();
const camera = this.getCamera(cameraID); const camera = this.getCamera(cameraID);
const cameraConfig = camera?.getConfig(); const cameraConfig = camera?.getConfig();
if (camera && cameraConfig) { if (!camera || !cameraConfig) {
if (!capability || camera.getCapabilities()?.matches(capability)) { return matchingCameraIDs;
matchingCameraIDs.add(cameraID);
} }
// Gather all dependent cameras...
const dependentCameras: Set<string> = new Set(); const dependentCameras: Set<string> = new Set();
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item)); cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) { if (cameraConfig.dependencies.all_cameras) {
this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID)); this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID));
} }
const matchingChildCameraIDs: Set<string> = new Set();
// ...now recurse through them.
for (const dependentCameraID of dependentCameras) { for (const dependentCameraID of dependentCameras) {
if (!visitedCameraIDs.has(dependentCameraID)) { if (!visitedCameraIDs.has(dependentCameraID)) {
getDependentCameras(dependentCameraID); getDependentCameras(dependentCameraID).forEach((dependentCameraID) =>
} matchingChildCameraIDs.add(dependentCameraID),
);
} }
} }
return new Set([
...(!capabilitySearchKeys ||
camera.getCapabilities()?.matches(capabilitySearchKeys) ||
(options?.inclusive && matchingChildCameraIDs.size)
? [cameraID]
: []),
...matchingChildCameraIDs,
]);
}; };
getDependentCameras(cameraID);
return matchingCameraIDs; return getDependentCameras(cameraID);
} }
} }
+4 -1
View File
@@ -99,7 +99,10 @@ interface CapabilitySearchAllAny {
allCapabilities?: CapabilityKey[]; allCapabilities?: CapabilityKey[];
anyCapabilities?: CapabilityKey[]; anyCapabilities?: CapabilityKey[];
} }
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny; export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny;
export interface CapabilitySearchOptions {
inclusive?: boolean;
}
export interface CameraManagerCameraMetadata { export interface CameraManagerCameraMetadata {
title: string; title: string;
+2 -2
View File
@@ -1,4 +1,4 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types'; import { CapabilitySearchKeys, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types'; import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index'; import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
@@ -27,7 +27,7 @@ export class QueryExecutor {
eventsMediaType?: ClipsOrSnapshotsOrAll; eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions; executorOptions?: QueryExecutorOptions;
}): Promise<QueryExecutorResult | null> { }): Promise<QueryExecutorResult | null> {
const capabilitySearch: CapabilitySearchOptions = const capabilitySearch: CapabilitySearchKeys =
!options?.eventsMediaType || options?.eventsMediaType === 'all' !options?.eventsMediaType || options?.eventsMediaType === 'all'
? { ? {
anyCapabilities: ['clips', 'snapshots'], anyCapabilities: ['clips', 'snapshots'],
+8 -9
View File
@@ -11,10 +11,6 @@ export const getCameraIDsForViewName = (
viewName: AdvancedCameraCardView, viewName: AdvancedCameraCardView,
cameraID?: string, cameraID?: string,
): Set<string> => { ): Set<string> => {
const capabilityMatchAnyMedia: CapabilitySearchOptions = {
anyCapabilities: ['clips', 'snapshots', 'recordings'],
};
switch (viewName) { switch (viewName) {
case 'diagnostics': case 'diagnostics':
case 'image': case 'image':
@@ -30,6 +26,9 @@ export const getCameraIDsForViewName = (
case 'snapshots': case 'snapshots':
case 'recording': case 'recording':
case 'recordings': case 'recordings':
const options: CapabilitySearchOptions = {
inclusive: viewName !== 'live',
};
const capability = const capability =
viewName === 'clip' viewName === 'clip'
? 'clips' ? 'clips'
@@ -39,12 +38,12 @@ export const getCameraIDsForViewName = (
? 'recordings' ? 'recordings'
: viewName; : viewName;
return cameraID return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability) ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(capability); : cameraManager.getStore().getCameraIDsWithCapability(capability, options);
case 'timeline': case 'timeline':
return cameraManager return cameraManager.getStore().getCameraIDsWithCapability({
.getStore() anyCapabilities: ['clips', 'snapshots', 'recordings'],
.getCameraIDsWithCapability(capabilityMatchAnyMedia); });
} }
}; };
+31
View File
@@ -297,6 +297,37 @@ describe('CameraManagerStore', async () => {
); );
expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two'])); expect(store.getAllDependentCameras('one', 'clips')).toEqual(new Set(['two']));
}); });
it('should return cameras with specific capabilities inclusive of parent', () => {
const store = new CameraManagerStore();
store.addCamera(
new Camera(
createCameraConfig({
id: 'one',
dependencies: {
all_cameras: true,
},
}),
engineGeneric,
),
);
store.addCamera(
new Camera(
createCameraConfig({
id: 'two',
}),
engineGeneric,
{
capabilities: new Capabilities({
clips: true,
}),
},
),
);
expect(store.getAllDependentCameras('one', 'clips', { inclusive: true })).toEqual(
new Set(['one', 'two']),
);
});
}); });
it('getCameraIDsWithCapability', () => { it('getCameraIDsWithCapability', () => {
+29 -8
View File
@@ -44,15 +44,9 @@ describe('getCameraIDsForViewName', () => {
describe('views that respect dependencies and need a capability', () => { describe('views that respect dependencies and need a capability', () => {
it.each([ it.each([
['live' as const, 'live' as const], ['live' as const, 'live' as const],
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
['recording' as const, 'recordings' as const],
['recordings' as const, 'recordings' as const],
['timeline' as const, 'clips' as const], ['timeline' as const, 'clips' as const],
['timeline' as const, 'snapshots' as const],
['timeline' as const, 'recordings' as const], ['timeline' as const, 'recordings' as const],
['timeline' as const, 'snapshots' as const],
])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => { ])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => {
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue( vi.mocked(cameraManager.getStore).mockReturnValue(
@@ -71,8 +65,35 @@ describe('getCameraIDsForViewName', () => {
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual( expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
new Set(['camera-2']), new Set(['camera-2']),
); );
});
it.each([
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
['recording' as const, 'recordings' as const],
['recordings' as const, 'recordings' as const],
])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
},
{
cameraID: 'camera-2',
capabilities: createCapabilities({ [capabilityKey]: true }),
},
]),
);
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual( expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-1')).toEqual(
new Set(['camera-2']), new Set(['camera-1', 'camera-2']),
); );
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual( expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
new Set(['camera-2']), new Set(['camera-2']),