diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 5d44b626..defd4c18 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -21,7 +21,7 @@ import { getGo2RTCMetadataEndpoint, getGo2RTCStreamEndpoint, } from '../utils/go2rtc/endpoint'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; +import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz'; import { getPTZInfo } from './requests'; import { FRIGATE_SEVERITY_MAP, @@ -217,8 +217,7 @@ export class FrigateCamera extends Camera { const frigatePTZ = await this._getPTZCapabilities(hass, config); const configPTZ = getPTZCapabilitiesFromCameraConfig(config); - const combinedPTZ: PTZCapabilities | null = - configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null; + const combinedPTZ = mergePTZCapabilities(frigatePTZ, configPTZ); const birdseye = isBirdseye(config); return { diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 77c25877..9d607a06 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -18,7 +18,7 @@ import type { Camera, CameraInitializationOptions } from '../camera'; import { EntityCamera } from '../entity-camera'; import { ReolinkInitializationError } from '../error'; import type { CameraEndpointsContext, CameraProxyConfig } from '../types'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; +import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz'; // Reolink channels are zero indexed. const REOLINK_DEFAULT_CHANNEL = 0; @@ -194,8 +194,7 @@ export class ReolinkCamera extends EntityCamera { ? this._entitiesToCapabilities(hass, this._ptzEntities) : null; - const combinedPTZ: PTZCapabilities | null = - configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null; + const combinedPTZ = mergePTZCapabilities(reolinkPTZ, configPTZ); return { ...(await super._getRawCapabilities(hass, options)), diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts index 3e374933..7c2b6c80 100644 --- a/src/camera-manager/tplink/camera.ts +++ b/src/camera-manager/tplink/camera.ts @@ -9,7 +9,7 @@ import { } from '../../types'; import type { CameraInitializationOptions } from '../camera'; import { EntityCamera } from '../entity-camera'; -import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; +import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz'; interface TPLinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; @@ -43,8 +43,7 @@ export class TPLinkCamera extends EntityCamera { ? this._entitiesToCapabilities(this._ptzEntities) : null; - const combinedPTZ: PTZCapabilities | null = - configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null; + const combinedPTZ = mergePTZCapabilities(tplinkPTZ, configPTZ); return { ...(await super._getRawCapabilities(hass, options)), diff --git a/src/camera-manager/utils/ptz.ts b/src/camera-manager/utils/ptz.ts index b2540ef9..7639ec6b 100644 --- a/src/camera-manager/utils/ptz.ts +++ b/src/camera-manager/utils/ptz.ts @@ -54,6 +54,32 @@ export const getConfiguredPTZMovementType = ( : null; }; +// Combine engine-detected and configured PTZ capabilities. Configured movement +// actions override their engine equivalents, but presets from both sources are +// kept (configured first) so that configuring a preset does not erase the +// auto-detected ones. +export const mergePTZCapabilities = ( + enginePTZ: PTZCapabilities | null, + configPTZ: PTZCapabilities | null, +): PTZCapabilities | null => { + if (!enginePTZ && !configPTZ) { + return null; + } + + const presets = [ + ...(configPTZ?.presets ?? []), + ...(enginePTZ?.presets ?? []).filter( + (preset) => !configPTZ?.presets?.includes(preset), + ), + ]; + + return { + ...enginePTZ, + ...configPTZ, + ...(presets.length ? { presets } : {}), + }; +}; + export const getPTZCapabilitiesFromCameraConfig = ( cameraConfig: CameraConfig, ): PTZCapabilities | null => { diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts index d9f03e7a..f0fecea4 100644 --- a/src/card-controller/actions/actions/ptz.ts +++ b/src/card-controller/actions/actions/ptz.ts @@ -56,7 +56,18 @@ export class PTZAction extends AdvancedCameraCardAction { } if (!action.ptz_action) { - if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) { + // A configured `home` preset takes precedence over the first + // auto-detected preset. Without this, engines that auto-detect presets + // (e.g. Reolink) populate `capabilities.presets` from a `select` entity + // and the home button always targets `presets[0]`, ignoring the + // configured action. See: + // https://github.com/dermotduffy/advanced-camera-card/issues/2525 + if (ptzConfiguration.presets?.['home']) { + await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { + phase: action.ptz_phase, + preset: 'home', + }); + } else if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) { await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { phase: action.ptz_phase, preset: ptzCapabilities.presets[0], diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 9fd4195f..1cbb69e4 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -507,6 +507,47 @@ describe('ReolinkCamera', () => { zoomOut: ['continuous'], }); }); + + it('should union configured presets with detected presets', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + ptz: { + presets: { + home: { + action: 'perform-action', + perform_action: 'button.press', + target: { entity_id: 'button.office_guard' }, + }, + }, + }, + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hassManager: createHASSManager({ + hass: createHASS({ + 'select.office_reolink_ptz_preset': createStateEntity({ + state: 'foo', + attributes: { + options: ['preset-one', 'preset-two'], + }, + }), + }), + }), + entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + left: ['continuous'], + right: ['continuous'], + up: ['continuous'], + down: ['continuous'], + zoomIn: ['continuous'], + zoomOut: ['continuous'], + presets: ['home', 'preset-one', 'preset-two'], + }); + }); }); }); diff --git a/tests/camera-manager/utils/ptz.test.ts b/tests/camera-manager/utils/ptz.test.ts index 2205614c..e271c96b 100644 --- a/tests/camera-manager/utils/ptz.test.ts +++ b/tests/camera-manager/utils/ptz.test.ts @@ -4,8 +4,10 @@ import { getConfiguredPTZAction, getConfiguredPTZMovementType, getPTZCapabilitiesFromCameraConfig, + mergePTZCapabilities, } from '../../../src/camera-manager/utils/ptz'; import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz'; +import { PTZMovementType } from '../../../src/types'; import { createCameraConfig } from '../../test-utils'; const action = { @@ -186,3 +188,64 @@ describe('getPTZCapabilitiesFromCameraConfig', () => { }); }); }); + +describe('mergePTZCapabilities', () => { + it('should return null when both are null', () => { + expect(mergePTZCapabilities(null, null)).toBeNull(); + }); + + it('should return engine capabilities when no config capabilities', () => { + expect( + mergePTZCapabilities( + { left: [PTZMovementType.Continuous], presets: ['Staw', 'Piwnica'] }, + null, + ), + ).toEqual({ + left: [PTZMovementType.Continuous], + presets: ['Staw', 'Piwnica'], + }); + }); + + it('should return config capabilities when no engine capabilities', () => { + expect(mergePTZCapabilities(null, { presets: ['home'] })).toEqual({ + presets: ['home'], + }); + }); + + it('should union presets with configured presets first', () => { + expect( + mergePTZCapabilities( + { left: [PTZMovementType.Continuous], presets: ['Staw', 'Piwnica'] }, + { presets: ['home'] }, + ), + ).toEqual({ + left: [PTZMovementType.Continuous], + presets: ['home', 'Staw', 'Piwnica'], + }); + }); + + it('should not duplicate presets present in both sources', () => { + expect( + mergePTZCapabilities({ presets: ['home', 'Staw'] }, { presets: ['home'] }), + ).toEqual({ + presets: ['home', 'Staw'], + }); + }); + + it('should let configured movement actions override engine equivalents', () => { + expect( + mergePTZCapabilities( + { left: [PTZMovementType.Continuous] }, + { left: [PTZMovementType.Relative] }, + ), + ).toEqual({ + left: [PTZMovementType.Relative], + }); + }); + + it('should omit presets when neither source has any', () => { + expect(mergePTZCapabilities({ left: [PTZMovementType.Continuous] }, null)).toEqual({ + left: [PTZMovementType.Continuous], + }); + }); +}); diff --git a/tests/card-controller/actions/actions/ptz.test.ts b/tests/card-controller/actions/actions/ptz.test.ts index 1499e745..2da616e9 100644 --- a/tests/card-controller/actions/actions/ptz.test.ts +++ b/tests/card-controller/actions/actions/ptz.test.ts @@ -205,6 +205,101 @@ describe('should handle ptz action', () => { ); }); + it('should call configured home preset in preference to first preset', async () => { + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2525 + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ + ptz: { + presets: { + home: { + action: 'perform-action', + perform_action: 'button.press', + data: { entity_id: 'button.office_guard' }, + }, + }, + }, + }), + // An engine (e.g. Reolink) auto-detects presets that would otherwise + // shadow the configured home action. + capabilities: new Capabilities({ + ptz: { presets: ['Staw', 'Piwnica'] }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'preset', + { + phase: undefined, + preset: 'home', + }, + ); + }); + + it('should call first preset when no home preset is configured', async () => { + const api = createCardAPI(); + const store = createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ + ptz: { + presets: { + window: { + action: 'perform-action', + perform_action: 'button.press', + data: { entity_id: 'button.office_window' }, + }, + }, + }, + }), + capabilities: new Capabilities({ + ptz: { presets: ['Staw', 'Piwnica'] }, + }), + }, + ]); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ camera: 'camera.office' }), + ); + + const action = new PTZAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz', + }, + ); + + await action.execute(api); + + expect(api.getCameraManager().executePTZAction).toBeCalledWith( + 'camera.office', + 'preset', + { + phase: undefined, + preset: 'Staw', + }, + ); + }); + it('should not call preset when there are no presets', async () => { const api = createCardAPI(); const store = createStore([