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
+9 -1
View File
@@ -27,7 +27,7 @@ See [Advanced Installation](advanced-installation.md) for other installation res
## Initial configuration ## Initial configuration
### Minimal configuration ### Minimal camera configuration
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
@@ -35,6 +35,14 @@ cameras:
- camera_entity: camera.office - camera_entity: camera.office
``` ```
### Minimal folder configuration
```yaml
type: custom:advanced-camera-card
folders:
- type: ha
```
### Video scrubbing configuration ### Video scrubbing configuration
```yaml ```yaml
+19 -3
View File
@@ -2,7 +2,13 @@
The card supports a myriad of configuration options for simple or complex setups. The card supports a myriad of configuration options for simple or complex setups.
### Minimal configuration ### Absolute minimum configuration
```yaml
type: custom:advanced-camera-card
```
### Minimal camera configuration
```yaml ```yaml
type: custom:advanced-camera-card type: custom:advanced-camera-card
@@ -10,20 +16,30 @@ cameras:
- camera_entity: camera.office - camera_entity: camera.office
``` ```
### Minimal folder configuration
```yaml
type: custom:advanced-camera-card
folders:
- type: ha
```
### Configuration blocks ### Configuration blocks
#### Top-level configuration blocks #### Top-level configuration blocks
Only the `cameras` option is required, all other parameters are optional. No top-level option is required. If `cameras` is omitted the card will still
render and can operate in `image` or `folders` views depending on configuration.
| Option | Description | | Option | Description |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`automations`](automations.md) | Take action when conditions are met. | | [`automations`](automations.md) | Take action when conditions are met. |
| [`cameras`](cameras/README.md) | Configures the cameras to be used in the card. At least one camera must be specified. | | [`cameras`](cameras/README.md) | Configures the cameras to be used in the card. Optional. If present, the first listed camera is the default. |
| [`cameras_global`](cameras/README.md) | Global defaults that apply to all cameras from the `cameras` section. | | [`cameras_global`](cameras/README.md) | Global defaults that apply to all cameras from the `cameras` section. |
| `card_id` | An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via [URL actions](../usage/url-actions.md). Must exclusively consist of these characters: `[a-zA-Z0-9_]`. | | `card_id` | An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via [URL actions](../usage/url-actions.md). Must exclusively consist of these characters: `[a-zA-Z0-9_]`. |
| [`dimensions`](dimensions.md) | Configures the overall card dimensions. | | [`dimensions`](dimensions.md) | Configures the overall card dimensions. |
| [`elements`](elements/README.md) | Add custom elements to the card. | | [`elements`](elements/README.md) | Add custom elements to the card. |
| [`folders`](folders.md) | Configures folders to be used in the card. Optional. If present, the first listed camera is the default. |
| [`image`](image.md) | Configures the `image` view. | | [`image`](image.md) | Configures the `image` view. |
| [`live`](live.md) | Configures the `live` view. | | [`live`](live.md) | Configures the `live` view. |
| [`media_gallery`](media-gallery.md) | Configures the media gallery. | | [`media_gallery`](media-gallery.md) | Configures the media gallery. |
+1 -1
View File
@@ -1,6 +1,6 @@
# Cameras # Cameras
The `cameras` block configures a list of cameras the card should support. The first listed camera is the default. The `cameras` block configures a list of cameras the card should support. This block is optional. When present, the first listed camera is the default.
```yaml ```yaml
cameras: cameras:
+3 -3
View File
@@ -12,7 +12,7 @@ view:
| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. | | `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. |
| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. | | `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. |
| `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). | | `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). |
| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. | | `default` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (screensaver). The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. |
| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. | | `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. |
| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. | | `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. |
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. | | `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. |
@@ -155,7 +155,7 @@ This card supports several different views.
| `snapshots` | Shows a gallery of snapshots for this camera. | | `snapshots` | Shows a gallery of snapshots for this camera. |
| `timeline` | Shows an event timeline. | | `timeline` | Shows an event timeline. |
The default view is `live`, but can be configured by the `view.default` parameter. The default view is `auto`. It will select `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (screensaver). You can override this with `view.default`.
## Fully expanded reference ## Fully expanded reference
@@ -163,7 +163,7 @@ The default view is `live`, but can be configured by the `view.default` paramete
```yaml ```yaml
view: view:
default: live default: auto
camera_select: current camera_select: current
interaction_seconds: 300 interaction_seconds: 300
default_cycle_camera: false default_cycle_camera: false
+4 -4
View File
@@ -10,7 +10,7 @@ import {
PTZActionPhase, PTZActionPhase,
PTZPanTiltAction, PTZPanTiltAction,
} from '../config/schema/actions/custom/ptz.js'; } 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 { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { Endpoint } from '../types.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 // 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 // order, to ensure that the defaults in the cameras global config do not
// override the values specified in the per-camera config. // 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), recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
); );
@@ -186,7 +186,7 @@ export class CameraManager {
} }
protected async _getEnginesForCameras( protected async _getEnginesForCameras(
camerasConfig: CamerasConfig, camerasConfig: CameraConfig[],
): Promise<Map<CameraConfig, CameraManagerEngine>> { ): Promise<Map<CameraConfig, CameraManagerEngine>> {
const output: Map<CameraConfig, CameraManagerEngine> = new Map(); const output: Map<CameraConfig, CameraManagerEngine> = new Map();
const engines: Map<Engine, CameraManagerEngine> = new Map(); const engines: Map<Engine, CameraManagerEngine> = new Map();
@@ -228,7 +228,7 @@ export class CameraManager {
return output; return output;
} }
protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> { protected async _initializeCameras(camerasConfig: CameraConfig[]): Promise<void> {
const initializationStartTime = new Date(); const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
+2
View File
@@ -111,6 +111,8 @@ interface CapabilitySearchAllAny {
} }
export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny; export type CapabilitySearchKeys = CapabilityKey | CapabilitySearchAllAny;
export interface CapabilitySearchOptions { 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; inclusive?: boolean;
} }
@@ -8,13 +8,13 @@ export class EffectAction extends AdvancedCameraCardAction<EffectActionConfig> {
switch (this._action.effect_action) { switch (this._action.effect_action) {
case 'start': case 'start':
api.getEffectsControllerAPI()?.startEffect(this._action.effect); api.getEffectsManager().startEffect(this._action.effect);
break; break;
case 'stop': case 'stop':
api.getEffectsControllerAPI()?.stopEffect(this._action.effect); api.getEffectsManager().stopEffect(this._action.effect);
break; break;
case 'toggle': case 'toggle':
api.getEffectsControllerAPI()?.toggleEffect(this._action.effect); api.getEffectsManager().toggleEffect(this._action.effect);
break; break;
} }
} }
@@ -20,7 +20,10 @@ export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActio
const item = view?.queryResults?.getSelectedResult() ?? null; const item = view?.queryResults?.getSelectedResult() ?? null;
if (view?.is('live')) { 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)) { } else if (view?.isViewerView() && item && ViewItemClassifier.isMedia(item)) {
await mediaPlayerController.playMedia(mediaPlayer, item); await mediaPlayerController.playMedia(mediaPlayer, item);
} }
@@ -30,9 +30,10 @@ export class SetReviewAction extends AdvancedCameraCardAction<SetReviewActionCon
viewManager.getEpoch(), viewManager.getEpoch(),
getReviewedQueryFilterFromQuery(view?.query, item), getReviewedQueryFilterFromQuery(view?.query, item),
), ),
api api.getEffectsManager().startEffect('check', {
.getEffectsControllerAPI() duration: 0.4,
?.startEffect('check', { duration: 0.4, fadeIn: false }), fadeIn: false,
}),
]); ]);
// Trigger UI update to refresh menu icon state // 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 // When the camera changes, update the entity to match (only if different
// to avoid race conditions when multiple cards share the same entity). // to avoid race conditions when multiple cards share the same entity).
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2244 // See: https://github.com/dermotduffy/advanced-camera-card/issues/2244
createInternalCallbackAction((api: CardActionsAPI) => createInternalCallbackAction(async (api: CardActionsAPI) =>
selectOptionOnEntityIfDifferent( selectOptionOnEntityIfDifferent(
cameraControlEntity,
api.getViewManager().getView()?.camera,
api, api,
cameraControlEntity,
api.getViewManager().getView()?.camera ?? undefined,
), ),
), ),
], ],
@@ -76,13 +76,10 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
`{{ hass.states["${cameraControlEntity}"].state }}`, `{{ hass.states["${cameraControlEntity}"].state }}`,
) )
: // Set the selected option in the entity to the current camera ID. : // Set the selected option in the entity to the current camera ID.
createInternalCallbackAction((api: CardActionsAPI) => createInternalCallbackAction(async (api: CardActionsAPI) => {
selectOptionOnEntityIfDifferent( const camera = api.getViewManager().getView()?.camera ?? undefined;
cameraControlEntity, return selectOptionOnEntityIfDifferent(api, cameraControlEntity, camera);
api.getViewManager().getView()?.camera, }),
api,
),
),
], ],
tag: automationTag, tag: automationTag,
}, },
@@ -108,9 +105,9 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
}; };
const selectOptionOnEntityIfDifferent = async ( const selectOptionOnEntityIfDifferent = async (
entity: string,
option: string | undefined,
api: CardActionsAPI, api: CardActionsAPI,
entity: string,
option?: string,
): Promise<void> => { ): Promise<void> => {
const hass = api.getHASSManager().getHASS(); const hass = api.getHASSManager().getHASS();
const currentState = hass?.states[entity]?.state; const currentState = hass?.states[entity]?.state;
+4 -10
View File
@@ -1,5 +1,6 @@
import { ReactiveController } from 'lit'; import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { EffectsManager } from './effects/effects-manager';
import { ConditionStateManager } from '../conditions/state-manager'; import { ConditionStateManager } from '../conditions/state-manager';
import { AdvancedCameraCardConfig } from '../config/schema/types'; import { AdvancedCameraCardConfig } from '../config/schema/types';
import { DeviceRegistryManager } from '../ha/registry/device'; 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 { EntityCache, EntityRegistryManager } from '../ha/registry/entity/types';
import { ResolvedMediaCache } from '../ha/resolved-media'; import { ResolvedMediaCache } from '../ha/resolved-media';
import { LovelaceCardEditor } from '../ha/types'; import { LovelaceCardEditor } from '../ha/types';
import { EffectsControllerAPI } from '../types';
import { ActionsManager } from './actions/actions-manager'; import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager'; import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager'; import { CameraURLManager } from './camera-url-manager';
@@ -28,7 +28,6 @@ import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager'; import { InteractionManager } from './interaction-manager';
import { KeyboardStateManager } from './keyboard-state-manager'; import { KeyboardStateManager } from './keyboard-state-manager';
import { MediaLoadedInfoManager } from './media-info-manager'; import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager'; import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager'; import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager'; import { MicrophoneManager } from './microphone-manager';
@@ -67,8 +66,6 @@ import {
import { ViewItemManager } from './view/item-manager'; import { ViewItemManager } from './view/item-manager';
import { ViewManager } from './view/view-manager'; import { ViewManager } from './view/view-manager';
type EffectsControllerAPICallback = () => EffectsControllerAPI | null;
export class CardController export class CardController
implements implements
CardActionsManagerAPI, CardActionsManagerAPI,
@@ -97,9 +94,8 @@ export class CardController
CardViewAPI, CardViewAPI,
ReactiveController ReactiveController
{ {
protected _effectsControllerAPICallback: EffectsControllerAPICallback;
protected _conditionStateManager = new ConditionStateManager(); protected _conditionStateManager = new ConditionStateManager();
protected _effectsManager = new EffectsManager();
// These properties may be used in the construction of 'managers' (and should // These properties may be used in the construction of 'managers' (and should
// be created first). // be created first).
@@ -138,7 +134,6 @@ export class CardController
host: CardHTMLElement, host: CardHTMLElement,
scrollCallback: ScrollCallback, scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback, menuToggleCallback: MenuToggleCallback,
effectsControllerAPICallback: EffectsControllerAPICallback,
) { ) {
host.addController(this); host.addController(this);
@@ -148,7 +143,6 @@ export class CardController
scrollCallback, scrollCallback,
menuToggleCallback, menuToggleCallback,
); );
this._effectsControllerAPICallback = effectsControllerAPICallback;
} }
// ************************************************************************* // *************************************************************************
@@ -199,8 +193,8 @@ export class CardController
return this._deviceRegistryManager; return this._deviceRegistryManager;
} }
public getEffectsControllerAPI(): EffectsControllerAPI | null { public getEffectsManager(): EffectsManager {
return this._effectsControllerAPICallback(); return this._effectsManager;
} }
public getEntityRegistryManager(): EntityRegistryManager { public getEntityRegistryManager(): EntityRegistryManager {
@@ -1,4 +1,4 @@
import { EffectName, EffectsControllerAPI } from '../../types'; import { EffectName, EffectsContainer, EffectsManagerInterface } from '../../types';
import { Timer } from '../../utils/timer'; import { Timer } from '../../utils/timer';
import { EffectComponent, EffectModule, EffectOptions } from './types'; 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 _importedModules: Map<EffectName, EffectModule> = new Map();
private _activeInstances: Map<EffectName, EffectComponent | null> = new Map();
private _durationTimers: Map<EffectName, Timer> = 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._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> { public async startEffect(name: EffectName, options?: EffectOptions): Promise<void> {
if (!this._container || this._activeInstances.has(name)) { if (this._activeEffects.has(name)) {
return; return;
} }
// Reserve the slot immediately with null to prevent concurrent starts. // 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); const effectModule = await this._importEffectModule(name);
// Check if the effect was cancelled during loading. this._pendingEffects.delete(name);
if (!effectModule || !this._activeInstances.has(name)) {
this._activeInstances.delete(name); if (!effectModule || !this._activeEffects.has(name) || !this._container) {
this._activeEffects.delete(name);
return; return;
} }
const effectComponent = new effectModule.default(); const effectComponent = new effectModule.default();
effectComponent.fadeIn = options?.fadeIn ?? true; effectComponent.fadeIn = options?.fadeIn ?? true;
this._container.appendChild(effectComponent); this._container.appendChild(effectComponent);
this._activeInstances.set(name, effectComponent); this._activeEffects.set(name, effectComponent);
const duration = options?.duration; const duration = options?.duration;
if (duration !== undefined) { if (duration !== undefined) {
@@ -76,48 +163,10 @@ export class EffectsController implements EffectsControllerAPI {
} }
} }
public async stopEffect(effect: EffectName): Promise<void> { private _startPendingEffects(): void {
const timer = this._durationTimers.get(effect); for (const [name, options] of this._pendingEffects.entries()) {
if (timer) { this._pendingEffects.delete(name);
timer.stop(); this._startEffect(name, options);
this._durationTimers.delete(effect);
}
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, createGeneralAction,
createViewAction, createViewAction,
} from '../utils/action.js'; } from '../utils/action.js';
import { ViewParameters } from '../view/view';
import { CardQueryStringAPI } from './types'; import { CardQueryStringAPI } from './types';
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select'; import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
import { ViewParametersUserSpecified } from './view/types.js';
interface QueryStringViewIntent { interface QueryStringViewIntent {
view?: Partial<ViewParameters> & { view?: ViewParametersUserSpecified & {
default?: boolean; default?: boolean;
substream?: string; substream?: string;
}; };
@@ -43,8 +43,8 @@ export class StatusBarItemManager {
view?: View | null; view?: View | null;
mediaLoadedInfo?: MediaLoadedInfo | null; mediaLoadedInfo?: MediaLoadedInfo | null;
}): StatusBarItem[] { }): StatusBarItem[] {
const cameraMetadata = options?.view const cameraMetadata = options?.view?.camera
? options?.cameraManager?.getCameraMetadata(options?.view?.camera) ? options?.cameraManager?.getCameraMetadata(options.view.camera)
: null; : null;
const engineIcon = cameraMetadata?.engineIcon ?? null; const engineIcon = cameraMetadata?.engineIcon ?? null;
const selectedResult = options?.view?.queryResults?.getSelectedResult(); 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 { DeviceRegistryManager } from '../ha/registry/device';
import type { EntityRegistryManager } from '../ha/registry/entity/types'; import type { EntityRegistryManager } from '../ha/registry/entity/types';
import type { ResolvedMediaCache } from '../ha/resolved-media'; 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 { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager'; import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager'; import type { CameraURLManager } from './camera-url-manager';
@@ -44,7 +44,7 @@ export interface CardActionsAPI {
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getEffectsControllerAPI(): EffectsControllerAPI | null; getEffectsManager(): EffectsManagerInterface;
getExpandManager(): ExpandManager; getExpandManager(): ExpandManager;
getFoldersManager(): FoldersManager; getFoldersManager(): FoldersManager;
getFullscreenManager(): FullscreenManager; getFullscreenManager(): FullscreenManager;
+185 -105
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 { ViewDisplayMode } from '../../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../../config/schema/types'; import { AdvancedCameraCardConfig } from '../../config/schema/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { resolveViewName } from '../../view/utils/resolve-default';
import { View, ViewParameters } from '../../view/view'; import { View, ViewParameters } from '../../view/view';
import { import {
getCameraIDsForViewName, doesViewRequireCamera,
getCameraIDsWithCapabilityForView,
isViewSupportedByCamera, isViewSupportedByCamera,
} from '../../view/view-support'; } from '../../view/view-support';
import { CardViewAPI } from '../types'; import { CardViewAPI } from '../types';
import { applyViewModifiers } from './modifiers'; 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 { export class ViewFactory {
protected _api: CardViewAPI; protected _api: CardViewAPI;
@@ -24,18 +31,38 @@ export class ViewFactory {
return null; return null;
} }
// Neither options.baseView.camera nor options.baseView.view are respected const viewName = this._getDefaultViewName(config);
// here, since this is the default view / camera.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1564
let cameraID: string | null = null; return this.getViewByParameters({
const viewName = options?.params?.view ?? config.view.default; params: {
...options?.params,
view: viewName,
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) { if (options?.params?.camera) {
cameraID = options.params.camera; return options.params.camera;
} else { }
const cameraIDs = [ const cameraIDs = [
...getCameraIDsForViewName( ...getCameraIDsWithCapabilityForView(
viewName, viewName,
this._api.getCameraManager(), this._api.getCameraManager(),
this._api.getFoldersManager(), this._api.getFoldersManager(),
@@ -49,20 +76,10 @@ export class ViewFactory {
) { ) {
const currentIndex = cameraIDs.indexOf(options.baseView.camera); const currentIndex = cameraIDs.indexOf(options.baseView.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1; const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
cameraID = cameraIDs[targetIndex]; return cameraIDs[targetIndex];
} else {
cameraID = cameraIDs[0] ?? null;
}
} }
return this.getViewByParameters({ return cameraIDs[0] ?? null;
params: {
...options?.params,
view: viewName,
camera: cameraID,
},
baseView: options?.baseView,
});
} }
public getViewByParameters(options?: ViewFactoryOptions): View | null { public getViewByParameters(options?: ViewFactoryOptions): View | null {
@@ -71,83 +88,15 @@ export class ViewFactory {
return null; return null;
} }
let cameraID: string | null = let viewName = this._resolveViewName(config, options);
options?.params?.camera ?? options?.baseView?.camera ?? null; let cameraID = this._resolveCameraID(viewName, options);
let viewName = ({ viewName, cameraID } = this._ensureViewCompatibility(
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, 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, cameraID,
) config,
) { options,
if ( ));
options?.failSafe && const displayMode = this._resolveDisplayMode(viewName, config, options);
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';
const viewParameters: ViewParameters = { const viewParameters: ViewParameters = {
...options?.params, ...options?.params,
@@ -165,22 +114,153 @@ export class ViewFactory {
return view; 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, viewName: AdvancedCameraCardView,
config: AdvancedCameraCardConfig, config: AdvancedCameraCardConfig,
): ViewDisplayMode | null { ): ViewDisplayMode | null {
let mode: ViewDisplayMode | null = null;
switch (viewName) { switch (viewName) {
case 'media': case 'media':
case 'clip': case 'clip':
case 'recording': case 'recording':
case 'snapshot': case 'snapshot':
mode = config.media_viewer.display?.mode ?? null; return config.media_viewer.display?.mode ?? null;
break;
case 'live': case 'live':
mode = config.live.display?.mode ?? null; return config.live.display?.mode ?? null;
break; default:
return null;
} }
return mode;
} }
} }
@@ -15,6 +15,10 @@ export class SubstreamOnViewModifier implements ViewModifier {
} }
public modify(view: View): void { public modify(view: View): void {
if (!view.camera) {
return;
}
const dependencies = [ const dependencies = [
...this._api ...this._api
.getCameraManager() .getCameraManager()
@@ -27,6 +31,13 @@ export class SubstreamOnViewModifier implements ViewModifier {
} }
const currentOverride = getStreamCameraID(view); 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 currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; 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 { ViewItem } from '../../view/item.js';
import { QueryResults } from '../../view/query-results.js'; import { QueryResults } from '../../view/query-results.js';
import { AdvancedCameraCardUserSpecifiedView } from '../../config/schema/common/const.js';
import { View, ViewParameters } from '../../view/view.js'; import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier { export interface ViewModifier {
@@ -26,12 +27,16 @@ export interface QueryExecutorOptions {
useCache?: boolean; useCache?: boolean;
} }
export type ViewParametersUserSpecified = Partial<Omit<ViewParameters, 'view'>> & {
view?: AdvancedCameraCardUserSpecifiedView;
};
export interface ViewFactoryOptions { export interface ViewFactoryOptions {
// An existing view to evolve from. // An existing view to evolve from.
baseView?: View | null; baseView?: View | null;
// View parameters to set/evolve. // View parameters to set/evolve.
params?: Partial<ViewParameters>; params?: ViewParametersUserSpecified;
// Modifiers to the view once created. // Modifiers to the view once created.
modifiers?: ViewModifier[]; modifiers?: ViewModifier[];
@@ -70,5 +75,4 @@ export interface ViewManagerInterface {
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean; hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
} }
export class ViewNoCameraError extends AdvancedCameraCardError {}
export class ViewIncompatible extends AdvancedCameraCardError {} export class ViewIncompatible extends AdvancedCameraCardError {}
+1 -1
View File
@@ -317,7 +317,7 @@ export class ViewManager implements ViewManagerInterface {
this._api.getConditionStateManager()?.setState({ this._api.getConditionStateManager()?.setState({
view: view?.view, view: view?.view,
camera: view?.camera, camera: view?.camera ?? undefined,
displayMode: view?.displayMode ?? undefined, displayMode: view?.displayMode ?? undefined,
}); });
@@ -6,6 +6,7 @@ import { UnifiedQuery } from '../../view/unified-query';
import { UnifiedQueryBuilder } from '../../view/unified-query-builder'; import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
import { UnifiedQueryRunner } from '../../view/unified-query-runner'; import { UnifiedQueryRunner } from '../../view/unified-query-runner';
import { View } from '../../view/view'; import { View } from '../../view/view';
import { doesViewRequireCamera, isViewSupported } from '../../view/view-support';
import { CardViewAPI } from '../types'; import { CardViewAPI } from '../types';
import { MergeContextViewModifier } from './modifiers/merge-context'; import { MergeContextViewModifier } from './modifiers/merge-context';
import { RemoveContextPropertyViewModifier } from './modifiers/remove-context-property'; 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 getDefaultQueryModifiers = async () => {
const query = builder.buildDefaultCameraQuery(cameraForQuery, { 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 { CardController } from './card-controller/controller';
import { MenuButtonController } from './components-lib/menu-button-controller'; import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/effects/effects'; import './components/effects/effects';
import { AdvancedCameraCardEffects } from './components/effects/effects';
import './components/elements.js'; import './components/elements.js';
import { AdvancedCameraCardElements } from './components/elements.js'; import { AdvancedCameraCardElements } from './components/elements.js';
import './components/loading.js'; import './components/loading.js';
@@ -96,12 +95,10 @@ class AdvancedCameraCard extends LitElement {
// diagnostics starting at the top). // diagnostics starting at the top).
() => this._refMain.value?.scroll({ top: 0 }), () => this._refMain.value?.scroll({ top: 0 }),
() => this._refMenu.value?.toggleMenu(), () => this._refMenu.value?.toggleMenu(),
() => this._refEffects.value ?? null,
); );
protected _menuButtonController = new MenuButtonController(); protected _menuButtonController = new MenuButtonController();
protected _refEffects: Ref<AdvancedCameraCardEffects> = createRef();
protected _refElements: Ref<AdvancedCameraCardElements> = createRef(); protected _refElements: Ref<AdvancedCameraCardElements> = createRef();
protected _refMain: Ref<HTMLElement> = createRef(); protected _refMain: Ref<HTMLElement> = createRef();
protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef(); protected _refMenu: Ref<AdvancedCameraCardMenu> = createRef();
@@ -350,7 +347,7 @@ class AdvancedCameraCard extends LitElement {
// ensure the hover menu styling continues to work. // ensure the hover menu styling continues to work.
return this._renderInDialogIfNecessary( return this._renderInDialogIfNecessary(
html` <advanced-camera-card-effects html` <advanced-camera-card-effects
${ref(this._refEffects)} .effectsManager=${this._controller.getEffectsManager()}
></advanced-camera-card-effects> ></advanced-camera-card-effects>
<ha-card <ha-card
id="ha-card" id="ha-card"
@@ -387,9 +384,9 @@ class AdvancedCameraCard extends LitElement {
.loaded=${this._controller .loaded=${this._controller
.getInitializationManager() .getInitializationManager()
.wasEverInitialized()} .wasEverInitialized()}
.effectsControllerAPI=${this._config?.performance?.features .effectsManager=${this._config?.performance?.features
.card_loading_effects !== false .card_loading_effects !== false
? this._controller.getEffectsControllerAPI() ? this._controller.getEffectsManager()
: undefined} : undefined}
></advanced-camera-card-loading>` ></advanced-camera-card-loading>`
: ''} : ''}
+41 -32
View File
@@ -31,8 +31,12 @@ import { isBeingCasted } from '../utils/casting';
import { getPTZTarget } from '../utils/ptz'; import { getPTZTarget } from '../utils/ptz';
import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { getStreamCameraID, hasSubstream } from '../utils/substream';
import { ViewItemClassifier } from '../view/item-classifier'; import { ViewItemClassifier } from '../view/item-classifier';
import { resolveViewName } from '../view/utils/resolve-default';
import { View } from '../view/view'; import { View } from '../view/view';
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support'; import {
getCameraIDsWithCapabilityForView,
isViewSupported,
} from '../view/view-support';
export interface MenuButtonControllerOptions { export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null; currentMediaLoadedInfo?: MediaLoadedInfo | null;
@@ -112,7 +116,13 @@ export class MenuButtonController {
this._getFoldersButton(config, foldersManager, options?.view), this._getFoldersButton(config, foldersManager, options?.view),
...this._dynamicMenuButtons.map((button) => ({ ...this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, button, options), style: this._getStyleFromActions(
config,
cameraManager,
foldersManager,
button,
options,
),
...button, ...button,
})), })),
].filter(isTruthy); ].filter(isTruthy);
@@ -175,7 +185,7 @@ export class MenuButtonController {
cameraManager: CameraManager, cameraManager: CameraManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
if (!view) { if (!view?.camera) {
return null; return null;
} }
@@ -184,7 +194,7 @@ export class MenuButtonController {
.getAllDependentCameras(view.camera, 'substream'); .getAllDependentCameras(view.camera, 'substream');
if (substreamCameraIDs.size && view.is('live')) { if (substreamCameraIDs.size && view.is('live')) {
const substreams = [...substreamCameraIDs].filter( const substreams = Array.from(substreamCameraIDs).filter(
(cameraID) => cameraID !== view.camera, (cameraID) => cameraID !== view.camera,
); );
const streams = [view.camera, ...substreams]; const streams = [view.camera, ...substreams];
@@ -239,14 +249,13 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('live', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('live', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:cctv', icon: 'mdi:cctv',
...config.menu.buttons.live, ...config.menu.buttons.live,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.live'), title: localize('config.view.views.live'),
style: view.is('live') ? this._getEmphasizedStyle() : {}, style: view?.is('live') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('live'), tap_action: createViewAction('live'),
} }
: null; : null;
@@ -258,8 +267,7 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('clips', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
...config.menu.buttons.clips, ...config.menu.buttons.clips,
@@ -278,8 +286,7 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('snapshots', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:camera', icon: 'mdi:camera',
...config.menu.buttons.snapshots, ...config.menu.buttons.snapshots,
@@ -298,14 +305,13 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('recordings', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('recordings', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:album', icon: 'mdi:album',
...config.menu.buttons.recordings, ...config.menu.buttons.recordings,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.recordings'), title: localize('config.view.views.recordings'),
style: view.is('recordings') ? this._getEmphasizedStyle() : {}, style: view?.is('recordings') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('recordings'), tap_action: createViewAction('recordings'),
hold_action: createViewAction('recording'), hold_action: createViewAction('recording'),
} }
@@ -318,14 +324,13 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('reviews', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:play-box-edit-outline', icon: 'mdi:play-box-edit-outline',
...config.menu.buttons.reviews, ...config.menu.buttons.reviews,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.reviews'), title: localize('config.view.views.reviews'),
style: view.is('reviews') ? this._getEmphasizedStyle() : {}, style: view?.is('reviews') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('reviews'), tap_action: createViewAction('reviews'),
hold_action: createViewAction('review'), hold_action: createViewAction('review'),
} }
@@ -338,14 +343,13 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('gallery', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('gallery', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:play-box-multiple', icon: 'mdi:play-box-multiple',
...config.menu.buttons.gallery, ...config.menu.buttons.gallery,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.gallery'), title: localize('config.menu.buttons.gallery'),
style: view.is('gallery') ? this._getEmphasizedStyle() : {}, style: view?.is('gallery') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('gallery'), tap_action: createViewAction('gallery'),
hold_action: createViewAction('media'), hold_action: createViewAction('media'),
} }
@@ -358,8 +362,7 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('image', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('image', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:image', icon: 'mdi:image',
...config.menu.buttons.image, ...config.menu.buttons.image,
@@ -377,14 +380,13 @@ export class MenuButtonController {
foldersManager: FoldersManager, foldersManager: FoldersManager,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
return view && return isViewSupported('timeline', cameraManager, foldersManager, view?.camera)
isViewSupportedByCamera('timeline', cameraManager, foldersManager, view.camera)
? { ? {
icon: 'mdi:chart-gantt', icon: 'mdi:chart-gantt',
...config.menu.buttons.timeline, ...config.menu.buttons.timeline,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.timeline'), title: localize('config.view.views.timeline'),
style: view.is('timeline') ? this._getEmphasizedStyle() : {}, style: view?.is('timeline') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('timeline'), tap_action: createViewAction('timeline'),
} }
: null; : null;
@@ -479,11 +481,12 @@ export class MenuButtonController {
view?: View | null, view?: View | null,
microphoneManager?: MicrophoneManager | null, microphoneManager?: MicrophoneManager | null,
): MenuItem | null { ): MenuItem | null {
if (!view) { const streamCameraID = view ? getStreamCameraID(view) : null;
if (!streamCameraID) {
return null; return null;
} }
const capabilities = cameraManager.getCameraCapabilities(getStreamCameraID(view)); const capabilities = cameraManager.getCameraCapabilities(streamCameraID);
if (microphoneManager && capabilities?.has('2-way-audio')) { if (microphoneManager && capabilities?.has('2-way-audio')) {
const unavailable = const unavailable =
@@ -557,7 +560,9 @@ export class MenuButtonController {
if (!view) { if (!view) {
return null; return null;
} }
const selectedCameraConfig = cameraManager.getStore().getCameraConfig(view.camera); const selectedCameraConfig = view.camera
? cameraManager.getStore().getCameraConfig(view.camera)
: null;
if ( if (
mediaPlayerController?.hasMediaPlayers() && mediaPlayerController?.hasMediaPlayers() &&
(view.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity)) (view.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
@@ -659,7 +664,7 @@ export class MenuButtonController {
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
const viewCameraIDs = view const viewCameraIDs = view
? getCameraIDsForViewName(view.view, cameraManager, foldersManager) ? getCameraIDsWithCapabilityForView(view.view, cameraManager, foldersManager)
: null; : null;
if ( if (
view?.supportsMultipleDisplayModes() && view?.supportsMultipleDisplayModes() &&
@@ -754,8 +759,8 @@ export class MenuButtonController {
foldersManager?: FoldersManager | null, foldersManager?: FoldersManager | null,
view?: View | null, view?: View | null,
): MenuItem | null { ): MenuItem | null {
const folders = [...(foldersManager?.getFolders() ?? [])]; const folders = Array.from(foldersManager?.getFolders() ?? []);
if (!folders?.length) { if (!foldersManager?.hasFolders()) {
return null; return null;
} }
@@ -823,6 +828,8 @@ export class MenuButtonController {
*/ */
protected _getStyleFromActions( protected _getStyleFromActions(
config: AdvancedCameraCardConfig, config: AdvancedCameraCardConfig,
cameraManager: CameraManager,
foldersManager: FoldersManager,
button: MenuItem, button: MenuItem,
options?: MenuButtonControllerOptions, options?: MenuButtonControllerOptions,
): StyleInfo { ): StyleInfo {
@@ -848,7 +855,9 @@ export class MenuButtonController {
), ),
) || ) ||
(action.advanced_camera_card_action === 'default' && (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' && (action.advanced_camera_card_action === 'fullscreen' &&
!!options?.fullscreenManager?.isInFullscreen()) || !!options?.fullscreenManager?.isInFullscreen()) ||
(action.advanced_camera_card_action === 'camera_select' && (action.advanced_camera_card_action === 'camera_select' &&
+1 -1
View File
@@ -458,7 +458,7 @@ export class TimelineController {
main: true, main: true,
}, },
); );
} else if (panMode === 'seek-in-camera') { } else if (panMode === 'seek-in-camera' && view.camera) {
newResults = results newResults = results
.clone() .clone()
.selectBestResult( .selectBestResult(
+17 -20
View File
@@ -1,31 +1,28 @@
import { LitElement, unsafeCSS } from 'lit'; import { LitElement, PropertyValues, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { EffectsController } from '../../components-lib/effects/effects-controller'; import { EffectsManager } from '../../card-controller/effects/effects-manager';
import { EffectOptions } from '../../components-lib/effects/types';
import effectsStyle from '../../scss/effects.scss'; import effectsStyle from '../../scss/effects.scss';
import { EffectName, EffectsControllerAPI } from '../../types';
@customElement('advanced-camera-card-effects') @customElement('advanced-camera-card-effects')
export class AdvancedCameraCardEffects export class AdvancedCameraCardEffects extends LitElement {
extends LitElement @property({ attribute: false })
implements EffectsControllerAPI public effectsManager?: EffectsManager;
{
protected _controller = new EffectsController();
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> { protected willUpdate(changedProperties: PropertyValues): void {
await this._controller.startEffect(effect, options); if (changedProperties.has('effectsManager')) {
const previousManager: EffectsManager | undefined =
changedProperties.get('effectsManager');
previousManager?.removeContainer();
} }
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 updated(): void { protected updated(): void {
this._controller.setContainer(this.renderRoot); this.effectsManager?.setContainer(this.renderRoot);
}
public disconnectedCallback(): void {
this.effectsManager?.removeContainer();
super.disconnectedCallback();
} }
static get styles() { static get styles() {
+17 -1
View File
@@ -10,11 +10,13 @@ import { CameraConfig } from '../config/schema/cameras';
import { ImageViewConfig } from '../config/schema/image'; import { ImageViewConfig } from '../config/schema/image';
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const'; import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
import { HomeAssistant } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js'; import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
import './image-updating-player'; import './image-updating-player';
import { resolveImageMode } from './image-updating-player'; import { resolveImageMode } from './image-updating-player';
import './media-dimensions-container'; import './media-dimensions-container';
import { renderMessage } from './message.js';
import './zoomer.js'; import './zoomer.js';
@customElement('advanced-camera-card-image') @customElement('advanced-camera-card-image')
@@ -81,10 +83,24 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) { if (!this.hass) {
return; 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` return this._renderContainer(html`
<advanced-camera-card-image-updating-player <advanced-camera-card-image-updating-player
${ref(this._refImage)} ${ref(this._refImage)}
+4 -1
View File
@@ -109,7 +109,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live'); const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView(); const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view) { if (!cameraIDs?.size || !view?.camera) {
return 0; return 0;
} }
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera)); 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; const cameraID = this.viewFilterCameraID ?? view.camera;
if (!cameraID) {
return {};
}
const currentIndex = cameraIDs.indexOf(cameraID); const currentIndex = cameraIDs.indexOf(cameraID);
if (currentIndex < 0) { if (currentIndex < 0) {
+4 -4
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import loadingStyle from '../scss/loading.scss'; import loadingStyle from '../scss/loading.scss';
import { EffectName, EffectsControllerAPI } from '../types'; import { EffectName, EffectsManagerInterface } from '../types';
import { getReleaseVersion } from '../utils/diagnostics'; import { getReleaseVersion } from '../utils/diagnostics';
import './icon'; import './icon';
@@ -33,7 +33,7 @@ const getDateEffect = (): EffectName | null => {
@customElement('advanced-camera-card-loading') @customElement('advanced-camera-card-loading')
export class AdvancedCameraCardLoading extends LitElement { export class AdvancedCameraCardLoading extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public effectsControllerAPI?: EffectsControllerAPI | null; public effectsManager?: EffectsManagerInterface;
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
public loaded = false; public loaded = false;
@@ -60,13 +60,13 @@ export class AdvancedCameraCardLoading extends LitElement {
} }
private _startEffect(effect: EffectName): void { private _startEffect(effect: EffectName): void {
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false }); this.effectsManager?.startEffect(effect, { fadeIn: false });
this._effectName = effect; this._effectName = effect;
} }
private _stopEffect(): void { private _stopEffect(): void {
if (this._effectName) { if (this._effectName) {
this.effectsControllerAPI?.stopEffect(this._effectName); this.effectsManager?.stopEffect(this._effectName);
} }
this._effectName = null; this._effectName = null;
} }
+2 -2
View File
@@ -139,7 +139,7 @@ export class AdvancedCameraCardViews extends LitElement {
? this.config.media_viewer.controls.timeline ? this.config.media_viewer.controls.timeline
: undefined; : undefined;
const cameraConfig = view const cameraConfig = view?.camera
? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null ? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null; : null;
@@ -155,7 +155,7 @@ export class AdvancedCameraCardViews extends LitElement {
.viewItemManager=${this.viewItemManager} .viewItemManager=${this.viewItemManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
> >
${!this.hide && view?.is('image') && cameraConfig ${!this.hide && view?.is('image')
? html` <advanced-camera-card-image ? html` <advanced-camera-card-image
.imageConfig=${this.config.image} .imageConfig=${this.config.image}
.viewManagerEpoch=${this.viewManagerEpoch} .viewManagerEpoch=${this.viewManagerEpoch}
+1 -4
View File
@@ -329,7 +329,4 @@ export const cameraConfigSchema = z
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
// Avoid using .nonempty() to avoid changing the inferred type export const camerasConfigSchema = cameraConfigSchema.array().optional();
// (https://github.com/colinhacks/zod#minmaxlength).
export const camerasConfigSchema = cameraConfigSchema.array().min(1);
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
+4 -5
View File
@@ -8,10 +8,7 @@ export const STATUS_BAR_PRIORITY_MAX = 100;
export const BUTTON_SIZE_MIN = 20; export const BUTTON_SIZE_MIN = 20;
// The default view (may not be supported on all cameras). const VIEWS = [
export const VIEW_DEFAULT = 'live' as const;
export const VIEWS_USER_SPECIFIED = [
'diagnostics', 'diagnostics',
'live', 'live',
'clip', 'clip',
@@ -29,5 +26,7 @@ export const VIEWS_USER_SPECIFIED = [
'image', 'image',
'timeline', 'timeline',
] as const; ] 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 AdvancedCameraCardUserSpecifiedView = (typeof VIEWS_USER_SPECIFIED)[number];
export type AdvancedCameraCardView = AdvancedCameraCardUserSpecifiedView | 'diagnostics';
@@ -13,4 +13,3 @@ export const menuBaseSchema = z.object({
icon: z.string().optional(), icon: z.string().optional(),
permanent: z.boolean().default(false).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 { z } from 'zod';
import { actionsSchema } from './actions/types'; 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({ const keyboardShortcut = z.object({
key: z.string(), key: z.string(),
@@ -46,7 +46,7 @@ export type PTZKeyboardShortcutName =
| 'ptz_zoom_out'; | 'ptz_zoom_out';
export const viewConfigDefault = { export const viewConfigDefault = {
default: VIEW_DEFAULT, default: 'auto' as const,
camera_select: 'current' as const, camera_select: 'current' as const,
interaction_seconds: 300, interaction_seconds: 300,
default_reset: { default_reset: {
+1
View File
@@ -428,6 +428,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
protected _viewModes: EditorSelectOption[] = [ protected _viewModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'auto', label: localize('config.view.views.auto') },
{ value: 'clip', label: localize('config.view.views.clip') }, { value: 'clip', label: localize('config.view.views.clip') },
{ value: 'clips', label: localize('config.view.views.clips') }, { value: 'clips', label: localize('config.view.views.clips') },
{ value: 'folder', label: localize('config.view.views.folder') }, { value: 'folder', label: localize('config.view.views.folder') },
+4 -1
View File
@@ -6,7 +6,6 @@
"common": { "common": {
"advanced_camera_card": "Advanced Camera Card", "advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card",
"up": "Up",
"folder": "Folder", "folder": "Folder",
"live": "Live", "live": "Live",
"no_folder": "No folder to display", "no_folder": "No folder to display",
@@ -22,6 +21,7 @@
"medium": "Medium" "medium": "Medium"
}, },
"severity": "Severity", "severity": "Severity",
"up": "Up",
"version": "Version" "version": "Version"
}, },
"config": { "config": {
@@ -628,6 +628,7 @@
"untrigger_seconds": "Seconds after inactive state change to untrigger" "untrigger_seconds": "Seconds after inactive state change to untrigger"
}, },
"views": { "views": {
"auto": "Automatic",
"clip": "Most recent clip", "clip": "Most recent clip",
"clips": "Clips gallery", "clips": "Clips gallery",
"current": "Current view", "current": "Current view",
@@ -738,6 +739,8 @@
"no_camera_engine": "Could not determine suitable engine for camera", "no_camera_engine": "Could not determine suitable engine for camera",
"no_camera_entity": "Could not find camera entity", "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_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_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_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", "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 { 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 type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types';
import { Severity } from './severity'; import { Severity } from './severity';
@@ -217,8 +217,12 @@ export type EffectName =
| 'shamrocks' | 'shamrocks'
| 'snow'; | 'snow';
export interface EffectsControllerAPI { export type EffectsContainer = HTMLElement | DocumentFragment;
export interface EffectsManagerInterface {
startEffect(name: EffectName, options?: EffectOptions): Promise<void>; startEffect(name: EffectName, options?: EffectOptions): Promise<void>;
stopEffect(effect: EffectName): void; stopEffect(effect: EffectName): void;
toggleEffect(effect: EffectName, options?: EffectOptions): Promise<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 = ( export const findBestMediaTimeIndex = (
mediaArray: ViewItem[], mediaArray: ViewItem[],
targetTime: Date, targetTime: Date,
favorCameraID?: string, favorCameraID?: string | null,
): number | null => { ): number | null => {
let bestMatch: let bestMatch:
| { | {
+3
View File
@@ -28,6 +28,9 @@ export const getPTZTarget = (
}; };
} else if (view.is('live')) { } else if (view.is('live')) {
const substreamAwareCameraID = getStreamCameraID(view); const substreamAwareCameraID = getStreamCameraID(view);
if (!substreamAwareCameraID) {
return null;
}
let type: PTZType = 'digital'; let type: PTZType = 'digital';
if (options?.type !== 'digital' && options?.cameraManager) { if (options?.type !== 'digital' && options?.cameraManager) {
+2 -2
View File
@@ -28,14 +28,14 @@ const screenshotElement = (
export const generateScreenshotFilename = (view?: View | null): string => { export const generateScreenshotFilename = (view?: View | null): string => {
if (view?.is('live') || view?.is('image')) { if (view?.is('live') || view?.is('image')) {
return `${view.view}_${view.camera}_${format( return `${view.view}${view.camera ? `_${view.camera}` : ''}_${format(
new Date(), new Date(),
`yyyy-MM-dd-HH-mm-ss`, `yyyy-MM-dd-HH-mm-ss`,
)}.jpg`; )}.jpg`;
} else if (view?.isViewerView()) { } else if (view?.isViewerView()) {
const media = view.queryResults?.getSelectedResult(); const media = view.queryResults?.getSelectedResult();
const id = media?.getID() ?? null; 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'; return 'screenshot.jpg';
}; };
+22 -6
View File
@@ -1,18 +1,31 @@
import { View } from '../view/view'; import { View } from '../view/view';
export const getStreamCameraID = (view: View, cameraID?: string): string => { /**
return ( * Get the effective camera ID for streaming, considering substream overrides.
view.context?.live?.overrides?.get(cameraID ?? view.camera) ?? * Returns null if the view has no camera.
cameraID ?? */
view.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 => { export const hasSubstream = (view: View): boolean => {
if (!view.camera) {
return false;
}
return getStreamCameraID(view) !== view.camera; return getStreamCameraID(view) !== view.camera;
}; };
export const setSubstream = (view: View, substreamID: string): void => { export const setSubstream = (view: View, substreamID: string): void => {
if (!view.camera) {
return;
}
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map(); const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
overrides.set(view.camera, substreamID); overrides.set(view.camera, substreamID);
view.mergeInContext({ view.mergeInContext({
@@ -21,6 +34,9 @@ export const setSubstream = (view: View, substreamID: string): void => {
}; };
export const removeSubstream = (view: View): void => { export const removeSubstream = (view: View): void => {
if (!view.camera) {
return;
}
const overrides: Map<string, string> | undefined = view.context?.live?.overrides; const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) { if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(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';
};
+119 -45
View File
@@ -1,64 +1,120 @@
import { CameraManager } from '../camera-manager/manager'; 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 { FoldersManager } from '../card-controller/folders/manager';
import { AdvancedCameraCardView } from '../config/schema/common/const'; import { AdvancedCameraCardView } from '../config/schema/common/const';
/** type ViewSource = 'camera' | 'folder' | 'any';
* Get cameraIDs that are relevant for a given view name based on camera
* capability (if camera specified). interface ViewCapabilityRequirements {
*/ src?: ViewSource;
export const getCameraIDsForViewName = ( 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, viewName: AdvancedCameraCardView,
cameraManager: CameraManager, cameraManager: CameraManager,
foldersManager: FoldersManager, foldersManager: FoldersManager,
cameraID?: string, cameraID?: string,
): Set<string> => { ): Set<string> => {
const folder = foldersManager.getFolder(); const requirements = VIEW_REQUIREMENTS[viewName];
const allCameras = cameraManager.getStore().getCameraIDs();
switch (viewName) { if (requirements.src !== 'camera' && requirements.src !== 'any') {
case 'diagnostics': if (requirements.src === 'folder' && !foldersManager.hasFolders()) {
case 'image': return new Set();
return cameraManager.getStore().getCameraIDs(); }
return allCameras;
}
case 'folder': if (requirements.src === 'any' && foldersManager.hasFolders()) {
case 'folders': return allCameras;
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 = { const options: CapabilitySearchOptions = {
inclusive: viewName !== 'live', inclusive: !!requirements.mediaCapabilitiesInclusive,
}; };
const capability = const capability = requirements.mediaCapabilities;
viewName === 'clip'
? 'clips' /* istanbul ignore next: this path is currently unreachable given the mapping
: viewName === 'snapshot' in VIEW_REQUIREMENTS includes mediaCapabilities for all camera or 'any'
? 'snapshots' related views -- @preserve */
: viewName === 'recording' if (!capability) {
? 'recordings' return allCameras;
: viewName === 'review' }
? 'reviews'
: viewName;
return cameraID return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options) ? cameraManager.getStore().getAllDependentCameras(cameraID, capability, options)
: cameraManager.getStore().getCameraIDsWithCapability(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'],
});
}
}; };
export const isViewSupportedByCamera = ( export const isViewSupportedByCamera = (
@@ -67,5 +123,23 @@ export const isViewSupportedByCamera = (
foldersManager: FoldersManager, foldersManager: FoldersManager,
cameraID: string, cameraID: string,
): boolean => { ): 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 { interface ViewEvolveParameters {
view?: AdvancedCameraCardView; view?: AdvancedCameraCardView;
camera?: string; camera?: string | null;
query?: UnifiedQuery | null; query?: UnifiedQuery | null;
queryResults?: QueryResults | null; queryResults?: QueryResults | null;
context?: ViewContext | null; context?: ViewContext | null;
@@ -25,7 +25,6 @@ interface ViewEvolveParameters {
export interface ViewParameters extends ViewEvolveParameters { export interface ViewParameters extends ViewEvolveParameters {
view: AdvancedCameraCardView; view: AdvancedCameraCardView;
camera: string;
} }
export const mergeViewContext = ( export const mergeViewContext = (
@@ -37,7 +36,7 @@ export const mergeViewContext = (
export class View { export class View {
public view: AdvancedCameraCardView; public view: AdvancedCameraCardView;
public camera: string; public camera: string | null;
public query: UnifiedQuery | null; public query: UnifiedQuery | null;
public queryResults: QueryResults | null; public queryResults: QueryResults | null;
public context: ViewContext | null; public context: ViewContext | null;
@@ -45,7 +44,7 @@ export class View {
constructor(params: ViewParameters) { constructor(params: ViewParameters) {
this.view = params.view; this.view = params.view;
this.camera = params.camera; this.camera = params.camera ?? null;
this.query = params.query ?? null; this.query = params.query ?? null;
this.queryResults = params.queryResults ?? null; this.queryResults = params.queryResults ?? null;
this.context = params.context ?? null; this.context = params.context ?? null;
+16
View File
@@ -24,6 +24,7 @@ import {
import { CardController } from '../../src/card-controller/controller.js'; import { CardController } from '../../src/card-controller/controller.js';
import { sortItems } from '../../src/card-controller/view/sort.js'; import { sortItems } from '../../src/card-controller/view/sort.js';
import { CameraConfig } from '../../src/config/schema/cameras.js'; import { CameraConfig } from '../../src/config/schema/cameras.js';
import { advancedCameraCardConfigSchema } from '../../src/config/schema/types.js';
import { HomeAssistant } from '../../src/ha/types.js'; import { HomeAssistant } from '../../src/ha/types.js';
import { QuerySource } from '../../src/query-source.js'; import { QuerySource } from '../../src/query-source.js';
import { Endpoint, PTZMovementType } from '../../src/types.js'; import { Endpoint, PTZMovementType } from '../../src/types.js';
@@ -340,6 +341,21 @@ describe('CameraManager', () => {
expect(manager.getStore().getCameraCount()).toBe(0); expect(manager.getStore().getCameraCount()).toBe(0);
}); });
it('without cameras in config', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
advancedCameraCardConfigSchema.parse({
type: 'advanced-camera-card' as const,
}),
);
const manager = new CameraManager(api);
await manager.initializeCamerasFromConfig();
expect(manager.getStore().getCameraCount()).toBe(0);
});
it('without an id', async () => { it('without an id', async () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it } from 'vitest';
import { EffectAction } from '../../../../src/card-controller/actions/actions/effect'; import { EffectAction } from '../../../../src/card-controller/actions/actions/effect';
import { createEffectAction } from '../../../../src/utils/action'; import { createEffectAction } from '../../../../src/utils/action';
import { createCardAPI } from '../../../test-utils'; import { createCardAPI } from '../../../test-utils';
@@ -11,7 +11,7 @@ describe('EffectAction', () => {
await action.execute(api); await action.execute(api);
expect(api.getEffectsControllerAPI()?.startEffect).toHaveBeenCalledWith('snow'); expect(api.getEffectsManager().startEffect).toHaveBeenCalledWith('snow');
}); });
it('should call stopEffect when action is stop', async () => { it('should call stopEffect when action is stop', async () => {
@@ -21,7 +21,7 @@ describe('EffectAction', () => {
await action.execute(api); await action.execute(api);
expect(api.getEffectsControllerAPI()?.stopEffect).toHaveBeenCalledWith('snow'); expect(api.getEffectsManager().stopEffect).toHaveBeenCalledWith('snow');
}); });
it('should call toggleEffect when action is toggle', async () => { it('should call toggleEffect when action is toggle', async () => {
@@ -31,16 +31,7 @@ describe('EffectAction', () => {
await action.execute(api); await action.execute(api);
expect(api.getEffectsControllerAPI()?.toggleEffect).toHaveBeenCalledWith('snow'); expect(api.getEffectsManager().toggleEffect).toHaveBeenCalledWith('snow');
});
it('should not throw when effectsControllerAPI is null', async () => {
const api = createCardAPI();
vi.mocked(api.getEffectsControllerAPI).mockReturnValue(null);
const actionConfig = createEffectAction('snow', 'start');
const action = new EffectAction({}, actionConfig);
await expect(action.execute(api)).resolves.toBeUndefined();
}); });
it('should have a no-op stop method', async () => { it('should have a no-op stop method', async () => {
@@ -101,4 +101,28 @@ describe('should handle media_player action', () => {
expect(api.getMediaPlayerManager().playMedia).not.toBeCalled(); expect(api.getMediaPlayerManager().playMedia).not.toBeCalled();
}); });
it('to not play live without camera', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: null,
view: 'live',
}),
);
const action = new MediaPlayerAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'media_player',
media_player_action: 'play',
media_player: 'this_is_a_media_player',
},
);
await action.execute(api);
expect(api.getMediaPlayerManager().playLive).not.toBeCalled();
});
}); });
@@ -1,12 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { AutomationsManager } from '../../../src/card-controller/automations-manager';
import { ConfigManager } from '../../../src/card-controller/config/config-manager'; import { ConfigManager } from '../../../src/card-controller/config/config-manager';
import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; import { InitializationAspect } from '../../../src/card-controller/initialization-manager';
import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { ConditionStateManager } from '../../../src/conditions/state-manager';
import { AutomationsManager } from '../../../src/card-controller/automations-manager'; import { Automation } from '../../../src/config/schema/automations';
import { AdvancedCameraCardCondition } from '../../../src/config/schema/conditions/types';
import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types';
import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
import { createGeneralAction } from '../../../src/utils/action'; import { createGeneralAction } from '../../../src/utils/action';
import { createCardAPI, createConfig, flushPromises } from '../../test-utils';
/** /**
* Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. * Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager.
@@ -92,7 +94,7 @@ describe('ConfigManager', () => {
it('invalid configuration with hint', () => { it('invalid configuration with hint', () => {
const manager = new ConfigManager(createCardAPI()); const manager = new ConfigManager(createCardAPI());
expect(() => manager.setConfig({})).toThrowError( expect(() => manager.setConfig({})).toThrowError(
'Invalid configuration: [\n "cameras",\n "type"\n]', 'Invalid configuration: [\n "type"\n]',
); );
}); });
@@ -290,7 +292,7 @@ describe('ConfigManager', () => {
overrides: [ overrides: [
{ {
conditions: [TEST_CONDITIONS.FULLSCREEN_ON], conditions: [TEST_CONDITIONS.FULLSCREEN_ON],
delete: ['cameras'], delete: ['type'],
}, },
], ],
}; };
@@ -472,7 +474,7 @@ describe('ConfigManager', () => {
], ],
}); });
manager.setConfig(config as any); manager.setConfig(config);
await flushPromises(); await flushPromises();
// Verify keyboard shortcuts automations were added initially with ptz_home // Verify keyboard shortcuts automations were added initially with ptz_home
@@ -501,9 +503,10 @@ describe('ConfigManager', () => {
// This confirms the override removed them (directly verified through add calls) // This confirms the override removed them (directly verified through add calls)
const addCalls = addAutomationsSpy.mock.calls; const addCalls = addAutomationsSpy.mock.calls;
const hasKeyboardShortcut = addCalls.some((call) => const hasKeyboardShortcut = addCalls.some((call) =>
call[0].some((automation: any) => call[0].some((automation: Automation) =>
automation.conditions?.some( automation.conditions?.some(
(cond: any) => cond.condition === 'key' && cond.key === 'h', (cond: AdvancedCameraCardCondition) =>
cond.condition === 'key' && cond.key === 'h',
), ),
), ),
); );
@@ -524,7 +527,7 @@ describe('ConfigManager', () => {
], ],
}); });
manager.setConfig(config as any); manager.setConfig(config);
await flushPromises(); await flushPromises();
// Verify initial payload (folders may be populated with defaults, so // Verify initial payload (folders may be populated with defaults, so
@@ -594,7 +597,7 @@ describe('ConfigManager', () => {
], ],
}); });
manager.setConfig(config as any); manager.setConfig(config);
await flushPromises(); await flushPromises();
// Verify automations were added initially // Verify automations were added initially
@@ -652,7 +655,7 @@ describe('ConfigManager', () => {
}); });
// Initial set should register remote control automations // Initial set should register remote control automations
manager.setConfig(config as any); manager.setConfig(config);
await flushPromises(); await flushPromises();
// Verify remote-control automations were added initially with config condition // Verify remote-control automations were added initially with config condition
@@ -682,9 +685,9 @@ describe('ConfigManager', () => {
// Verify new automations don't contain remote-control config conditions // Verify new automations don't contain remote-control config conditions
const addCalls = addAutomationsSpy.mock.calls; const addCalls = addAutomationsSpy.mock.calls;
const hasRemoteControl = addCalls.some((call) => const hasRemoteControl = addCalls.some((call) =>
call[0].some((automation: any) => call[0].some((automation: Automation) =>
automation.conditions?.some( automation.conditions?.some(
(cond: any) => (cond: AdvancedCameraCardCondition) =>
cond.condition === 'config' && cond.condition === 'config' &&
cond.paths?.includes('remote_control.entities.camera'), cond.paths?.includes('remote_control.entities.camera'),
), ),
@@ -313,13 +313,16 @@ describe('setRemoteControlEntityFromConfig', () => {
}); });
describe('should select option on entity', () => { describe('should select option on entity', () => {
it('should select option when camera differs from entity state', () => { it('should select option when camera differs from entity state', async () => {
const hass = createHASS({ const hass = createHASS({
'input_select.camera': createStateEntity({ 'input_select.camera': createStateEntity({
state: 'camera.one', state: 'camera.one',
}), }),
}); });
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -349,7 +352,7 @@ describe('setRemoteControlEntityFromConfig', () => {
INTERNAL_CALLBACK_ACTION, INTERNAL_CALLBACK_ACTION,
); );
cameraSyncAction.callback(api); await cameraSyncAction.callback(api);
expect(hass.callService).toBeCalledWith( expect(hass.callService).toBeCalledWith(
'input_select', 'input_select',
'select_option', 'select_option',
@@ -369,6 +372,9 @@ describe('setRemoteControlEntityFromConfig', () => {
}), }),
}); });
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.one' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -441,7 +447,47 @@ describe('setRemoteControlEntityFromConfig', () => {
expect(hass.callService).not.toBeCalled(); expect(hass.callService).not.toBeCalled();
}); });
it('should not throw when hass is undefined', () => { it('should not select option when view exists but has no camera', () => {
const hass = createHASS({
'input_select.camera': createStateEntity({
state: 'camera.one',
}),
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
remote_control: {
entities: {
camera: 'input_select.camera',
camera_priority: 'card',
},
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: null,
view: 'live',
}),
);
setRemoteControlEntityFromConfig(api);
// Test the 'camera' condition callback (automation index 1)
const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock
.calls[0][0][1].actions?.[0] as InternalCallbackActionConfig;
cameraSyncAction.callback(api);
expect(hass.callService).not.toBeCalled();
// Also test the 'initialized' condition callback (automation index 2)
const initializedSyncAction = vi.mocked(api.getAutomationsManager().addAutomations)
.mock.calls[0][0][2].actions?.[0] as InternalCallbackActionConfig;
initializedSyncAction.callback(api);
expect(hass.callService).not.toBeCalled();
});
it('should not throw when hass is undefined', async () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
@@ -472,12 +518,15 @@ describe('setRemoteControlEntityFromConfig', () => {
); );
// Should not throw and obviously not call service (as hass is null) // Should not throw and obviously not call service (as hass is null)
cameraSyncAction.callback(api); await cameraSyncAction.callback(api);
}); });
it('should select option when entity state is undefined', () => { it('should select option when entity state is undefined', async () => {
const hass = createHASS({}); const hass = createHASS({});
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -506,7 +555,7 @@ describe('setRemoteControlEntityFromConfig', () => {
INTERNAL_CALLBACK_ACTION, INTERNAL_CALLBACK_ACTION,
); );
cameraSyncAction.callback(api); await cameraSyncAction.callback(api);
expect(hass.callService).toBeCalledWith( expect(hass.callService).toBeCalledWith(
'input_select', 'input_select',
'select_option', 'select_option',
@@ -519,13 +568,16 @@ describe('setRemoteControlEntityFromConfig', () => {
); );
}); });
it('should select option on initialization with card priority', () => { it('should select option on initialization with card priority', async () => {
const hass = createHASS({ const hass = createHASS({
'input_select.camera': createStateEntity({ 'input_select.camera': createStateEntity({
state: 'camera.one', state: 'camera.one',
}), }),
}); });
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.two' }]),
);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue( vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({ createConfig({
@@ -553,7 +605,7 @@ describe('setRemoteControlEntityFromConfig', () => {
expect(isAdvancedCameraCardCustomAction(initAction)).toBeTruthy(); expect(isAdvancedCameraCardCustomAction(initAction)).toBeTruthy();
expect(initAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION); expect(initAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION);
initAction.callback(api); await initAction.callback(api);
expect(hass.callService).toBeCalledWith( expect(hass.callService).toBeCalledWith(
'input_select', 'input_select',
'select_option', 'select_option',
+3 -12
View File
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../src/camera-manager/manager'; import { CameraManager } from '../../src/camera-manager/manager';
import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
import { AutomationsManager } from '../../src/card-controller/automations-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager';
@@ -34,7 +33,6 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
import { DeviceRegistryManager } from '../../src/ha/registry/device'; import { DeviceRegistryManager } from '../../src/ha/registry/device';
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity'; import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
import { ResolvedMediaCache } from '../../src/ha/resolved-media'; import { ResolvedMediaCache } from '../../src/ha/resolved-media';
import { EffectsControllerAPI } from '../../src/types';
vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager'); vi.mock('../../src/card-controller/actions/actions-manager');
@@ -74,7 +72,7 @@ const createCardElement = (): CardHTMLElement => {
}; };
const createController = (): CardController => { const createController = (): CardController => {
return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn()); return new CardController(createCardElement(), vi.fn(), vi.fn());
}; };
// @vitest-environment jsdom // @vitest-environment jsdom
@@ -87,15 +85,8 @@ describe('CardController', () => {
const element = createCardElement(); const element = createCardElement();
const scrollCallback = vi.fn(); const scrollCallback = vi.fn();
const menuToggleCallback = vi.fn(); const menuToggleCallback = vi.fn();
const effectsControllerAPI = mock<EffectsControllerAPI>();
const effectsControllerAPICallback = vi.fn().mockReturnValue(effectsControllerAPI);
const controller = new CardController( const controller = new CardController(element, scrollCallback, menuToggleCallback);
element,
scrollCallback,
menuToggleCallback,
effectsControllerAPICallback,
);
expect(CardElementManager).toBeCalledWith( expect(CardElementManager).toBeCalledWith(
controller, controller,
@@ -103,7 +94,7 @@ describe('CardController', () => {
scrollCallback, scrollCallback,
menuToggleCallback, menuToggleCallback,
); );
expect(controller.getEffectsControllerAPI()).toBe(effectsControllerAPI); expect(controller.getEffectsManager()).toBeTruthy();
}); });
describe('accessors', () => { describe('accessors', () => {
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EffectsController } from '../../../src/components-lib/effects/effects-controller'; import { EffectsManager } from '../../../src/card-controller/effects/effects-manager';
import { EffectComponent } from '../../../src/components-lib/effects/types'; import { EffectComponent } from '../../../src/card-controller/effects/types';
import { EffectName } from '../../../src/types'; import { EffectName } from '../../../src/types';
import { flushPromises } from '../../test-utils'; import { flushPromises } from '../../test-utils';
@@ -36,14 +36,14 @@ const createMockEffectComponent = (): EffectComponent => {
}; };
// @vitest-environment jsdom // @vitest-environment jsdom
describe('EffectsController', () => { describe('EffectsManager', () => {
let container: HTMLElement; let container: HTMLElement;
let controller: EffectsController; let manager: EffectsManager;
beforeEach(() => { beforeEach(() => {
container = document.createElement('div'); container = document.createElement('div');
controller = new EffectsController(); manager = new EffectsManager();
controller.setContainer(container); manager.setContainer(container);
vi.useFakeTimers(); vi.useFakeTimers();
}); });
@@ -56,7 +56,7 @@ describe('EffectsController', () => {
it('should load and start snow effect', async () => { it('should load and start snow effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('snow'); await manager.startEffect('snow');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
@@ -64,7 +64,7 @@ describe('EffectsController', () => {
it('should load and start hearts effect', async () => { it('should load and start hearts effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('hearts'); await manager.startEffect('hearts');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
@@ -72,7 +72,7 @@ describe('EffectsController', () => {
it('should load and start shamrocks effect', async () => { it('should load and start shamrocks effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('shamrocks'); await manager.startEffect('shamrocks');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
@@ -80,7 +80,7 @@ describe('EffectsController', () => {
it('should load and start fireworks effect', async () => { it('should load and start fireworks effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('fireworks'); await manager.startEffect('fireworks');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
@@ -88,7 +88,7 @@ describe('EffectsController', () => {
it('should load and start ghost effect', async () => { it('should load and start ghost effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('ghost'); await manager.startEffect('ghost');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
@@ -96,13 +96,13 @@ describe('EffectsController', () => {
it('should load and start check effect', async () => { it('should load and start check effect', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.startEffect('check'); await manager.startEffect('check');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('should stop effect automatically after duration', async () => { it('should stop effect automatically after duration', async () => {
const effectPromise = controller.startEffect('snow', { duration: 1 }); const effectPromise = manager.startEffect('snow', { duration: 1 });
// Wait for the effect to be added to the DOM. // Wait for the effect to be added to the DOM.
await flushPromises(); await flushPromises();
@@ -117,56 +117,61 @@ describe('EffectsController', () => {
it('should clean up timer when stopped manually', async () => { it('should clean up timer when stopped manually', async () => {
// Don't await startEffect as it waits for the duration // Don't await startEffect as it waits for the duration
controller.startEffect('snow', { duration: 10 }); manager.startEffect('snow', { duration: 10 });
// Wait for the effect to be added to the DOM. // Wait for the effect to be added to the DOM.
await flushPromises(); await flushPromises();
await controller.stopEffect('snow'); await manager.stopEffect('snow');
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
}); });
it('should set fadeIn to true by default', async () => { it('should set fadeIn to true by default', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
const effectElement = container.children[0] as EffectComponent; const effectElement = container.children[0] as EffectComponent;
expect(effectElement.fadeIn).toBe(true); expect(effectElement.fadeIn).toBe(true);
}); });
it('should set fadeIn from options', async () => { it('should set fadeIn from options', async () => {
await controller.startEffect('snow', { fadeIn: false }); await manager.startEffect('snow', { fadeIn: false });
const effectElement = container.children[0] as EffectComponent; const effectElement = container.children[0] as EffectComponent;
expect(effectElement.fadeIn).toBe(false); expect(effectElement.fadeIn).toBe(false);
}); });
it('should not start effect if container is null', async () => { it('should queue effects until a container is available', async () => {
controller.setContainer(null); manager.removeContainer();
await controller.startEffect('snow'); await manager.startEffect('snow');
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
manager.setContainer(container);
await flushPromises();
expect(container.children.length).toBe(1);
}); });
it('should not start effect if already active', async () => { it('should not start effect if already active', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
await controller.startEffect('snow'); await manager.startEffect('snow');
// Should only have one child since the second call is ignored. // Should only have one child since the second call is ignored.
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('should allow starting different effects simultaneously', async () => { it('should allow starting different effects simultaneously', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
await controller.startEffect('hearts'); await manager.startEffect('hearts');
expect(container.children.length).toBe(2); expect(container.children.length).toBe(2);
}); });
it('should return early if effect module cannot be loaded', async () => { it('should return early if effect module cannot be loaded', async () => {
// Use an effect name that doesn't exist in the registry // Use an effect name that doesn't exist in the registry
await controller.startEffect('unknown' as EffectName); await manager.startEffect('unknown' as EffectName);
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
}); });
@@ -174,10 +179,10 @@ describe('EffectsController', () => {
describe('stopEffect', () => { describe('stopEffect', () => {
it('should stop an active effect', async () => { it('should stop an active effect', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
const effectElement = container.children[0] as EffectComponent; const effectElement = container.children[0] as EffectComponent;
await controller.stopEffect('snow'); await manager.stopEffect('snow');
expect(effectElement.startFadeOut).toHaveBeenCalled(); expect(effectElement.startFadeOut).toHaveBeenCalled();
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
@@ -186,25 +191,25 @@ describe('EffectsController', () => {
it('should do nothing if effect is not active', async () => { it('should do nothing if effect is not active', async () => {
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
await controller.stopEffect('snow'); await manager.stopEffect('snow');
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
}); });
it('should allow starting effect again after stopping', async () => { it('should allow starting effect again after stopping', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
await controller.stopEffect('snow'); await manager.stopEffect('snow');
await controller.startEffect('snow'); await manager.startEffect('snow');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('should cancel effect when stopped during loading', async () => { it('should cancel effect when stopped during loading', async () => {
// Start the effect but don't await it - simulates the loading phase. // Start the effect but don't await it - simulates the loading phase.
const startPromise = controller.startEffect('snow'); const startPromise = manager.startEffect('snow');
// Stop the effect immediately while it's still loading. // Stop the effect immediately while it's still loading.
await controller.stopEffect('snow'); await manager.stopEffect('snow');
// Wait for start to complete. // Wait for start to complete.
await startPromise; await startPromise;
@@ -212,26 +217,48 @@ describe('EffectsController', () => {
// The effect should not appear since it was stopped during loading. // The effect should not appear since it was stopped during loading.
expect(container.children.length).toBe(0); expect(container.children.length).toBe(0);
}); });
it('should cancel queued effects', async () => {
manager.removeContainer();
await manager.startEffect('snow');
await manager.stopEffect('snow');
manager.setContainer(container);
await flushPromises();
expect(container.children.length).toBe(0);
});
it('should clear active effects and timers when container is removed', async () => {
manager.startEffect('snow', { duration: 10 });
await flushPromises();
expect(container.children.length).toBe(1);
manager.removeContainer();
expect(container.children.length).toBe(0);
});
}); });
describe('toggleEffect', () => { describe('toggleEffect', () => {
it('should start effect when not active', async () => { it('should start effect when not active', async () => {
await controller.toggleEffect('snow'); await manager.toggleEffect('snow');
expect(container.children.length).toBe(1); expect(container.children.length).toBe(1);
}); });
it('should stop effect when active', async () => { it('should stop effect when active', async () => {
await controller.startEffect('snow'); await manager.startEffect('snow');
const effectElement = container.children[0] as EffectComponent; const effectElement = container.children[0] as EffectComponent;
await controller.toggleEffect('snow'); await manager.toggleEffect('snow');
expect(effectElement.startFadeOut).toHaveBeenCalled(); expect(effectElement.startFadeOut).toHaveBeenCalled();
}); });
it('should pass options when starting effect', async () => { it('should pass options when starting effect', async () => {
await controller.toggleEffect('snow', { fadeIn: false }); await manager.toggleEffect('snow', { fadeIn: false });
const effectElement = container.children[0] as EffectComponent; const effectElement = container.children[0] as EffectComponent;
expect(effectElement.fadeIn).toBe(false); expect(effectElement.fadeIn).toBe(false);
@@ -239,10 +266,10 @@ describe('EffectsController', () => {
it('should cancel effect when toggled during loading', async () => { it('should cancel effect when toggled during loading', async () => {
// Start the effect but don't await it - simulates the loading phase. // Start the effect but don't await it - simulates the loading phase.
const startPromise = controller.startEffect('snow'); const startPromise = manager.startEffect('snow');
// Toggle the effect immediately while it's still loading. // Toggle the effect immediately while it's still loading.
await controller.toggleEffect('snow'); await manager.toggleEffect('snow');
// Wait for start to complete. // Wait for start to complete.
await startPromise; await startPromise;
+89 -3
View File
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { ViewFactory } from '../../../src/card-controller/view/factory'; import { ViewFactory } from '../../../src/card-controller/view/factory';
import { ViewModifier } from '../../../src/card-controller/view/types'; import { ViewModifier } from '../../../src/card-controller/view/types';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
@@ -9,6 +11,7 @@ import {
createCapabilities, createCapabilities,
createCardAPI, createCardAPI,
createConfig, createConfig,
createFolder,
createStore, createStore,
createView, createView,
} from '../../test-utils'; } from '../../test-utils';
@@ -20,7 +23,7 @@ describe('getViewDefault', () => {
expect(factory.getViewDefault()).toBeNull(); expect(factory.getViewDefault()).toBeNull();
}); });
it('should throw if no cameras support view', () => { it('should throw when no cameras support view', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
@@ -47,6 +50,23 @@ describe('getViewDefault', () => {
expect(() => factory.getViewDefault()).toThrowError(/No cameras support this view/); expect(() => factory.getViewDefault()).toThrowError(/No cameras support this view/);
}); });
it('should use folders view as default when folders exist without cameras', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getFoldersManager).mockReturnValue(mock<FoldersManager>());
vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true);
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(createFolder());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore([]));
const factory = new ViewFactory(api);
const view = factory.getViewDefault();
expect(view?.view).toBe('folders');
expect(view?.camera).toBeNull();
});
it('should create view', () => { it('should create view', () => {
const factory = new ViewFactory(createPopulatedAPI()); const factory = new ViewFactory(createPopulatedAPI());
const view = factory.getViewDefault(); const view = factory.getViewDefault();
@@ -217,8 +237,6 @@ describe('getViewByParameters', () => {
const factory = new ViewFactory(api); const factory = new ViewFactory(api);
expect(() => expect(() =>
factory.getViewByParameters({ factory.getViewByParameters({
// Since no camera is specified, and no camera supports the capabilities
// necessary for this view, the view will be null.
params: { params: {
view: 'snapshots', view: 'snapshots',
}, },
@@ -226,6 +244,56 @@ describe('getViewByParameters', () => {
).toThrowError(/No cameras support this view/); ).toThrowError(/No cameras support this view/);
}); });
describe('should handle no camera for view with failsafe', () => {
it('should choose default view when it does not require a camera', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore([]));
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default: 'diagnostics',
},
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
view: 'live',
},
failSafe: true,
});
expect(view?.is('diagnostics')).toBeTruthy();
expect(view?.camera).toBeNull();
});
it('should choose default view and camera when it requires a camera', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([{ cameraID: 'camera.one' }]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default: 'live',
},
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
view: 'snapshots',
},
failSafe: true,
});
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.one');
});
});
describe('should handle unsupported view', () => { describe('should handle unsupported view', () => {
it('should throw without failsafe', () => { it('should throw without failsafe', () => {
const api = createCardAPI(); const api = createCardAPI();
@@ -285,6 +353,24 @@ describe('getViewByParameters', () => {
}); });
expect(view?.is('live')).toBeTruthy(); expect(view?.is('live')).toBeTruthy();
}); });
it('should throw and omit capabilities when camera object is missing from store', () => {
const api = createCardAPI();
const store = createStore([{ cameraID: 'camera.one' }]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.spyOn(store, 'getCamera').mockReturnValue(null);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const factory = new ViewFactory(api);
expect(() =>
factory.getViewByParameters({
params: {
camera: 'camera.one',
view: 'snapshots',
},
}),
).toThrowError(/The selected camera or media does not support this view/);
});
}); });
it('should call modifiers', () => { it('should call modifiers', () => {
@@ -21,9 +21,7 @@ const createAPIWithSubstreams = (
config?: RawAdvancedCameraCardConfig, config?: RawAdvancedCameraCardConfig,
): CardController => { ): CardController => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); const store = createStore([
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{ {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: createCapabilities({ capabilities: createCapabilities({
@@ -42,8 +40,8 @@ const createAPIWithSubstreams = (
substream: true, substream: true,
}), }),
}, },
]), ]);
); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api; return api;
}; };
@@ -97,11 +95,25 @@ describe('should turn on substream', () => {
expect(hasSubstream(view)).toBe(false); expect(hasSubstream(view)).toBe(false);
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager()); const cameraManager = createCameraManager(createStore([]));
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
const modifier = new SubstreamOnViewModifier(api); const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view); modifier.modify(view);
expect(hasSubstream(view)).toBe(false); expect(hasSubstream(view)).toBe(false);
}); });
it('without camera', () => {
const view = createView({
camera: null,
view: 'live',
});
const api = createCardAPI();
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
});
}); });
@@ -257,7 +257,7 @@ describe('ViewQueryExecutor', () => {
const store = createStore([ const store = createStore([
{ {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: createCapabilities({ [mode]: true }), capabilities: createCapabilities({ live: true, [mode]: true }),
config: createCameraConfig({ config: createCameraConfig({
media: { media: {
type: mode === 'reviews' ? 'reviews' : 'events', type: mode === 'reviews' ? 'reviews' : 'events',
@@ -546,6 +546,34 @@ describe('ViewQueryExecutor', () => {
expect(view.query).toBeNull(); expect(view.query).toBeNull();
expect(view.queryResults).toBeNull(); expect(view.queryResults).toBeNull();
}); });
it('should handle non-grid view without a camera', async () => {
const api = createPopulatedAPI();
const viewQueryExecutor = new ViewQueryExecutor(api);
const view = createView({
view: 'live',
camera: null,
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view.query).toBeNull();
});
it('should handle diagnostics view without a camera', async () => {
const api = createPopulatedAPI();
const viewQueryExecutor = new ViewQueryExecutor(api);
const view = createView({
view: 'diagnostics',
camera: null,
});
const modifiers = await viewQueryExecutor.getNewQueryModifiers(view);
applyViewModifiers(view, modifiers);
expect(view.query).toBeNull();
});
}); });
describe('with a timeline view', () => { describe('with a timeline view', () => {
@@ -799,6 +827,7 @@ describe('ViewQueryExecutor', () => {
'should execute default folder query with %s view', 'should execute default folder query with %s view',
async (viewName: AdvancedCameraCardView) => { async (viewName: AdvancedCameraCardView) => {
const api = createPopulatedAPI(); const api = createPopulatedAPI();
vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true);
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue({ vi.mocked(api.getFoldersManager().getFolder).mockReturnValue({
type: 'ha', type: 'ha',
id: 'office', id: 'office',
@@ -827,6 +856,7 @@ describe('ViewQueryExecutor', () => {
it('should execute default folder query with folder view and handle null results', async () => { it('should execute default folder query with folder view and handle null results', async () => {
const api = createPopulatedAPI(); const api = createPopulatedAPI();
vi.mocked(api.getFoldersManager().hasFolders).mockReturnValue(true);
vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null); vi.mocked(api.getFoldersManager().getFolder).mockReturnValue(null);
vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue( vi.mocked(api.getFoldersManager().getDefaultQueryParameters).mockReturnValue(
null, null,
@@ -26,8 +26,8 @@ import { ViewMedia, ViewMediaType } from '../../src/view/item.js';
import { QueryResults } from '../../src/view/query-results.js'; import { QueryResults } from '../../src/view/query-results.js';
import { UnifiedQuery } from '../../src/view/unified-query.js'; import { UnifiedQuery } from '../../src/view/unified-query.js';
import { import {
getCameraIDsForViewName, getCameraIDsWithCapabilityForView,
isViewSupportedByCamera, isViewSupported,
} from '../../src/view/view-support.js'; } from '../../src/view/view-support.js';
import { View } from '../../src/view/view.js'; import { View } from '../../src/view/view.js';
import { import {
@@ -89,6 +89,7 @@ describe('MenuButtonController', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(isViewSupported).mockReturnValue(true);
controller = new MenuButtonController(); controller = new MenuButtonController();
}); });
@@ -507,7 +508,7 @@ describe('MenuButtonController', () => {
describe('should have live menu button', () => { describe('should have live menu button', () => {
it('when in live view', () => { it('when in live view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }), view: createView({ view: 'live' }),
viewManager: viewManager, viewManager: viewManager,
@@ -526,7 +527,7 @@ describe('MenuButtonController', () => {
it('when not in live view', () => { it('when not in live view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }), view: createView({ view: 'clips' }),
viewManager: viewManager, viewManager: viewManager,
@@ -545,7 +546,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -561,7 +562,7 @@ describe('MenuButtonController', () => {
describe('should have clips menu button', () => { describe('should have clips menu button', () => {
it('when in clips view', () => { it('when in clips view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'clips' }), view: createView({ view: 'clips' }),
viewManager: viewManager, viewManager: viewManager,
@@ -581,7 +582,7 @@ describe('MenuButtonController', () => {
it('when not in clips view', () => { it('when not in clips view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -600,7 +601,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -614,9 +615,7 @@ describe('MenuButtonController', () => {
describe('should have snapshots menu button', () => { describe('should have snapshots menu button', () => {
it('when in snapshots view', () => { it('when in snapshots view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockImplementation( vi.mocked(isViewSupported).mockImplementation((view) => view !== 'reviews');
(view) => view !== 'reviews',
);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'snapshots' }), view: createView({ view: 'snapshots' }),
viewManager: viewManager, viewManager: viewManager,
@@ -642,7 +641,7 @@ describe('MenuButtonController', () => {
it('when not in snapshots view', () => { it('when not in snapshots view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -667,7 +666,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -683,7 +682,7 @@ describe('MenuButtonController', () => {
describe('should have reviews menu button', () => { describe('should have reviews menu button', () => {
it('when in reviews view', () => { it('when in reviews view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'reviews' }), view: createView({ view: 'reviews' }),
viewManager: viewManager, viewManager: viewManager,
@@ -709,7 +708,7 @@ describe('MenuButtonController', () => {
it('when not in reviews view', () => { it('when not in reviews view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -734,7 +733,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -748,7 +747,7 @@ describe('MenuButtonController', () => {
describe('should have gallery menu button', () => { describe('should have gallery menu button', () => {
it('when in gallery view', () => { it('when in gallery view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'gallery' }), view: createView({ view: 'gallery' }),
viewManager: viewManager, viewManager: viewManager,
@@ -774,7 +773,7 @@ describe('MenuButtonController', () => {
it('when not in gallery view', () => { it('when not in gallery view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -799,7 +798,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -813,7 +812,7 @@ describe('MenuButtonController', () => {
describe('should have recordings menu button', () => { describe('should have recordings menu button', () => {
it('when in recordings view', () => { it('when in recordings view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'recordings' }), view: createView({ view: 'recordings' }),
viewManager: viewManager, viewManager: viewManager,
@@ -839,7 +838,7 @@ describe('MenuButtonController', () => {
it('when not in recordings view', () => { it('when not in recordings view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -864,7 +863,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -880,7 +879,7 @@ describe('MenuButtonController', () => {
describe('should have image menu button', () => { describe('should have image menu button', () => {
it('when in image view', () => { it('when in image view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'image' }), view: createView({ view: 'image' }),
@@ -900,7 +899,7 @@ describe('MenuButtonController', () => {
it('when not in image view', () => { it('when not in image view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }), view: createView({ view: 'live' }),
@@ -920,7 +919,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
viewManager: viewManager, viewManager: viewManager,
}); });
@@ -936,7 +935,7 @@ describe('MenuButtonController', () => {
describe('should have timeline button', () => { describe('should have timeline button', () => {
it('when in timeline view', () => { it('when in timeline view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'timeline' }), view: createView({ view: 'timeline' }),
viewManager: viewManager, viewManager: viewManager,
@@ -958,7 +957,7 @@ describe('MenuButtonController', () => {
it('when not in timeline view', () => { it('when not in timeline view', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(true); vi.mocked(isViewSupported).mockReturnValue(true);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }), view: createView({ view: 'live' }),
viewManager: viewManager, viewManager: viewManager,
@@ -980,7 +979,7 @@ describe('MenuButtonController', () => {
it('when not supported', () => { it('when not supported', () => {
const viewManager = mock<ViewManager>(); const viewManager = mock<ViewManager>();
vi.mocked(isViewSupportedByCamera).mockReturnValue(false); vi.mocked(isViewSupported).mockReturnValue(false);
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
view: createView({ view: 'live' }), view: createView({ view: 'live' }),
viewManager: viewManager, viewManager: viewManager,
@@ -1640,7 +1639,7 @@ describe('MenuButtonController', () => {
]), ]),
); );
vi.mocked(getCameraIDsForViewName).mockReturnValue( vi.mocked(getCameraIDsWithCapabilityForView).mockReturnValue(
new Set(['camera-1', 'camera-2']), new Set(['camera-1', 'camera-2']),
); );
@@ -1928,6 +1927,7 @@ describe('MenuButtonController', () => {
describe('should have folders button', () => { describe('should have folders button', () => {
it('should have no folders button without folders', () => { it('should have no folders button without folders', () => {
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
foldersManager.hasFolders.mockReturnValue(true);
foldersManager.getFolders.mockReturnValue(new Map().entries()); foldersManager.getFolders.mockReturnValue(new Map().entries());
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
@@ -1945,6 +1945,7 @@ describe('MenuButtonController', () => {
it('should have a folders button for a single folder outside the folder view', () => { it('should have a folders button for a single folder outside the folder view', () => {
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
foldersManager.hasFolders.mockReturnValue(true);
foldersManager.getFolders.mockReturnValue( foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(), new Map([['folder-0', createFolder({ id: 'folder-0' })]]).entries(),
); );
@@ -1968,6 +1969,7 @@ describe('MenuButtonController', () => {
it('should have a folders button for a single folder inside the folder view', () => { it('should have a folders button for a single folder inside the folder view', () => {
const folder = createFolder({ id: 'folder-0' }); const folder = createFolder({ id: 'folder-0' });
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
foldersManager.hasFolders.mockReturnValue(true);
foldersManager.getFolders.mockReturnValue( foldersManager.getFolders.mockReturnValue(
new Map([['folder-0', folder]]).entries(), new Map([['folder-0', folder]]).entries(),
); );
@@ -2006,6 +2008,7 @@ describe('MenuButtonController', () => {
['folder-0', createFolder({ id: 'folder-0' })], ['folder-0', createFolder({ id: 'folder-0' })],
['folder-selected', selectedFolder], ['folder-selected', selectedFolder],
]); ]);
foldersManager.hasFolders.mockReturnValue(true);
foldersManager.getFolders.mockReturnValue(folders.entries()); foldersManager.getFolders.mockReturnValue(folders.entries());
const selectedFolderNode: FolderQuery = { const selectedFolderNode: FolderQuery = {
@@ -2079,6 +2082,7 @@ describe('MenuButtonController', () => {
['folder-0', createFolder({ id: 'folder-0' })], ['folder-0', createFolder({ id: 'folder-0' })],
['folder-1', createFolder({ id: 'folder-1' })], ['folder-1', createFolder({ id: 'folder-1' })],
]); ]);
foldersManager.hasFolders.mockReturnValue(true);
foldersManager.getFolders.mockReturnValue(folders.entries()); foldersManager.getFolders.mockReturnValue(folders.entries());
const buttons = calculateButtons(controller, { const buttons = calculateButtons(controller, {
@@ -2202,9 +2206,10 @@ describe('MenuButtonController', () => {
...dynamicButton, ...dynamicButton,
tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'default' }, tap_action: { action: 'fire-dom-event', advanced_camera_card_action: 'default' },
}; };
const cameraManager = createCameraManager(createStore([{ cameraID: 'camera-1' }]));
controller.addDynamicMenuButton(button); controller.addDynamicMenuButton(button);
expect(calculateButtons(controller)).toContainEqual({ expect(calculateButtons(controller, { cameraManager })).toContainEqual({
...button, ...button,
style: { color: 'var(--advanced-camera-card-menu-button-active-color)' }, style: { color: 'var(--advanced-camera-card-menu-button-active-color)' },
}); });
@@ -2346,4 +2351,12 @@ describe('MenuButtonController', () => {
); );
}); });
}); });
it('should handle view without camera', () => {
const buttons = calculateButtons(controller, {
view: createView({ camera: null }),
});
expect(buttons).toBeDefined();
});
}); });
+1 -1
View File
@@ -378,7 +378,7 @@ describe('config defaults', () => {
type: 'advanced-camera-card', type: 'advanced-camera-card',
view: { view: {
camera_select: 'current', camera_select: 'current',
default: 'live', default: 'auto',
keyboard_shortcuts: { keyboard_shortcuts: {
enabled: true, enabled: true,
ptz_down: { ptz_down: {
+3 -7
View File
@@ -28,6 +28,7 @@ import { CardElementManager } from '../src/card-controller/card-element-manager'
import { ConfigManager } from '../src/card-controller/config/config-manager'; import { ConfigManager } from '../src/card-controller/config/config-manager';
import { CardController } from '../src/card-controller/controller'; import { CardController } from '../src/card-controller/controller';
import { DefaultManager } from '../src/card-controller/default-manager'; import { DefaultManager } from '../src/card-controller/default-manager';
import { EffectsManager } from '../src/card-controller/effects/effects-manager';
import { ExpandManager } from '../src/card-controller/expand-manager'; import { ExpandManager } from '../src/card-controller/expand-manager';
import { FoldersManager } from '../src/card-controller/folders/manager'; import { FoldersManager } from '../src/card-controller/folders/manager';
import { FolderQuery } from '../src/card-controller/folders/types'; import { FolderQuery } from '../src/card-controller/folders/types';
@@ -71,12 +72,7 @@ import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types'; import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
import { QuerySource } from '../src/query-source'; import { QuerySource } from '../src/query-source';
import { Severity } from '../src/severity'; import { Severity } from '../src/severity';
import { import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
CapabilitiesRaw,
EffectsControllerAPI,
Interaction,
MediaLoadedInfo,
} from '../src/types';
import { import {
EventViewMedia, EventViewMedia,
ReviewViewMedia, ReviewViewMedia,
@@ -658,7 +654,7 @@ export const createCardAPI = (): CardController => {
api.getCardElementManager.mockReturnValue(mock<CardElementManager>()); api.getCardElementManager.mockReturnValue(mock<CardElementManager>());
api.getConditionStateManager.mockReturnValue(mock<ConditionStateManager>()); api.getConditionStateManager.mockReturnValue(mock<ConditionStateManager>());
api.getConfigManager.mockReturnValue(mock<ConfigManager>()); api.getConfigManager.mockReturnValue(mock<ConfigManager>());
api.getEffectsControllerAPI.mockReturnValue(mock<EffectsControllerAPI>()); api.getEffectsManager.mockReturnValue(mock<EffectsManager>());
api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>()); api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>());
api.getExpandManager.mockReturnValue(mock<ExpandManager>()); api.getExpandManager.mockReturnValue(mock<ExpandManager>());
api.getFoldersManager.mockReturnValue(mock<FoldersManager>()); api.getFoldersManager.mockReturnValue(mock<FoldersManager>());
+8
View File
@@ -120,6 +120,14 @@ describe('getPTZTarget', () => {
type: 'digital', type: 'digital',
}); });
}); });
it('without cameraID', () => {
const view = createView({
view: 'live',
camera: null,
});
expect(getPTZTarget(view, { cameraManager: createCameraManager() })).toBeNull();
});
}); });
describe('in non-media views', () => { describe('in non-media views', () => {
+12
View File
@@ -123,4 +123,16 @@ describe('generateScreenshotTitle', () => {
expect(generateScreenshotFilename(view)).toBe('media_camera-1.jpg'); expect(generateScreenshotFilename(view)).toBe('media_camera-1.jpg');
}); });
it('should get title for live view without camera', () => {
expect(generateScreenshotFilename(createView({ view: 'live', camera: null }))).toBe(
'live_2023-06-13-21-54-01.jpg',
);
});
it('should get title for viewer view without camera', () => {
expect(generateScreenshotFilename(createView({ view: 'media', camera: null }))).toBe(
'media.jpg',
);
});
}); });
+28
View File
@@ -3,6 +3,7 @@ import {
getStreamCameraID, getStreamCameraID,
hasSubstream, hasSubstream,
removeSubstream, removeSubstream,
setSubstream,
} from '../../src/utils/substream'; } from '../../src/utils/substream';
import { View } from '../../src/view/view'; import { View } from '../../src/view/view';
import { createView } from '../test-utils'; import { createView } from '../test-utils';
@@ -62,6 +63,25 @@ describe('hasSubstream/getStreamCameraID', () => {
expect(getStreamCameraID(view, 'camera3')).toBe('camera3'); expect(getStreamCameraID(view, 'camera3')).toBe('camera3');
}); });
}); });
it('should correctly handle null cameras', () => {
expect(getStreamCameraID(createView({ camera: null }))).toBeNull();
expect(hasSubstream(createView({ camera: null }))).toBeFalsy();
});
});
describe('setSubstream', () => {
it('should set substream', () => {
const view = createView({ camera: 'camera1' });
setSubstream(view, 'substream1');
expect(view.context?.live?.overrides?.get('camera1')).toBe('substream1');
});
it('should return null without a camera', () => {
const view = createView({ camera: null });
setSubstream(view, 'foo');
expect(view.context).toBeNull();
});
}); });
describe('removeSubstream', () => { describe('removeSubstream', () => {
@@ -100,4 +120,12 @@ describe('removeSubstream', () => {
}, },
}); });
}); });
it('should not remove substream without camera', () => {
const view = createView({
camera: null,
});
removeSubstream(view);
expect(view.context).toBeNull();
});
}); });
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManager } from '../../../src/camera-manager/manager';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { resolveViewName } from '../../../src/view/utils/resolve-default';
import { createStore } from '../../test-utils';
describe('resolveViewName', () => {
it('should return the view name directly if not auto', () => {
expect(resolveViewName('live', mock<CameraManager>(), mock<FoldersManager>())).toBe(
'live',
);
});
it('should return live if auto and cameras are present', () => {
const cameraManager = mock<CameraManager>();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([{ cameraID: 'camera-1' }]),
);
const foldersManager = mock<FoldersManager>();
expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('live');
});
it('should return folders if auto, no cameras, but folders are present', () => {
const cameraManager = mock<CameraManager>();
vi.mocked(cameraManager.getStore).mockReturnValue(createStore([]));
const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.hasFolders).mockReturnValue(true);
expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('folders');
});
it('should return image if auto and nothing is present', () => {
const cameraManager = mock<CameraManager>();
vi.mocked(cameraManager.getStore).mockReturnValue(createStore([]));
const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.hasFolders).mockReturnValue(false);
expect(resolveViewName('auto', cameraManager, foldersManager)).toBe('image');
});
});
+132 -38
View File
@@ -4,18 +4,19 @@ import { FoldersManager } from '../../src/card-controller/folders/manager';
import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const';
import { CapabilityKey } from '../../src/types'; import { CapabilityKey } from '../../src/types';
import { import {
getCameraIDsForViewName, getCameraIDsWithCapabilityForView,
isViewAvailable,
isViewSupported,
isViewSupportedByCamera, isViewSupportedByCamera,
} from '../../src/view/view-support'; } from '../../src/view/view-support';
import { import {
createCameraConfig, createCameraConfig,
createCameraManager, createCameraManager,
createCapabilities, createCapabilities,
createFolder,
createStore, createStore,
} from '../test-utils'; } from '../test-utils';
describe('getCameraIDsForViewName', () => { describe('getCameraIDsWithCapabilityForView', () => {
describe('views that are always supported', () => { describe('views that are always supported', () => {
it.each([['diagnostics' as const], ['image' as const]])( it.each([['diagnostics' as const], ['image' as const]])(
'%s', '%s',
@@ -32,14 +33,24 @@ describe('getCameraIDsForViewName', () => {
); );
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect( expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager),
).toEqual(new Set(['camera-1', 'camera-2'])); ).toEqual(new Set(['camera-1', 'camera-2']));
expect( expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'), getCameraIDsWithCapabilityForView(
viewName,
cameraManager,
foldersManager,
'camera-1',
),
).toEqual(new Set(['camera-1', 'camera-2']));
expect(
getCameraIDsWithCapabilityForView(
viewName,
cameraManager,
foldersManager,
'camera-2',
),
).toEqual(new Set(['camera-1', 'camera-2'])); ).toEqual(new Set(['camera-1', 'camera-2']));
}, },
); );
@@ -76,9 +87,9 @@ describe('getCameraIDsForViewName', () => {
); );
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( expect(
new Set(['camera-2']), getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager),
); ).toEqual(new Set(['camera-2']));
}); });
it.each([ it.each([
@@ -106,20 +117,14 @@ describe('getCameraIDsForViewName', () => {
); );
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect( expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager),
).toEqual(new Set(['camera-1', 'camera-2'])); ).toEqual(new Set(['camera-2', 'camera-1']));
expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'),
).toEqual(new Set(['camera-2']));
}); });
}); });
describe('views that respect a folder', () => { describe('folder views', () => {
describe('should return cameras when a folder is present', () => { describe('should return all cameras when a folder is present', () => {
it.each([ it.each([
['folder' as const], ['folder' as const],
['folders' as const], ['folders' as const],
@@ -140,16 +145,10 @@ describe('getCameraIDsForViewName', () => {
]), ]),
); );
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.getFolder).mockReturnValue(createFolder()); vi.mocked(foldersManager.hasFolders).mockReturnValue(true);
expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual(
new Set(['camera-1', 'camera-2']),
);
expect( expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager),
).toEqual(new Set(['camera-1', 'camera-2']));
expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'),
).toEqual(new Set(['camera-1', 'camera-2'])); ).toEqual(new Set(['camera-1', 'camera-2']));
}); });
}); });
@@ -175,19 +174,60 @@ describe('getCameraIDsForViewName', () => {
]), ]),
); );
const foldersManager = mock<FoldersManager>(); const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.getFolder).mockReturnValue(null); vi.mocked(foldersManager.hasFolders).mockReturnValue(false);
expect(getCameraIDsForViewName(viewName, cameraManager, foldersManager)).toEqual( expect(
new Set(), getCameraIDsWithCapabilityForView(viewName, cameraManager, foldersManager),
).toEqual(new Set());
});
});
});
});
describe('isViewAvailable', () => {
it('should return true when cameras are present for camera view', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([{ cameraID: 'camera-1' }]),
); );
expect( const foldersManager = mock<FoldersManager>();
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-1'), expect(isViewAvailable('live', cameraManager, foldersManager)).toBe(true);
).toEqual(new Set());
expect(
getCameraIDsForViewName(viewName, cameraManager, foldersManager, 'camera-2'),
).toEqual(new Set());
}); });
it('should return false when cameras are missing for camera view', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createStore([]));
const foldersManager = mock<FoldersManager>();
expect(isViewAvailable('live', cameraManager, foldersManager)).toBe(false);
}); });
it('should return true when folders are present for folder view', () => {
const cameraManager = createCameraManager();
const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.hasFolders).mockReturnValue(true);
expect(isViewAvailable('folder', cameraManager, foldersManager)).toBe(true);
});
it('should return false when folders are missing for folder view', () => {
const cameraManager = createCameraManager();
const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.hasFolders).mockReturnValue(false);
expect(isViewAvailable('folder', cameraManager, foldersManager)).toBe(false);
});
it('should return true when either covers it', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createStore([]));
const foldersManager = mock<FoldersManager>();
vi.mocked(foldersManager.hasFolders).mockReturnValue(true);
expect(isViewAvailable('gallery', cameraManager, foldersManager)).toBe(true);
});
it('should return true for diagnostics and image view', () => {
const cameraManager = createCameraManager();
const foldersManager = mock<FoldersManager>();
expect(isViewAvailable('diagnostics', cameraManager, foldersManager)).toBe(true);
expect(isViewAvailable('image', cameraManager, foldersManager)).toBe(true);
}); });
}); });
@@ -226,3 +266,57 @@ describe('isViewSupportedByCamera', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('isViewSupported', () => {
it('should return true when view is available and camera is null', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([{ cameraID: 'camera-1' }]),
);
const foldersManager = mock<FoldersManager>();
expect(isViewSupported('live', cameraManager, foldersManager, null)).toBe(true);
});
it('should return true when view is available and camera is supported', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ live: true }),
},
]),
);
const foldersManager = mock<FoldersManager>();
expect(isViewSupported('live', cameraManager, foldersManager, 'camera-1')).toBe(
true,
);
});
it('should return false when view is available but camera is not supported', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(
createStore([
{
cameraID: 'camera-1',
capabilities: createCapabilities({ live: false }),
},
]),
);
const foldersManager = mock<FoldersManager>();
expect(isViewSupported('live', cameraManager, foldersManager, 'camera-1')).toBe(
false,
);
});
it('should return false when view is not available', () => {
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createStore([]));
const foldersManager = mock<FoldersManager>();
expect(isViewSupported('live', cameraManager, foldersManager, null)).toBe(false);
});
});