fix: PTZ actions should reflect camera rotation (#2258)

- Closes: #2210
This commit is contained in:
Dermot Duffy
2025-12-07 21:12:12 -08:00
committed by GitHub
parent 596a37903f
commit c38bf192e0
6 changed files with 172 additions and 9 deletions
@@ -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.
+4 -2
View File
@@ -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).
+35 -3
View File
@@ -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),
);
}
}
+3 -1
View File
@@ -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];
+123
View File
@@ -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<CameraManagerEngine>();
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', () => {
+4 -3
View File
@@ -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] } }),
},
]);