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
+4 -4
View File
@@ -10,7 +10,7 @@ import {
PTZActionPhase,
PTZPanTiltAction,
} from '../config/schema/actions/custom/ptz.js';
import { CameraConfig, CamerasConfig, Rotation } from '../config/schema/cameras.js';
import { CameraConfig, Rotation } from '../config/schema/cameras.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js';
import { Endpoint } from '../types.js';
@@ -166,7 +166,7 @@ export class CameraManager {
// global config (which does have defaults). The merging must happen in this
// order, to ensure that the defaults in the cameras global config do not
// override the values specified in the per-camera config.
const cameras = config.cameras.map((camera) =>
const cameras = (config.cameras ?? []).map((camera) =>
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
);
@@ -186,7 +186,7 @@ export class CameraManager {
}
protected async _getEnginesForCameras(
camerasConfig: CamerasConfig,
camerasConfig: CameraConfig[],
): Promise<Map<CameraConfig, CameraManagerEngine>> {
const output: Map<CameraConfig, CameraManagerEngine> = new Map();
const engines: Map<Engine, CameraManagerEngine> = new Map();
@@ -228,7 +228,7 @@ export class CameraManager {
return output;
}
protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> {
protected async _initializeCameras(camerasConfig: CameraConfig[]): Promise<void> {
const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
+2
View File
@@ -111,6 +111,8 @@ interface CapabilitySearchAllAny {
}
export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny;
export interface CapabilitySearchOptions {
// If true, include a parent camera in results when any of its dependent
// (child) cameras have the capability, even if the parent itself doesn't.
inclusive?: boolean;
}
@@ -8,13 +8,13 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
switch (this._action.effect_action) {
case 'start':
api.getEffectsControllerAPI()?.startEffect(this._action.effect);
api.getEffectsManager().startEffect(this._action.effect);
break;
case 'stop':
api.getEffectsControllerAPI()?.stopEffect(this._action.effect);
api.getEffectsManager().stopEffect(this._action.effect);
break;
case 'toggle':
api.getEffectsControllerAPI()?.toggleEffect(this._action.effect);
api.getEffectsManager().toggleEffect(this._action.effect);
break;
}
}
@@ -20,7 +20,10 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
const item = view?.queryResults?.getSelectedResult() ?? null;
if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
const cameraID = getStreamCameraID(view);
if (cameraID) {
await mediaPlayerController.playLive(mediaPlayer, cameraID);
}
} else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
await mediaPlayerController.playMedia(mediaPlayer, item);
}
@@ -30,9 +30,10 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
viewManager.getEpoch(),
getReviewedQueryFilterFromQuery(view?.query, item),
),
api
.getEffectsControllerAPI()
?.startEffect('check', { duration: 0.4, fadeIn: false }),
api.getEffectsManager().startEffect('check', {
duration: 0.4,
fadeIn: false,
}),
]);
// Trigger UI update to refresh menu icon state
@@ -47,11 +47,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
// When the camera changes, update the entity to match (only if different
// to avoid race conditions when multiple cards share the same entity).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2244
createInternalCallbackAction((api: CardActionsAPI) =>
createInternalCallbackAction(async (api: CardActionsAPI) =>
selectOptionOnEntityIfDifferent(
cameraControlEntity,
api.getViewManager().getView()?.camera,
api,
cameraControlEntity,
api.getViewManager().getView()?.camera ?? undefined,
),
),
],
@@ -76,13 +76,10 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
`{{ hass.states["${cameraControlEntity}"].state }}`,
)
: // Set the selected option in the entity to the current camera ID.
createInternalCallbackAction((api: CardActionsAPI) =>
selectOptionOnEntityIfDifferent(
cameraControlEntity,
api.getViewManager().getView()?.camera,
api,
),
),
createInternalCallbackAction(async (api: CardActionsAPI) => {
const camera = api.getViewManager().getView()?.camera ?? undefined;
return selectOptionOnEntityIfDifferent(api, cameraControlEntity, camera);
}),
],
tag: automationTag,
},
@@ -108,9 +105,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
};
const selectOptionOnEntityIfDifferent = async (
entity: string,
option: string | undefined,
api: CardActionsAPI,
entity: string,
option?: string,
): Promise<void> => {
const hass = api.getHASSManager().getHASS();
const currentState = hass?.states[entity]?.state;
+4 -10
View File
@@ -1,5 +1,6 @@
import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager';
import { EffectsManager } from './effects/effects-manager';
import { ConditionStateManager } from '../conditions/state-manager';
import { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device';
@@ -8,7 +9,6 @@ import { EntityRegistryManagerLive } from '../ha/registry/entity';
import { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media';
import { LovelaceCardEditor } from '../ha/types';
import { EffectsControllerAPI } from '../types';
import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
@@ -28,7 +28,6 @@ import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
@@ -67,8 +66,6 @@ import {
import { ViewItemManager } from './view/item-manager';
import { ViewManager } from './view/view-manager';
type EffectsControllerAPICallback = () => EffectsControllerAPI | null;
export class CardController
implements
CardActionsManagerAPI,
@@ -97,9 +94,8 @@ export class CardController
CardViewAPI,
ReactiveController
{
protected _effectsControllerAPICallback: EffectsControllerAPICallback;
protected _conditionStateManager = new ConditionStateManager();
protected _effectsManager = new EffectsManager();
// These properties may be used in the construction of 'managers' (and should
// be created first).
@@ -138,7 +134,6 @@ export class CardController
host: CardHTMLElement,
scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback,
effectsControllerAPICallback: EffectsControllerAPICallback,
) {
host.addController(this);
@@ -148,7 +143,6 @@ export class CardController
scrollCallback,
menuToggleCallback,
);
this._effectsControllerAPICallback = effectsControllerAPICallback;
}
// *************************************************************************
@@ -199,8 +193,8 @@ export class CardController
return this._deviceRegistryManager;
}
public getEffectsControllerAPI(): EffectsControllerAPI | null {
return this._effectsControllerAPICallback();
public getEffectsManager(): EffectsManager {
return this._effectsManager;
}
public getEntityRegistryManager(): EntityRegistryManager {
@@ -1,4 +1,4 @@
import { EffectName, EffectsControllerAPI } from '../../types';
import { EffectName, EffectsContainer, EffectsManagerInterface } from '../../types';
import { Timer } from '../../utils/timer';
import { EffectComponent, EffectModule, EffectOptions } from './types';
@@ -29,38 +29,125 @@ const effectRegistry: Record<EffectName, () => Promise<EffectModule>> = {
},
};
type EffectsContainer = HTMLElement | DocumentFragment;
type EffectModuleImporter = (name: EffectName) => Promise<EffectModule | null>;
export class EffectsController implements EffectsControllerAPI {
const defaultImportEffectModule: EffectModuleImporter = async (name: EffectName) => {
const effectModule = await effectRegistry[name]?.();
return effectModule ?? null;
};
export class EffectsManager implements EffectsManagerInterface {
private _importer: EffectModuleImporter;
private _importedModules: Map<EffectName, EffectModule> = new Map();
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
private _durationTimers: Map<EffectName, Timer> = new Map();
private _container: EffectsContainer | null = null;
public setContainer(container: EffectsContainer | null): void {
// Effects that have been requested to start but are still loading or waiting
// for a container to be registered (test case: a card without cameras or
// folders will initialize very quickly and the card will be loaded before
// effects can be started).
private _pendingEffects: Map<EffectName, EffectOptions | undefined> = new Map();
private _activeEffects: Map<EffectName, EffectComponent | null> = new Map();
protected _container: EffectsContainer | null = null;
constructor(importer: EffectModuleImporter = defaultImportEffectModule) {
this._importer = importer;
}
public setContainer(container: EffectsContainer): void {
this._container = container;
this._startPendingEffects();
}
public removeContainer(): void {
this._container = null;
this._pendingEffects.clear();
for (const timer of this._durationTimers.values()) {
timer.stop();
}
this._durationTimers.clear();
for (const instance of this._activeEffects.values()) {
instance?.remove();
}
this._activeEffects.clear();
}
public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (!this._container || this._activeInstances.has(name)) {
if (this._activeEffects.has(name)) {
return;
}
// Reserve the slot immediately with null to prevent concurrent starts.
this._activeInstances.set(name, null);
this._activeEffects.set(name, null);
if (!this._container) {
this._pendingEffects.set(name, options);
return;
}
await this._startEffect(name, options);
}
public async stopEffect(effect: EffectName): Promise<void> {
const timer = this._durationTimers.get(effect);
if (timer) {
timer.stop();
this._durationTimers.delete(effect);
}
this._pendingEffects.delete(effect);
if (!this._activeEffects.has(effect)) {
return;
}
const instance = this._activeEffects.get(effect);
this._activeEffects.delete(effect);
// If instance is null, it's still loading - just clearing the reservation
// will prevent it from appearing (startEffect checks this after import).
if (instance) {
await instance.startFadeOut();
instance.remove();
}
}
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (this._activeEffects.has(name)) {
await this.stopEffect(name);
} else {
await this.startEffect(name, options);
}
}
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
const existingModule = this._importedModules.get(name);
if (existingModule) {
return existingModule;
}
const effectModule = await this._importer(name);
if (!effectModule) {
return null;
}
this._importedModules.set(name, effectModule);
return effectModule;
}
private async _startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
const effectModule = await this._importEffectModule(name);
// Check if the effect was cancelled during loading.
if (!effectModule || !this._activeInstances.has(name)) {
this._activeInstances.delete(name);
this._pendingEffects.delete(name);
if (!effectModule || !this._activeEffects.has(name) || !this._container) {
this._activeEffects.delete(name);
return;
}
const effectComponent = new effectModule.default();
effectComponent.fadeIn = options?.fadeIn ?? true;
this._container.appendChild(effectComponent);
this._activeInstances.set(name, effectComponent);
this._activeEffects.set(name, effectComponent);
const duration = options?.duration;
if (duration !== undefined) {
@@ -76,48 +163,10 @@ export class EffectsController implements EffectsControllerAPI {
}
}
public async stopEffect(effect: EffectName): Promise<void> {
const timer = this._durationTimers.get(effect);
if (timer) {
timer.stop();
this._durationTimers.delete(effect);
private _startPendingEffects(): void {
for (const [name, options] of this._pendingEffects.entries()) {
this._pendingEffects.delete(name);
this._startEffect(name, options);
}
if (!this._activeInstances.has(effect)) {
return;
}
const instance = this._activeInstances.get(effect);
this._activeInstances.delete(effect);
// If instance is null, it's still loading - just clearing the reservation
// will prevent it from appearing (startEffect checks this after import).
if (instance) {
await instance.startFadeOut();
instance.remove();
}
}
public async toggleEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (this._activeInstances.has(name)) {
await this.stopEffect(name);
} else {
await this.startEffect(name, options);
}
}
private async _importEffectModule(name: EffectName): Promise<EffectModule | null> {
const existingModule = this._importedModules.get(name);
if (existingModule) {
return existingModule;
}
const effectModule = await effectRegistry[name]?.();
if (!effectModule) {
return null;
}
this._importedModules.set(name, effectModule);
return effectModule;
}
}
+2 -2
View File
@@ -5,12 +5,12 @@ import {
createGeneralAction,
createViewAction,
} from '../utils/action.js';
import { ViewParameters } from '../view/view';
import { CardQueryStringAPI } from './types';
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
import { ViewParametersUserSpecified } from './view/types.js';
interface QueryStringViewIntent {
view?: Partial<ViewParameters> & {
view?: ViewParametersUserSpecified & {
default?: boolean;
substream?: string;
};
@@ -43,8 +43,8 @@ export class StatusBarItemManager {
view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null;
}): StatusBarItem[] {
const cameraMetadata = options?.view
? options?.cameraManager?.getCameraMetadata(options?.view?.camera)
const cameraMetadata = options?.view?.camera
? options?.cameraManager?.getCameraMetadata(options.view.camera)
: null;
const engineIcon = cameraMetadata?.engineIcon ?? null;
const selectedResult = options?.view?.queryResults?.getSelectedResult();
+2 -2
View File
@@ -4,7 +4,7 @@ import type { Automation } from '../config/schema/automations';
import type { DeviceRegistryManager } from '../ha/registry/device';
import type { EntityRegistryManager } from '../ha/registry/entity/types';
import type { ResolvedMediaCache } from '../ha/resolved-media';
import type { EffectsControllerAPI } from '../types';
import type { EffectsManagerInterface } from '../types';
import type { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
@@ -44,7 +44,7 @@ export interface CardActionsAPI {
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getEffectsControllerAPI(): EffectsControllerAPI | null;
getEffectsManager(): EffectsManagerInterface;
getExpandManager(): ExpandManager;
getFoldersManager(): FoldersManager;
getFullscreenManager(): FullscreenManager;
+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, {
+3 -6
View File
@@ -9,7 +9,6 @@ import { actionHandler } from './action-handler-directive.js';
import { CardController } from './card-controller/controller';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects';
import { AdvancedCameraCardEffects } from './components/effects/effects';
import './components/elements.js';
import { AdvancedCameraCardElements } from './components/elements.js';
import './components/loading.js';
@@ -96,12 +95,10 @@ class AdvancedCameraCard extends LitElement {
// diagnostics starting at the top).
() => this._refMain.value?.scroll({ top: 0 }),
() => this._refMenu.value?.toggleMenu(),
() => this._refEffects.value ?? null,
);
protected _menuButtonController = new MenuButtonController();
protected _refEffects: Ref<AdvancedCameraCardEffects> = createRef();
protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
protected _refMain: Ref<HTMLElement> = createRef();
protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef();
@@ -350,7 +347,7 @@ class AdvancedCameraCard extends LitElement {
// ensure the hover menu styling continues to work.
return this._renderInDialogIfNecessary(
html` <advanced-camera-card-effects
${ref(this._refEffects)}
.effectsManager=${this._controller.getEffectsManager()}
></advanced-camera-card-effects>
<ha-card
id="ha-card"
@@ -387,9 +384,9 @@ class AdvancedCameraCard extends LitElement {
.loaded=${this._controller
.getInitializationManager()
.wasEverInitialized()}
.effectsControllerAPI=${this._config?.performance?.features
.effectsManager=${this._config?.performance?.features
.card_loading_effects !== false
? this._controller.getEffectsControllerAPI()
? this._controller.getEffectsManager()
: undefined}
></advanced-camera-card-loading>`
: ''}
+41 -32
View File
@@ -31,8 +31,12 @@ import { isBeingCasted } from '../utils/casting';
import { getPTZTarget } from '../utils/ptz';
import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { ViewItemClassifier } from '../view/item-classifier';
import { resolveViewName } from '../view/utils/resolve-default';
import { View } from '../view/view';
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
import {
getCameraIDsWithCapabilityForView,
isViewSupported,
} from '../view/view-support';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
@@ -112,7 +116,13 @@ export class MenuButtonController {
this._getFoldersButton(config, foldersManager, options?.view),
...this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, button, options),
style: this._getStyleFromActions(
config,
cameraManager,
foldersManager,
button,
options,
),
...button,
})),
].filter(isTruthy);
@@ -175,7 +185,7 @@ export class MenuButtonController {
cameraManager: CameraManager,
view?: View | null,
): MenuItem | null {
if (!view) {
if (!view?.camera) {
return null;
}
@@ -184,7 +194,7 @@ export class MenuButtonController {
.getAllDependentCameras(view.camera, 'substream');
if (substreamCameraIDs.size && view.is('live')) {
const substreams = [...substreamCameraIDs].filter(
const substreams = Array.from(substreamCameraIDs).filter(
(cameraID) => cameraID !== view.camera,
);
const streams = [view.camera, ...substreams];
@@ -239,14 +249,13 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('live', cameraManager, foldersManager, view.camera)
return isViewSupported('live', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:cctv',
...config.menu.buttons.live,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.live'),
style: view.is('live') ? this._getEmphasizedStyle() : {},
style: view?.is('live') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('live'),
}
: null;
@@ -258,8 +267,7 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
return isViewSupported('clips', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:filmstrip',
...config.menu.buttons.clips,
@@ -278,8 +286,7 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
return isViewSupported('snapshots', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:camera',
...config.menu.buttons.snapshots,
@@ -298,14 +305,13 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('recordings', cameraManager, foldersManager, view.camera)
return isViewSupported('recordings', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:album',
...config.menu.buttons.recordings,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.recordings'),
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
style: view?.is('recordings') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('recordings'),
hold_action: createViewAction('recording'),
}
@@ -318,14 +324,13 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
return isViewSupported('reviews', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:play-box-edit-outline',
...config.menu.buttons.reviews,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.reviews'),
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
style: view?.is('reviews') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('reviews'),
hold_action: createViewAction('review'),
}
@@ -338,14 +343,13 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('gallery', cameraManager, foldersManager, view.camera)
return isViewSupported('gallery', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:play-box-multiple',
...config.menu.buttons.gallery,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.gallery'),
style: view.is('gallery') ? this._getEmphasizedStyle() : {},
style: view?.is('gallery') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('gallery'),
hold_action: createViewAction('media'),
}
@@ -358,8 +362,7 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('image', cameraManager, foldersManager, view.camera)
return isViewSupported('image', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:image',
...config.menu.buttons.image,
@@ -377,14 +380,13 @@ export class MenuButtonController {
foldersManager: FoldersManager,
view?: View | null,
): MenuItem | null {
return view &&
isViewSupportedByCamera('timeline', cameraManager, foldersManager, view.camera)
return isViewSupported('timeline', cameraManager, foldersManager, view?.camera)
? {
icon: 'mdi:chart-gantt',
...config.menu.buttons.timeline,
type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.timeline'),
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
style: view?.is('timeline') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('timeline'),
}
: null;
@@ -479,11 +481,12 @@ export class MenuButtonController {
view?: View | null,
microphoneManager?: MicrophoneManager | null,
): MenuItem | null {
if (!view) {
const streamCameraID = view ? getStreamCameraID(view) : null;
if (!streamCameraID) {
return null;
}
const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view));
const capabilities = cameraManager.getCameraCapabilities(streamCameraID);
if (microphoneManager && capabilities?.has('2-way-audio')) {
const unavailable =
@@ -557,7 +560,9 @@ export class MenuButtonController {
if (!view) {
return null;
}
const selectedCameraConfig = cameraManager.getStore().getCameraConfig(view.camera);
const selectedCameraConfig = view.camera
? cameraManager.getStore().getCameraConfig(view.camera)
: null;
if (
mediaPlayerController?.hasMediaPlayers() &&
(view.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
@@ -659,7 +664,7 @@ export class MenuButtonController {
view?: View | null,
): MenuItem | null {
const viewCameraIDs = view
? getCameraIDsForViewName(view.view, cameraManager, foldersManager)
? getCameraIDsWithCapabilityForView(view.view, cameraManager, foldersManager)
: null;
if (
view?.supportsMultipleDisplayModes() &&
@@ -754,8 +759,8 @@ export class MenuButtonController {
foldersManager?: FoldersManager | null,
view?: View | null,
): MenuItem | null {
const folders = [...(foldersManager?.getFolders() ?? [])];
if (!folders?.length) {
const folders = Array.from(foldersManager?.getFolders() ?? []);
if (!foldersManager?.hasFolders()) {
return null;
}
@@ -823,6 +828,8 @@ export class MenuButtonController {
*/
protected _getStyleFromActions(
config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
button: MenuItem,
options?: MenuButtonControllerOptions,
): StyleInfo {
@@ -848,7 +855,9 @@ export class MenuButtonController {
),
) ||
(action.advanced_camera_card_action === 'default' &&
options?.view?.is(config.view.default)) ||
options?.view?.is(
resolveViewName(config.view.default, cameraManager, foldersManager),
)) ||
(action.advanced_camera_card_action === 'fullscreen' &&
!!options?.fullscreenManager?.isInFullscreen()) ||
(action.advanced_camera_card_action === 'camera_select' &&
+1 -1
View File
@@ -458,7 +458,7 @@ export class TimelineController {
main: true,
},
);
} else if (panMode === 'seek-in-camera') {
} else if (panMode === 'seek-in-camera' && view.camera) {
newResults = results
.clone()
.selectBestResult(
+18 -21
View File
@@ -1,31 +1,28 @@
import { LitElement, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { EffectsController } from '../../components-lib/effects/effects-controller';
import { EffectOptions } from '../../components-lib/effects/types';
import { LitElement, PropertyValues, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { EffectsManager } from '../../card-controller/effects/effects-manager';
import effectsStyle from '../../scss/effects.scss';
import { EffectName, EffectsControllerAPI } from '../../types';
@customElement('advanced-camera-card-effects')
export class AdvancedCameraCardEffects
extends LitElement
implements EffectsControllerAPI
{
protected _controller = new EffectsController();
export class AdvancedCameraCardEffects extends LitElement {
@property({ attribute: false })
public effectsManager?: EffectsManager;
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
await this._controller.startEffect(effect, options);
}
public stopEffect(effect: EffectName): void {
this._controller.stopEffect(effect);
}
public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
await this._controller.toggleEffect(effect, options);
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has('effectsManager')) {
const previousManager: EffectsManager | undefined =
changedProperties.get('effectsManager');
previousManager?.removeContainer();
}
}
protected updated(): void {
this._controller.setContainer(this.renderRoot);
this.effectsManager?.setContainer(this.renderRoot);
}
public disconnectedCallback(): void {
this.effectsManager?.removeContainer();
super.disconnectedCallback();
}
static get styles() {
+17 -1
View File
@@ -10,11 +10,13 @@ import { CameraConfig } from '../config/schema/cameras';
import { ImageViewConfig } from '../config/schema/image';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
import './image-updating-player';
import { resolveImageMode } from './image-updating-player';
import './media-dimensions-container';
import { renderMessage } from './message.js';
import './zoomer.js';
@customElement('advanced-camera-card-image')
@@ -81,10 +83,24 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
if (!this.hass) {
return;
}
// Determine if this image mode requires a camera
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' && !this.cameraConfig) {
return renderMessage({
type: 'info',
message: localize('error.no_camera_for_image'),
icon: 'mdi:camera-off',
});
}
return this._renderContainer(html`
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
+4 -1
View File
@@ -109,7 +109,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view) {
if (!cameraIDs?.size || !view?.camera) {
return 0;
}
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
@@ -248,6 +248,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
const cameraID = this.viewFilterCameraID ?? view.camera;
if (!cameraID) {
return {};
}
const currentIndex = cameraIDs.indexOf(cameraID);
if (currentIndex < 0) {
+4 -4
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import loadingStyle from '../scss/loading.scss';
import { EffectName, EffectsControllerAPI } from '../types';
import { EffectName, EffectsManagerInterface } from '../types';
import { getReleaseVersion } from '../utils/diagnostics';
import './icon';
@@ -33,7 +33,7 @@ const getDateEffect = (): EffectName | null => {
@customElement('advanced-camera-card-loading')
export class AdvancedCameraCardLoading extends LitElement {
@property({ attribute: false })
public effectsControllerAPI?: EffectsControllerAPI | null;
public effectsManager?: EffectsManagerInterface;
@property({ type: Boolean, reflect: true })
public loaded = false;
@@ -60,13 +60,13 @@ export class AdvancedCameraCardLoading extends LitElement {
}
private _startEffect(effect: EffectName): void {
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
this.effectsManager?.startEffect(effect, { fadeIn: false });
this._effectName = effect;
}
private _stopEffect(): void {
if (this._effectName) {
this.effectsControllerAPI?.stopEffect(this._effectName);
this.effectsManager?.stopEffect(this._effectName);
}
this._effectName = null;
}
+2 -2
View File
@@ -139,7 +139,7 @@ export class AdvancedCameraCardViews extends LitElement {
? this.config.media_viewer.controls.timeline
: undefined;
const cameraConfig = view
const cameraConfig = view?.camera
? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null;
@@ -155,7 +155,7 @@ export class AdvancedCameraCardViews extends LitElement {
.viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && view?.is('image') && cameraConfig
${!this.hide && view?.is('image')
? html` <advanced-camera-card-image
.imageConfig=${this.config.image}
.viewManagerEpoch=${this.viewManagerEpoch}
+1 -4
View File
@@ -329,7 +329,4 @@ export const cameraConfigSchema = z
.default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
// Avoid using .nonempty() to avoid changing the inferred type
// (https://github.com/colinhacks/zod#minmaxlength).
export const camerasConfigSchema = cameraConfigSchema.array().min(1);
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
export const camerasConfigSchema = cameraConfigSchema.array().optional();
+4 -5
View File
@@ -8,10 +8,7 @@ export const STATUS_BAR_PRIORITY_MAX = 100;
export const BUTTON_SIZE_MIN = 20;
// The default view (may not be supported on all cameras).
export const VIEW_DEFAULT = 'live' as const;
export const VIEWS_USER_SPECIFIED = [
const VIEWS = [
'diagnostics',
'live',
'clip',
@@ -29,5 +26,7 @@ export const VIEWS_USER_SPECIFIED = [
'image',
'timeline',
] as const;
export type AdvancedCameraCardView = (typeof VIEWS)[number];
export const VIEWS_USER_SPECIFIED = ['auto', ...VIEWS] as const;
export type AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number];
export type AdvancedCameraCardView = AdvancedCameraCardUserSpecifiedView | 'diagnostics';
@@ -13,4 +13,3 @@ export const menuBaseSchema = z.object({
icon: z.string().optional(),
permanent: z.boolean().default(false).optional(),
});
export type MenuItemBase = z.infer<typeof menuBaseSchema>;
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { actionsSchema } from './actions/types';
import { VIEW_DEFAULT, VIEWS_USER_SPECIFIED } from './common/const';
import { VIEWS_USER_SPECIFIED } from './common/const';
const keyboardShortcut = z.object({
key: z.string(),
@@ -46,7 +46,7 @@ export type PTZKeyboardShortcutName =
| 'ptz_zoom_out';
export const viewConfigDefault = {
default: VIEW_DEFAULT,
default: 'auto' as const,
camera_select: 'current' as const,
interaction_seconds: 300,
default_reset: {
+1
View File
@@ -428,6 +428,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected _viewModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'auto', label: localize('config.view.views.auto') },
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'clips', label: localize('config.view.views.clips') },
{ value: 'folder', label: localize('config.view.views.folder') },
+4 -1
View File
@@ -6,7 +6,6 @@
"common": {
"advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card",
"up": "Up",
"folder": "Folder",
"live": "Live",
"no_folder": "No folder to display",
@@ -22,6 +21,7 @@
"medium": "Medium"
},
"severity": "Severity",
"up": "Up",
"version": "Version"
},
"config": {
@@ -628,6 +628,7 @@
"untrigger_seconds": "Seconds after inactive state change to untrigger"
},
"views": {
"auto": "Automatic",
"clip": "Most recent clip",
"clips": "Clips gallery",
"current": "Current view",
@@ -738,6 +739,8 @@
"no_camera_engine": "Could not determine suitable engine for camera",
"no_camera_entity": "Could not find camera entity",
"no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers",
"no_camera_for_image": "No camera configured for camera image mode",
"no_camera_for_live": "No cameras are configured for live view",
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
+6 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import type { EffectOptions } from './components-lib/effects/types';
import type { EffectOptions } from './card-controller/effects/types';
import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
import { Severity } from './severity';
@@ -217,8 +217,12 @@ export type EffectName =
| 'shamrocks'
| 'snow';
export interface EffectsControllerAPI {
export type EffectsContainer = HTMLElement | DocumentFragment;
export interface EffectsManagerInterface {
startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
stopEffect(effect: EffectName): void;
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void>;
setContainer(container: EffectsContainer): void;
removeContainer(): void;
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { ViewItemClassifier } from '../view/item-classifier';
export const findBestMediaTimeIndex = (
mediaArray: ViewItem[],
targetTime: Date,
favorCameraID?: string,
favorCameraID?: string | null,
): number | null => {
let bestMatch:
| {
+3
View File
@@ -28,6 +28,9 @@ export const getPTZTarget = (
};
} else if (view.is('live')) {
const substreamAwareCameraID = getStreamCameraID(view);
if (!substreamAwareCameraID) {
return null;
}
let type: PTZType = 'digital';
if (options?.type !== 'digital' && options?.cameraManager) {
+2 -2
View File
@@ -28,14 +28,14 @@ const screenshotElement = (
export const generateScreenshotFilename = (view?: View | null): string => {
if (view?.is('live') || view?.is('image')) {
return `${view.view}_${view.camera}_${format(
return `${view.view}${view.camera ? `_${view.camera}` : ''}_${format(
new Date(),
`yyyy-MM-dd-HH-mm-ss`,
)}.jpg`;
} else if (view?.isViewerView()) {
const media = view.queryResults?.getSelectedResult();
const id = media?.getID() ?? null;
return `${view.view}_${view.camera}${id ? `_${id}` : ''}.jpg`;
return `${view.view}${view.camera ? `_${view.camera}` : ''}${id ? `_${id}` : ''}.jpg`;
}
return 'screenshot.jpg';
};
+22 -6
View File
@@ -1,18 +1,31 @@
import { View } from '../view/view';
export const getStreamCameraID = (view: View, cameraID?: string): string => {
return (
view.context?.live?.overrides?.get(cameraID ?? view.camera) ??
cameraID ??
view.camera
);
/**
* Get the effective camera ID for streaming, considering substream overrides.
* Returns null if the view has no camera.
*/
export const getStreamCameraID = (
view: View,
cameraID?: string | null,
): string | null => {
const baseCameraID = cameraID ?? view.camera;
if (!baseCameraID) {
return null;
}
return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID;
};
export const hasSubstream = (view: View): boolean => {
if (!view.camera) {
return false;
}
return getStreamCameraID(view) !== view.camera;
};
export const setSubstream = (view: View, substreamID: string): void => {
if (!view.camera) {
return;
}
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
overrides.set(view.camera, substreamID);
view.mergeInContext({
@@ -21,6 +34,9 @@ export const setSubstream = (view: View, substreamID: string): void => {
};
export const removeSubstream = (view: View): void => {
if (!view.camera) {
return;
}
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
+28
View File
@@ -0,0 +1,28 @@
import { CameraManager } from '../../camera-manager/manager';
import { FoldersManager } from '../../card-controller/folders/manager';
import {
AdvancedCameraCardUserSpecifiedView,
AdvancedCameraCardView,
} from '../../config/schema/common/const';
/**
* Resolve a view name that may be 'auto'.
* @param viewName The view name.
* @param cameraManager The camera manager.
* @param foldersManager The folders manager.
* @returns A concrete view name.
*/
export const resolveViewName = (
viewName: AdvancedCameraCardUserSpecifiedView,
cameraManager: CameraManager,
foldersManager: FoldersManager,
): AdvancedCameraCardView => {
if (viewName !== 'auto') {
return viewName;
}
return cameraManager.getStore().getCameraIDs().size
? 'live'
: foldersManager.hasFolders()
? 'folders'
: 'image';
};
+125 -51
View File
@@ -1,64 +1,120 @@
import { CameraManager } from '../camera-manager/manager';
import { CapabilitySearchOptions } from '../camera-manager/types';
import { CapabilitySearchKeys, CapabilitySearchOptions } from '../camera-manager/types';
import { FoldersManager } from '../card-controller/folders/manager';
import { AdvancedCameraCardView } from '../config/schema/common/const';
/**
* Get cameraIDs that are relevant for a given view name based on camera
* capability (if camera specified).
*/
export const getCameraIDsForViewName = (
type ViewSource = 'camera' | 'folder' | 'any';
interface ViewCapabilityRequirements {
src?: ViewSource;
mediaCapabilities?: CapabilitySearchKeys;
mediaCapabilitiesInclusive?: boolean;
}
const anyMedia: ViewCapabilityRequirements = {
src: 'any',
mediaCapabilities: {
anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'],
},
mediaCapabilitiesInclusive: false,
};
const anyFolder = { src: 'folder' as const };
const noRequirements = {};
const generateCameraRequirements = (
mediaCapabilities: CapabilitySearchKeys,
mediaCapabilitiesInclusive = true,
): ViewCapabilityRequirements => {
return {
src: 'camera',
mediaCapabilities,
mediaCapabilitiesInclusive,
};
};
const VIEW_REQUIREMENTS: Record<AdvancedCameraCardView, ViewCapabilityRequirements> = {
live: generateCameraRequirements('live', false),
clip: generateCameraRequirements('clips'),
clips: generateCameraRequirements('clips'),
snapshot: generateCameraRequirements('snapshots'),
snapshots: generateCameraRequirements('snapshots'),
recording: generateCameraRequirements('recordings'),
recordings: generateCameraRequirements('recordings'),
review: generateCameraRequirements('reviews'),
reviews: generateCameraRequirements('reviews'),
gallery: anyMedia,
media: anyMedia,
timeline: anyMedia,
folder: anyFolder,
folders: anyFolder,
image: noRequirements,
diagnostics: noRequirements,
};
export const isViewAvailable = (
view: AdvancedCameraCardView,
cameraManager: CameraManager,
foldersManager: FoldersManager,
): boolean => {
const req = VIEW_REQUIREMENTS[view];
const hasCameras = cameraManager.getStore().getCameraIDs().size > 0;
const hasFolders = foldersManager.hasFolders();
if (req.src === 'camera') {
return hasCameras;
}
if (req.src === 'folder') {
return hasFolders;
}
if (req.src === 'any') {
return hasCameras || hasFolders;
}
return true;
};
export const doesViewRequireCamera = (view: AdvancedCameraCardView): boolean => {
return VIEW_REQUIREMENTS[view].src === 'camera';
};
export const getCameraIDsWithCapabilityForView = (
viewName: AdvancedCameraCardView,
cameraManager: CameraManager,
foldersManager: FoldersManager,
cameraID?: string,
): Set<string> => {
const folder = foldersManager.getFolder();
const requirements = VIEW_REQUIREMENTS[viewName];
const allCameras = cameraManager.getStore().getCameraIDs();
switch (viewName) {
case 'diagnostics':
case 'image':
return cameraManager.getStore().getCameraIDs();
case 'folder':
case 'folders':
return folder ? cameraManager.getStore().getCameraIDs() : new Set();
case 'live':
case 'clip':
case 'clips':
case 'snapshot':
case 'snapshots':
case 'recording':
case 'recordings':
case 'review':
case 'reviews':
const options: CapabilitySearchOptions = {
inclusive: viewName !== 'live',
};
const capability =
viewName === 'clip'
? 'clips'
: viewName === 'snapshot'
? 'snapshots'
: viewName === 'recording'
? 'recordings'
: viewName === 'review'
? 'reviews'
: viewName;
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
case 'gallery':
case 'media':
case 'timeline':
return folder
? cameraManager.getStore().getCameraIDs()
: cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings', 'reviews'],
});
if (requirements.src !== 'camera' && requirements.src !== 'any') {
if (requirements.src === 'folder' && !foldersManager.hasFolders()) {
return new Set();
}
return allCameras;
}
if (requirements.src === 'any' && foldersManager.hasFolders()) {
return allCameras;
}
const options: CapabilitySearchOptions = {
inclusive: !!requirements.mediaCapabilitiesInclusive,
};
const capability = requirements.mediaCapabilities;
/* istanbul ignore next: this path is currently unreachable given the mapping
in VIEW_REQUIREMENTS includes mediaCapabilities for all camera or 'any'
related views -- @preserve */
if (!capability) {
return allCameras;
}
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(capability, options);
};
export const isViewSupportedByCamera = (
@@ -67,5 +123,23 @@ export const isViewSupportedByCamera = (
foldersManager: FoldersManager,
cameraID: string,
): boolean => {
return !!getCameraIDsForViewName(view, cameraManager, foldersManager, cameraID)?.size;
return !!getCameraIDsWithCapabilityForView(
view,
cameraManager,
foldersManager,
cameraID,
)?.size;
};
export const isViewSupported = (
viewName: AdvancedCameraCardView,
cameraManager: CameraManager,
foldersManager: FoldersManager,
cameraID?: string | null,
): boolean => {
return (
isViewAvailable(viewName, cameraManager, foldersManager) &&
(!cameraID ||
isViewSupportedByCamera(viewName, cameraManager, foldersManager, cameraID))
);
};
+3 -4
View File
@@ -16,7 +16,7 @@ declare module 'view' {
interface ViewEvolveParameters {
view?: AdvancedCameraCardView;
camera?: string;
camera?: string | null;
query?: UnifiedQuery | null;
queryResults?: QueryResults | null;
context?: ViewContext | null;
@@ -25,7 +25,6 @@ interface ViewEvolveParameters {
export interface ViewParameters extends ViewEvolveParameters {
view: AdvancedCameraCardView;
camera: string;
}
export const mergeViewContext = (
@@ -37,7 +36,7 @@ export const mergeViewContext = (
export class View {
public view: AdvancedCameraCardView;
public camera: string;
public camera: string | null;
public query: UnifiedQuery | null;
public queryResults: QueryResults | null;
public context: ViewContext | null;
@@ -45,7 +44,7 @@ export class View {
constructor(params: ViewParameters) {
this.view = params.view;
this.camera = params.camera;
this.camera = params.camera ?? null;
this.query = params.query ?? null;
this.queryResults = params.queryResults ?? null;
this.context = params.context ?? null;