feat: Make cameras optional (#2343)

This commit is contained in:
Dermot Duffy
2026-02-13 20:21:05 -08:00
committed by GitHub
parent 21f9469784
commit 93f1f08e51
61 changed files with 1312 additions and 557 deletions
+198 -118
View File
@@ -1,15 +1,22 @@
import { AdvancedCameraCardView, VIEW_DEFAULT } from '../../config/schema/common/const';
import { AdvancedCameraCardView } from '../../config/schema/common/const';
import { ViewDisplayMode } from '../../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../../config/schema/types';
import { localize } from '../../localize/localize';
import { resolveViewName } from '../../view/utils/resolve-default';
import { View, ViewParameters } from '../../view/view';
import {
getCameraIDsForViewName,
doesViewRequireCamera,
getCameraIDsWithCapabilityForView,
isViewSupportedByCamera,
} from '../../view/view-support';
import { CardViewAPI } from '../types';
import { applyViewModifiers } from './modifiers';
import { ViewFactoryOptions, ViewIncompatible, ViewNoCameraError } from './types';
import { ViewFactoryOptions, ViewIncompatible } from './types';
interface ResolvedViewTarget {
viewName: AdvancedCameraCardView;
cameraID: string | null;
}
export class ViewFactory {
protected _api: CardViewAPI;
@@ -24,130 +31,72 @@ export class ViewFactory {
return null;
}
// Neither options.baseView.camera nor options.baseView.view are respected
// here, since this is the default view / camera.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1564
let cameraID: string | null = null;
const viewName = options?.params?.view ?? config.view.default;
if (options?.params?.camera) {
cameraID = options.params.camera;
} else {
const cameraIDs = [
...getCameraIDsForViewName(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
),
];
if (
cameraIDs.length &&
options?.baseView?.camera &&
config.view.default_cycle_camera
) {
const currentIndex = cameraIDs.indexOf(options.baseView.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
cameraID = cameraIDs[targetIndex];
} else {
cameraID = cameraIDs[0] ?? null;
}
}
const viewName = this._getDefaultViewName(config);
return this.getViewByParameters({
params: {
...options?.params,
view: viewName,
camera: cameraID,
camera: this._getDefaultCameraID(config, viewName, options),
},
baseView: options?.baseView,
});
}
protected _getDefaultViewName = (
config: AdvancedCameraCardConfig,
): AdvancedCameraCardView =>
resolveViewName(
config.view.default,
this._api.getCameraManager(),
this._api.getFoldersManager(),
);
protected _getDefaultCameraID(
config: AdvancedCameraCardConfig,
viewName: AdvancedCameraCardView,
options?: ViewFactoryOptions,
): string | null {
if (options?.params?.camera) {
return options.params.camera;
}
const cameraIDs = [
...getCameraIDsWithCapabilityForView(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
),
];
if (
cameraIDs.length &&
options?.baseView?.camera &&
config.view.default_cycle_camera
) {
const currentIndex = cameraIDs.indexOf(options.baseView.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
return cameraIDs[targetIndex];
}
return cameraIDs[0] ?? null;
}
public getViewByParameters(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let cameraID: string | null =
options?.params?.camera ?? options?.baseView?.camera ?? null;
let viewName =
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (!cameraID || !allCameraIDs.has(cameraID)) {
const viewCameraIDs = getCameraIDsForViewName(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
);
// Reset to the default camera.
cameraID = viewCameraIDs?.keys().next().value ?? null;
}
if (!cameraID) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
view: viewName,
cameras_capabilities: camerasToCapabilities,
});
}
if (
!isViewSupportedByCamera(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
cameraID,
)
) {
if (
options?.failSafe &&
isViewSupportedByCamera(
VIEW_DEFAULT,
this._api.getCameraManager(),
this._api.getFoldersManager(),
cameraID,
)
) {
viewName = VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
});
}
}
const configuredDisplayMode = this._getDefaultDisplayModeForView(viewName, config);
const displayMode =
// Prioritize the configured display mode (if present).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1812
(viewName !== options?.baseView?.view ? configuredDisplayMode : null) ??
options?.params?.displayMode ??
options?.baseView?.displayMode ??
configuredDisplayMode ??
'single';
let viewName = this._resolveViewName(config, options);
let cameraID = this._resolveCameraID(viewName, options);
({ viewName, cameraID } = this._ensureViewCompatibility(
viewName,
cameraID,
config,
options,
));
const displayMode = this._resolveDisplayMode(viewName, config, options);
const viewParameters: ViewParameters = {
...options?.params,
@@ -165,22 +114,153 @@ export class ViewFactory {
return view;
}
protected _getDefaultDisplayModeForView(
protected _resolveViewName(
config: AdvancedCameraCardConfig,
options?: ViewFactoryOptions,
): AdvancedCameraCardView {
if (options?.params?.view !== undefined) {
return resolveViewName(
options.params.view,
this._api.getCameraManager(),
this._api.getFoldersManager(),
);
}
return options?.baseView?.view ?? this._getDefaultViewName(config);
}
protected _resolveCameraID(
viewName: AdvancedCameraCardView,
options?: ViewFactoryOptions,
): string | null {
const cameraID = options?.params?.camera ?? options?.baseView?.camera ?? null;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (cameraID && allCameraIDs.has(cameraID)) {
return cameraID;
}
const viewCameraIDs = getCameraIDsWithCapabilityForView(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
);
return viewCameraIDs?.keys().next().value ?? null;
}
protected _ensureViewCompatibility(
viewName: AdvancedCameraCardView,
cameraID: string | null,
config: AdvancedCameraCardConfig,
options?: ViewFactoryOptions,
): ResolvedViewTarget {
if (!cameraID && doesViewRequireCamera(viewName)) {
return this._handleNoCameraForView(viewName, config, options);
}
if (
cameraID &&
!isViewSupportedByCamera(
viewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
cameraID,
)
) {
return this._handleUnsupportedView(viewName, cameraID, config, options);
}
return { viewName, cameraID };
}
protected _handleNoCameraForView(
viewName: AdvancedCameraCardView,
config: AdvancedCameraCardConfig,
options?: ViewFactoryOptions,
): ResolvedViewTarget {
const defaultViewName = this._getDefaultViewName(config);
if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) {
return { viewName: defaultViewName, cameraID: null };
}
if (options?.failSafe) {
return {
viewName: defaultViewName,
cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(),
};
}
throw new ViewIncompatible(localize('error.no_supported_cameras'), {
view: viewName,
camera: null,
default_view: defaultViewName,
});
}
protected _handleUnsupportedView(
viewName: AdvancedCameraCardView,
cameraID: string,
config: AdvancedCameraCardConfig,
options?: ViewFactoryOptions,
): ResolvedViewTarget {
const defaultViewName = this._getDefaultViewName(config);
if (
options?.failSafe &&
isViewSupportedByCamera(
defaultViewName,
this._api.getCameraManager(),
this._api.getFoldersManager(),
cameraID,
)
) {
return { viewName: defaultViewName, cameraID };
}
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
view: viewName,
camera: cameraID,
default_view: defaultViewName,
...(capabilities && { camera_capabilities: capabilities }),
});
}
protected _resolveDisplayMode(
viewName: AdvancedCameraCardView,
config: AdvancedCameraCardConfig,
options?: ViewFactoryOptions,
): ViewDisplayMode {
const configuredDisplayMode = this._getConfiguredDisplayMode(viewName, config);
// Prioritize the configured display mode (if present).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1812
return (
(viewName !== options?.baseView?.view ? configuredDisplayMode : null) ??
options?.params?.displayMode ??
options?.baseView?.displayMode ??
configuredDisplayMode ??
'single'
);
}
protected _getConfiguredDisplayMode(
viewName: AdvancedCameraCardView,
config: AdvancedCameraCardConfig,
): ViewDisplayMode | null {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config.media_viewer.display?.mode ?? null;
break;
return config.media_viewer.display?.mode ?? null;
case 'live':
mode = config.live.display?.mode ?? null;
break;
return config.live.display?.mode ?? null;
default:
return null;
}
return mode;
}
}
@@ -15,6 +15,10 @@ export class SubstreamOnViewModifier implements ViewModifier {
}
public modify(view: View): void {
if (!view.camera) {
return;
}
const dependencies = [
...this._api
.getCameraManager()
@@ -27,6 +31,13 @@ export class SubstreamOnViewModifier implements ViewModifier {
}
const currentOverride = getStreamCameraID(view);
/* istanbul ignore if: the if path cannot be reached, as there is a
view.camera guard at the start of this method and getStreamCameraID will
always return non-null as long as camera is present -- @preserve */
if (!currentOverride) {
return;
}
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
+6 -2
View File
@@ -3,6 +3,7 @@ import { AdvancedCameraCardError } from '../../types.js';
import { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js';
import { AdvancedCameraCardUserSpecifiedView } from '../../config/schema/common/const.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
@@ -26,12 +27,16 @@ export interface QueryExecutorOptions {
useCache?: boolean;
}
export type ViewParametersUserSpecified = Partial<Omit<ViewParameters, 'view'>> & {
view?: AdvancedCameraCardUserSpecifiedView;
};
export interface ViewFactoryOptions {
// An existing view to evolve from.
baseView?: View | null;
// View parameters to set/evolve.
params?: Partial<ViewParameters>;
params?: ViewParametersUserSpecified;
// Modifiers to the view once created.
modifiers?: ViewModifier[];
@@ -70,5 +75,4 @@ export interface ViewManagerInterface {
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
}
export class ViewNoCameraError extends AdvancedCameraCardError {}
export class ViewIncompatible extends AdvancedCameraCardError {}
+1 -1
View File
@@ -317,7 +317,7 @@ export class ViewManager implements ViewManagerInterface {
this._api.getConditionStateManager()?.setState({
view: view?.view,
camera: view?.camera,
camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined,
});
@@ -6,6 +6,7 @@ import { UnifiedQuery } from '../../view/unified-query';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { View } from '../../view/view';
import { doesViewRequireCamera, isViewSupported } from '../../view/view-support';
import { CardViewAPI } from '../types';
import { MergeContextViewModifier } from './modifiers/merge-context';
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property';
@@ -103,7 +104,26 @@ export class ViewQueryExecutor {
];
};
const cameraForQuery = view.isGrid() ? undefined : view.camera;
// Don't query if the view is not supported (e.g. a camera-based view with
// no cameras).
if (
!isViewSupported(
view.view,
this._api.getCameraManager(),
this._api.getFoldersManager(),
view.camera,
)
) {
return null;
}
// Don't query if the view requires a camera, if it's not in grid mode, but
// no camera is provided.
if (!view.isGrid() && !view.camera && doesViewRequireCamera(view.view)) {
return null;
}
const cameraForQuery = view.isGrid() ? undefined : view.camera ?? undefined;
const getDefaultQueryModifiers = async () => {
const query = builder.buildDefaultCameraQuery(cameraForQuery, {