From c38bf192e0551ccd03a5dc6ba708949a8d81d0f9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 7 Dec 2025 21:12:12 -0800 Subject: [PATCH] fix: PTZ actions should reflect camera rotation (#2258) - Closes: #2210 --- docs/configuration/actions/custom/README.md | 3 + docs/configuration/cameras/README.md | 6 +- src/camera-manager/manager.ts | 38 +++++- src/config/schema/actions/custom/ptz.ts | 4 +- tests/camera-manager/manager.test.ts | 123 ++++++++++++++++++++ tests/utils/ptz.test.ts | 7 +- 6 files changed, 172 insertions(+), 9 deletions(-) diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 1c7aaca1..d7fb76e0 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -356,6 +356,9 @@ advanced_camera_card_action: ptz > [!NOTE] > If no `ptz_action` is specified, the camera returns to its "home" position. For a real PTZ camera, the "home" position is the first available preset. If there are no presets, there is no home position. +> [!TIP] +> When a camera has a [`rotation`](../../cameras/README.md?id=dimensions) configured, directional PTZ actions (`left`, `right`, `up`, `down`) are automatically rotated to match the camera's orientation. Use the `ptz_action` value that matches how the camera is being displayed, and it will be automatically translated based on the rotation to the true value. + ## `ptz_controls` Show or hide the PTZ controls. diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index d964aea8..b93adccd 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -139,8 +139,10 @@ cameras: | `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. | > [!NOTE] -> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the pl -> ayer, which may be distracting or confusing (e.g. upside down controls). Builtin controls can be disabled using the [`live.controls.builtin` parameter](../live.md?id=controls). Rotation is not available in iOS fullscreen, due to the limited fullscreen support offered by that OS. +> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the player, which may be distracting or confusing (e.g. upside down controls). Builtin controls can be disabled using the [`live.controls.builtin` parameter](../live.md?id=controls). Rotation is not available in iOS fullscreen, due to the limited fullscreen support offered by that OS. + +> [!TIP] +> When rotation is configured, directional PTZ actions (`left`, `right`, `up`, `down`) are automatically rotated to match the camera's orientation. > [!WARNING] > Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings). diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 1a9f8ae4..3f5aece3 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -3,8 +3,13 @@ import { cloneDeep, sum } from 'lodash-es'; import PQueue from 'p-queue'; import { CardCameraAPI } from '../card-controller/types.js'; import { sortItems } from '../card-controller/view/sort.js'; -import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js'; -import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js'; +import { + PTZ_PAN_TILT_ACTIONS, + PTZAction, + PTZActionPhase, + PTZPanTiltAction, +} from '../config/schema/actions/custom/ptz.js'; +import { CameraConfig, CamerasConfig, Rotation } from '../config/schema/cameras.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; import { localize } from '../localize/localize.js'; import { Endpoint } from '../types.js'; @@ -788,6 +793,29 @@ export class CameraManager { }); } + /** + * Rotate a PTZ action based on camera rotation setting. + * When camera view is rotated, PTZ controls should logically rotate too. + * For example: with 90° rotation, pressing "left" should send "down" to camera. + */ + private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction { + if (!rotation) { + return action; + } + + // Pan/tilt directions in clockwise order for rotation calculation + const index = PTZ_PAN_TILT_ACTIONS.indexOf(action as PTZPanTiltAction); + + if (index === -1) { + // Not a directional action (e.g., zoom_in, zoom_out, preset) + return action; + } + + // Each 90° rotation shifts the direction index counter-clockwise. + const shift = (4 - rotation / 90) % 4; + return PTZ_PAN_TILT_ACTIONS[(index + shift) % 4]; + } + public async executePTZAction( cameraID: string, action: PTZAction, @@ -800,8 +828,12 @@ export class CameraManager { if (!camera) { return; } + const rotatedAction = this._rotatePTZAction( + action, + camera.getConfig().dimensions?.rotation, + ); await this._requestLimit.add(() => - camera.executePTZAction(this._api.getActionsManager(), action, options), + camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options), ); } } diff --git a/src/config/schema/actions/custom/ptz.ts b/src/config/schema/actions/custom/ptz.ts index 1792d7fb..1b8e7b08 100644 --- a/src/config/schema/actions/custom/ptz.ts +++ b/src/config/schema/actions/custom/ptz.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; import { advancedCameraCardCustomActionsBaseSchema } from './base'; -const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const; +export const PTZ_PAN_TILT_ACTIONS = ['up', 'right', 'down', 'left'] as const; +export type PTZPanTiltAction = (typeof PTZ_PAN_TILT_ACTIONS)[number]; + const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const; const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const; export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number]; diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 2f6b0bb6..20b529ad 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -1141,6 +1141,129 @@ describe('CameraManager', async () => { expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action }); }); + + describe('with rotation', () => { + it.each([ + // No rotation + [undefined, 'left', 'left', undefined], + [undefined, 'right', 'right', undefined], + [undefined, 'up', 'up', undefined], + [undefined, 'down', 'down', undefined], + [0, 'left', 'left', undefined], + [0, 'right', 'right', undefined], + [0, 'up', 'up', undefined], + [0, 'down', 'down', undefined], + + // 90° rotation (clockwise view rotation means controls rotate counter-clockwise) + [90, 'left', 'down', undefined], + [90, 'right', 'up', undefined], + [90, 'up', 'left', undefined], + [90, 'down', 'right', undefined], + + // 180° rotation + [180, 'left', 'right', undefined], + [180, 'right', 'left', undefined], + [180, 'up', 'down', undefined], + [180, 'down', 'up', undefined], + + // 270° rotation + [270, 'left', 'up', undefined], + [270, 'right', 'down', undefined], + [270, 'up', 'right', undefined], + [270, 'down', 'left', undefined], + + // Non-directional actions should pass through unchanged + [90, 'zoom_in', 'zoom_in', undefined], + [90, 'zoom_out', 'zoom_out', undefined], + [90, 'preset', 'preset', 'test-preset'], + [180, 'zoom_in', 'zoom_in', undefined], + [180, 'zoom_out', 'zoom_out', undefined], + [180, 'preset', 'preset', 'test-preset'], + [270, 'zoom_in', 'zoom_in', undefined], + [270, 'zoom_out', 'zoom_out', undefined], + [270, 'preset', 'preset', 'test-preset'], + ] as const)( + 'rotates %s° %s to %s', + async (rotation, inputAction, expectedAction, preset) => { + const api = createCardAPI(); + const engine = mock(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const leftAction = { + action: 'perform-action' as const, + perform_action: 'left-action', + }; + const rightAction = { + action: 'perform-action' as const, + perform_action: 'right-action', + }; + const upAction = { + action: 'perform-action' as const, + perform_action: 'up-action', + }; + const downAction = { + action: 'perform-action' as const, + perform_action: 'down-action', + }; + const zoomInAction = { + action: 'perform-action' as const, + perform_action: 'zoom-in-action', + }; + const zoomOutAction = { + action: 'perform-action' as const, + perform_action: 'zoom-out-action', + }; + const presetAction = { + action: 'perform-action' as const, + perform_action: 'preset-action', + }; + + const manager = createCameraManager(api, engine, [ + { + config: createCameraConfig({ + baseCameraConfig, + id: 'rotated-camera', + dimensions: rotation !== undefined ? { rotation } : undefined, + ptz: { + actions_left: leftAction, + actions_right: rightAction, + actions_up: upAction, + actions_down: downAction, + actions_zoom_in: zoomInAction, + actions_zoom_out: zoomOutAction, + presets: { + 'test-preset': presetAction, + }, + }, + }), + }, + ]); + expect(await manager.initializeCamerasFromConfig()).toBeTruthy(); + + manager.executePTZAction( + 'rotated-camera', + inputAction, + preset ? { preset } : undefined, + ); + + // Map expected action to the corresponding action object + const expectedActionMap = { + left: leftAction, + right: rightAction, + up: upAction, + down: downAction, + zoom_in: zoomInAction, + zoom_out: zoomOutAction, + preset: presetAction, + }; + + expect(api.getActionsManager().executeActions).toBeCalledWith({ + actions: expectedActionMap[expectedAction], + }); + }, + ); + }); }); describe('should determine if queries are fresh', () => { diff --git a/tests/utils/ptz.test.ts b/tests/utils/ptz.test.ts index 02932e74..19048588 100644 --- a/tests/utils/ptz.test.ts +++ b/tests/utils/ptz.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { Capabilities } from '../../src/camera-manager/capabilities'; import { AdvancedCameraCardView } from '../../src/config/schema/common/const'; import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const'; +import { PTZMovementType } from '../../src/types'; import { getPTZTarget, hasCameraTruePTZ, @@ -64,7 +65,7 @@ describe('getPTZTarget', () => { const store = createStore([ { cameraID: 'camera-1', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); @@ -105,7 +106,7 @@ describe('getPTZTarget', () => { const store = createStore([ { cameraID: 'camera-1', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]); @@ -139,7 +140,7 @@ describe('hasCameraTruePTZ', () => { const store = createStore([ { cameraID: 'camera-1', - capabilities: new Capabilities({ ptz: { left: ['relative'] } }), + capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }), }, ]);