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,
PTZCapabilities,
} from '../types';
import { CapabilitySearchOptions } from './types';
import { CapabilitySearchKeys } from './types';
export class Capabilities {
private _capabilities: CapabilitiesRaw;
@@ -32,7 +32,7 @@ export class Capabilities {
delete this._capabilities[capability];
}
public matches(capability: CapabilitySearchOptions): boolean {
public matches(capability: CapabilitySearchKeys): boolean {
let result = true;
if (typeof capability === 'string') {
result &&= this.has(capability);
+54 -25
View File
@@ -4,7 +4,7 @@ import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/item';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
import { CapabilitySearchOptions, Engine } from './types';
import { CapabilitySearchKeys, CapabilitySearchOptions, Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
@@ -27,11 +27,13 @@ export interface CameraManagerReadOnlyConfigStore {
getDefaultCameraID(): string | null;
getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions,
capability: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string>;
getAllDependentCameras(
cameraID: string,
capability?: CapabilityKey | CapabilitySearchOptions,
capability?: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string>;
}
@@ -121,14 +123,23 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions,
capability: CapabilityKey | CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): Set<string> {
const output: Set<string> = new Set();
for (const camera of this._cameras.values()) {
if (camera.getCapabilities()?.matches(capability)) {
output.add(camera.getID());
}
// Must use getAllDependentCameras() to recursively get all relevant
// 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;
}
@@ -177,33 +188,51 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
*/
public getAllDependentCameras(
cameraID: string,
capability?: CapabilitySearchOptions,
capabilitySearchKeys?: CapabilitySearchKeys,
options?: CapabilitySearchOptions,
): 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);
const matchingCameraIDs: Set<string> = new Set();
const camera = this.getCamera(cameraID);
const cameraConfig = camera?.getConfig();
if (camera && cameraConfig) {
if (!capability || camera.getCapabilities()?.matches(capability)) {
matchingCameraIDs.add(cameraID);
}
const dependentCameras: Set<string> = new Set();
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID));
}
for (const dependentCameraID of dependentCameras) {
if (!visitedCameraIDs.has(dependentCameraID)) {
getDependentCameras(dependentCameraID);
}
if (!camera || !cameraConfig) {
return matchingCameraIDs;
}
// Gather all dependent cameras...
const dependentCameras: Set<string> = new Set();
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID));
}
const matchingChildCameraIDs: Set<string> = new Set();
// ...now recurse through them.
for (const dependentCameraID of dependentCameras) {
if (!visitedCameraIDs.has(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[];
anyCapabilities?: CapabilityKey[];
}
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny;
export interface CapabilitySearchOptions {
inclusive?: boolean;
}
export interface CameraManagerCameraMetadata {
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 { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
@@ -27,7 +27,7 @@ export class QueryExecutor {
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryExecutorResult | null> {
const capabilitySearch: CapabilitySearchOptions =
const capabilitySearch: CapabilitySearchKeys =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
+8 -9
View File
@@ -11,10 +11,6 @@ export const getCameraIDsForViewName = (
viewName: AdvancedCameraCardView,
cameraID?: string,
): Set<string> => {
const capabilityMatchAnyMedia: CapabilitySearchOptions = {
anyCapabilities: ['clips', 'snapshots', 'recordings'],
};
switch (viewName) {
case 'diagnostics':
case 'image':
@@ -30,6 +26,9 @@ export const getCameraIDsForViewName = (
case 'snapshots':
case 'recording':
case 'recordings':
const options: CapabilitySearchOptions = {
inclusive: viewName !== 'live',
};
const capability =
viewName === 'clip'
? 'clips'
@@ -39,12 +38,12 @@ export const getCameraIDsForViewName = (
? 'recordings'
: viewName;
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability)
: cameraManager.getStore().getCameraIDsWithCapability(capability);
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
case 'timeline':
return cameraManager
.getStore()
.getCameraIDsWithCapability(capabilityMatchAnyMedia);
return cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
}
};
+31
View File
@@ -297,6 +297,37 @@ describe('CameraManagerStore', async () => {
);
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', () => {
+29 -8
View File
@@ -44,15 +44,9 @@ describe('getCameraIDsForViewName', () => {
describe('views that respect dependencies and need a capability', () => {
it.each([
['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, 'snapshots' as const],
['timeline' as const, 'recordings' as const],
['timeline' as const, 'snapshots' as const],
])('%s', (viewName: AdvancedCameraCardView, capabilityKey: CapabilityKey) => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
@@ -71,8 +65,35 @@ describe('getCameraIDsForViewName', () => {
expect(getCameraIDsForViewName(cameraManager, viewName)).toEqual(
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(
new Set(['camera-2']),
new Set(['camera-1', 'camera-2']),
);
expect(getCameraIDsForViewName(cameraManager, viewName, 'camera-2')).toEqual(
new Set(['camera-2']),