diff --git a/src/camera-manager/camera.ts b/src/camera-manager/camera.ts index 93c61855..ea9dc8e4 100644 --- a/src/camera-manager/camera.ts +++ b/src/camera-manager/camera.ts @@ -225,6 +225,7 @@ export class Camera { executor: ActionsExecutor, action: PTZAction, options?: { + hass?: HomeAssistant; phase?: PTZActionPhase; preset?: string; }, diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index 81453649..3c3503e2 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -61,6 +61,7 @@ export class FrigateCamera extends Camera { executor: ActionsExecutor, action: PTZAction, options?: { + hass?: HomeAssistant; phase?: PTZActionPhase; preset?: string; }, diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 4a919b5d..70f3832b 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -943,8 +943,12 @@ export class CameraManager { action, camera.getConfig().dimensions?.rotation, ); + const hass = this._api.getHASSManager().getHASS(); await this._requestLimit.add(() => - camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options), + camera.executePTZAction(this._api.getActionsManager(), rotatedAction, { + ...options, + hass: hass ?? undefined, + }), ); } } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index 6e6c4931..096bbda0 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -1,5 +1,9 @@ import { ActionsExecutor } from '../../card-controller/actions/types'; -import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz'; +import { + PTZAction, + PTZActionPhase, + PTZBaseAction, +} from '../../config/schema/actions/custom/ptz'; import { DeviceRegistryManager } from '../../ha/registry/device/index'; import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import { HomeAssistant } from '../../ha/types'; @@ -20,6 +24,21 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; // Reolink channels are zero indexed. const REOLINK_DEFAULT_CHANNEL = 0; +// Reolink cameras expose zoom via two independent entity types: +// - Button entities (ptz_zoom_in / ptz_zoom_out): continuous start/stop +// movement, disabled by default in the Reolink integration. +// - Number entity (zoom): absolute position, always enabled. Present on +// digital-zoom-only models that lack button entities entirely. +// +// When only the number entity is available we call `number.set_value` with a +// target computed as a fraction of the entity's range per tap (so ~10 taps +// covers it). Rapid taps may collapse into a single step since HA state lags +// (e.g. 3 rapid taps may only result in a single apparent zoom -- local state +// may need to be introduced if this presents an unacceptable UX). + +// Fraction of the zoom range to step per zoom action when using number entity. +const ZOOM_POSITION_STEP_FRACTION = 0.1; + interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; deviceRegistryManager: DeviceRegistryManager; @@ -27,7 +46,10 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions class ReolinkInitializationError extends CameraInitializationError {} -interface PTZEntities { +// Button entities for continuous PTZ movement (press to start, press stop to +// end). Discovered from button.{name}_ptz_{action} entities. Zoom button +// entities are disabled by default in the Reolink integration. +interface PTZButtonEntities { stop?: string; left?: string; right?: string; @@ -35,9 +57,28 @@ interface PTZEntities { down?: string; zoom_in?: string; zoom_out?: string; +} + +interface PTZEntities extends PTZButtonEntities { + // Number entity for absolute zoom positioning (number.{name}_zoom). + // Used as a fallback when zoom_in/zoom_out button entities are absent. + // Some Reolink cameras (e.g. digital-zoom-only models) expose zoom only + // through this entity. + zoom?: string; + + // Select entity for PTZ presets (select.{name}_ptz_preset). presets?: string; } -type PTZEntity = keyof PTZEntities; + +const PTZ_BUTTON_ENTITY_KEYS: readonly (keyof PTZButtonEntities)[] = [ + 'stop', + 'left', + 'right', + 'up', + 'down', + 'zoom_in', + 'zoom_out', +]; export class ReolinkCamera extends EntityCamera { // The HostID identifying the camera or NVR. @@ -191,6 +232,13 @@ export class ReolinkCamera extends EntityCamera { } } + if (!reolinkPTZCapabilities.zoomIn && ptzEntities.zoom) { + reolinkPTZCapabilities.zoomIn = [PTZMovementType.Relative]; + } + if (!reolinkPTZCapabilities.zoomOut && ptzEntities.zoom) { + reolinkPTZCapabilities.zoomOut = [PTZMovementType.Relative]; + } + const ptzPresetsEntityState = ptzEntities?.presets ? hass.states[ptzEntities.presets] : null; @@ -231,19 +279,9 @@ export class ReolinkCamera extends EntityCamera { ent.entity_id.startsWith('select.'), ); - const uniqueSuffixes: PTZEntity[] = [ - 'stop', - 'left', - 'right', - 'up', - 'down', - 'zoom_in', - 'zoom_out', - ]; - const ptzEntities: PTZEntities = {}; for (const buttonEntity of buttonEntities) { - for (const uniqueIDSuffix of uniqueSuffixes) { + for (const uniqueIDSuffix of PTZ_BUTTON_ENTITY_KEYS) { if ( buttonEntity.unique_id && String(buttonEntity.unique_id).endsWith(uniqueIDSuffix) @@ -257,6 +295,14 @@ export class ReolinkCamera extends EntityCamera { ptzEntities.presets = ptzPresetEntities[0].entity_id; } + const zoomNumberEntities = allRelevantEntities.filter( + (ent: Entity) => + ent.unique_id === `${uniqueIDPrefix}zoom` && ent.entity_id.startsWith('number.'), + ); + if (zoomNumberEntities.length === 1) { + ptzEntities.zoom = zoomNumberEntities[0].entity_id; + } + return Object.keys(ptzEntities).length ? ptzEntities : null; } @@ -294,6 +340,7 @@ export class ReolinkCamera extends EntityCamera { executor: ActionsExecutor, action: PTZAction, options?: { + hass?: HomeAssistant; phase?: PTZActionPhase; preset?: string; }, @@ -319,11 +366,32 @@ export class ReolinkCamera extends EntityCamera { return true; } + if (action === 'zoom_in' || action === 'zoom_out') { + return ( + // Try a continuous action first, if not available fall back to an + // absolute step. + (await this._executeContinuousPTZAction(executor, action, options)) || + (await this._executeAbsoluteZoomAction(executor, action, options)) + ); + } + + return this._executeContinuousPTZAction(executor, action, options); + } + + // Handles PTZ via button entities (button.press) for continuous start/stop + // movement. + private async _executeContinuousPTZAction( + executor: ActionsExecutor, + action: PTZBaseAction, + options?: { + phase?: PTZActionPhase; + }, + ): Promise { const entityID = options?.phase === 'start' - ? this._ptzEntities[action] + ? this._ptzEntities?.[action] : options?.phase === 'stop' - ? this._ptzEntities.stop + ? this._ptzEntities?.stop : null; if (!entityID) { return false; @@ -340,4 +408,44 @@ export class ReolinkCamera extends EntityCamera { }); return true; } + + // Handles zoom via the number entity (number.set_value) when zoom button + // entities are absent. + private async _executeAbsoluteZoomAction( + executor: ActionsExecutor, + action: 'zoom_in' | 'zoom_out', + options?: { + hass?: HomeAssistant; + }, + ): Promise { + if (!this._ptzEntities?.zoom) { + return false; + } + + const state = options?.hass?.states[this._ptzEntities.zoom]; + const min = Number(state?.attributes?.min); + const max = Number(state?.attributes?.max); + const current = Number(state?.state); + if (isNaN(min) || isNaN(max) || isNaN(current)) { + return false; + } + + const step = Math.max(1, Math.round((max - min) * ZOOM_POSITION_STEP_FRACTION)); + const target = + action === 'zoom_in' + ? Math.min(current + step, max) + : Math.max(current - step, min); + + await executor.executeActions({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: target }, + target: { entity_id: this._ptzEntities.zoom }, + }, + ], + }); + return true; + } } diff --git a/src/camera-manager/tplink/camera.ts b/src/camera-manager/tplink/camera.ts index 272923b5..468b5ee3 100644 --- a/src/camera-manager/tplink/camera.ts +++ b/src/camera-manager/tplink/camera.ts @@ -95,6 +95,7 @@ export class TPLinkCamera extends EntityCamera { executor: ActionsExecutor, action: PTZAction, options?: { + hass?: HomeAssistant; phase?: PTZActionPhase; preset?: string; }, diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 96fe014e..2657fd0a 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -33,7 +33,7 @@ export type NotificationActionConfig = z.infer< advanced_camera_card_action: 'notification'; notification: Notification; }; -export const notificationActionConfigSchema: z.ZodSchema = +const notificationActionConfigSchema: z.ZodSchema = advancedCameraCardCustomActionsBaseSchema.extend({ advanced_camera_card_action: z.literal('notification'), notification: z.lazy(() => notificationSchema), @@ -122,18 +122,18 @@ const notificationBaseSchema = z.object({ severity: severitySchema.optional(), }); -export const notificationDetailSchema = notificationBaseSchema.extend({ +const notificationDetailSchema = notificationBaseSchema.extend({ text: z.string(), }); export type NotificationDetail = z.infer; -export const notificationControlSchema = notificationBaseSchema.extend({ +const notificationControlSchema = notificationBaseSchema.extend({ actions: actionsBaseSchema.optional(), dismiss: z.boolean().default(true), }); export type NotificationControl = z.infer; -export const notificationSchema = z.object({ +const notificationSchema = z.object({ heading: notificationDetailSchema.optional(), controls: notificationControlSchema.array().optional(), details: notificationDetailSchema.array().optional(), diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 7cdf1eef..13d60729 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -1328,6 +1328,36 @@ describe('CameraManager', () => { // No visible action. }); + it('successfully with null hass', async () => { + const api = createCardAPI(); + const engine = mock(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + const action = { + action: 'perform-action' as const, + perform_action: 'action', + }; + const manager = createCameraManager(api, engine, [ + { + config: createCameraConfig({ + baseCameraConfig, + id: 'another', + ptz: { + actions_left: action, + }, + }), + }, + ]); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + manager.executePTZAction('another', 'left'); + + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: action, + }); + }); + it('successfully', async () => { const api = createCardAPI(); const engine = mock(); diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index e121c6c3..a991da57 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -62,6 +62,12 @@ describe('ReolinkCamera', () => { platform: 'reolink', }); + const numberEntityZoom = createRegistryEntity({ + entity_id: 'number.office_reolink_zoom', + unique_id: '85270002TS7D4RUP_0_zoom', + platform: 'reolink', + }); + const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([ cameraEntity, buttonEntityPTZLeft, @@ -854,5 +860,404 @@ describe('ReolinkCamera', () => { expect(executor.executeActions).not.toHaveBeenCalled(); }); }); + + describe('should execute absolute zoom action', () => { + it('should discover zoom number entity', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + zoomIn: ['relative'], + zoomOut: ['relative'], + }); + }); + + it('should ignore disabled zoom number entity', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + createRegistryEntity({ + entity_id: 'number.office_reolink_zoom', + unique_id: '85270002TS7D4RUP_0_zoom', + platform: 'reolink', + disabled_by: 'user', + }), + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull(); + }); + + it('should prefer button entities over number entity for zoom', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPTZZoomIn, + buttonEntityPTZZoomOut, + buttonEntityPTZStop, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + zoomIn: ['continuous'], + zoomOut: ['continuous'], + }); + }); + + it('should zoom in via number entity', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '10', + attributes: { min: 0, max: 33 }, + }), + }), + }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: 13 }, + target: { entity_id: 'number.office_reolink_zoom' }, + }, + ], + }); + }); + + it('should zoom out via number entity', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_out', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '10', + attributes: { min: 0, max: 33 }, + }), + }), + }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: 7 }, + target: { entity_id: 'number.office_reolink_zoom' }, + }, + ], + }); + }); + + it('should clamp zoom in to max', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '32', + attributes: { min: 0, max: 33 }, + }), + }), + }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: 33 }, + target: { entity_id: 'number.office_reolink_zoom' }, + }, + ], + }); + }); + + it('should clamp zoom out to min', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_out', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '1', + attributes: { min: 0, max: 33 }, + }), + }), + }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: 0 }, + target: { entity_id: 'number.office_reolink_zoom' }, + }, + ], + }); + }); + + it('should not execute when state is unavailable', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: 'unavailable', + attributes: { min: 0, max: 33 }, + }), + }), + }); + + expect(executor.executeActions).not.toHaveBeenCalled(); + }); + + it('should not execute when min/max attributes are missing', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '10', + }), + }), + }); + + expect(executor.executeActions).not.toHaveBeenCalled(); + }); + + it('should not execute when hass is not provided', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in'); + + expect(executor.executeActions).not.toHaveBeenCalled(); + }); + + it('should use continuous zoom when button entities exist', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPTZZoomIn, + buttonEntityPTZZoomOut, + buttonEntityPTZStop, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { phase: 'start' }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'button.press', + target: { entity_id: 'button.office_reolink_ptz_zoom_in' }, + }, + ], + }); + }); + + it('should not zoom when neither zoom button nor number entity exists', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + buttonEntityPTZLeft, + buttonEntityPTZStop, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + await camera.executePTZAction(executor, 'zoom_in', { phase: 'start' }); + + expect(executor.executeActions).not.toHaveBeenCalled(); + }); + + it('should use step of at least 1', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + cameraEntity, + numberEntityZoom, + ]), + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + const executor = mock(); + + // Range of 5: 10% = 0.5, rounds to 1, step = max(1, 1) = 1. + await camera.executePTZAction(executor, 'zoom_in', { + hass: createHASS({ + 'number.office_reolink_zoom': createStateEntity({ + state: '2', + attributes: { min: 0, max: 5 }, + }), + }), + }); + + expect(executor.executeActions).toHaveBeenCalledWith({ + actions: [ + { + action: 'perform-action', + perform_action: 'number.set_value', + data: { value: 3 }, + target: { entity_id: 'number.office_reolink_zoom' }, + }, + ], + }); + }); + }); }); });