fix: PTZ actions should reflect camera rotation (#2258)
- Closes: #2210
This commit is contained in:
@@ -356,6 +356,9 @@ advanced_camera_card_action: ptz
|
|||||||
> [!NOTE]
|
> [!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.
|
> 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`
|
## `ptz_controls`
|
||||||
|
|
||||||
Show or hide the PTZ controls.
|
Show or hide the PTZ controls.
|
||||||
|
|||||||
@@ -139,8 +139,10 @@ cameras:
|
|||||||
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
|
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the pl
|
> 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.
|
||||||
> 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.
|
|
||||||
|
> [!TIP]
|
||||||
|
> When rotation is configured, directional PTZ actions (`left`, `right`, `up`, `down`) are automatically rotated to match the camera's orientation.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
|
> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import { cloneDeep, sum } from 'lodash-es';
|
|||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { CardCameraAPI } from '../card-controller/types.js';
|
import { CardCameraAPI } from '../card-controller/types.js';
|
||||||
import { sortItems } from '../card-controller/view/sort.js';
|
import { sortItems } from '../card-controller/view/sort.js';
|
||||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
|
import {
|
||||||
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
|
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 { 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';
|
||||||
@@ -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(
|
public async executePTZAction(
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
action: PTZAction,
|
action: PTZAction,
|
||||||
@@ -800,8 +828,12 @@ export class CameraManager {
|
|||||||
if (!camera) {
|
if (!camera) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rotatedAction = this._rotatePTZAction(
|
||||||
|
action,
|
||||||
|
camera.getConfig().dimensions?.rotation,
|
||||||
|
);
|
||||||
await this._requestLimit.add(() =>
|
await this._requestLimit.add(() =>
|
||||||
camera.executePTZAction(this._api.getActionsManager(), action, options),
|
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
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_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const;
|
||||||
const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const;
|
const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const;
|
||||||
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
|
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
|
||||||
|
|||||||
@@ -1141,6 +1141,129 @@ describe('CameraManager', async () => {
|
|||||||
|
|
||||||
expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action });
|
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', () => {
|
describe('should determine if queries are fresh', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||||
import { AdvancedCameraCardView } from '../../src/config/schema/common/const';
|
import { AdvancedCameraCardView } from '../../src/config/schema/common/const';
|
||||||
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const';
|
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const';
|
||||||
|
import { PTZMovementType } from '../../src/types';
|
||||||
import {
|
import {
|
||||||
getPTZTarget,
|
getPTZTarget,
|
||||||
hasCameraTruePTZ,
|
hasCameraTruePTZ,
|
||||||
@@ -64,7 +65,7 @@ describe('getPTZTarget', () => {
|
|||||||
const store = createStore([
|
const store = createStore([
|
||||||
{
|
{
|
||||||
cameraID: 'camera-1',
|
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([
|
const store = createStore([
|
||||||
{
|
{
|
||||||
cameraID: 'camera-1',
|
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([
|
const store = createStore([
|
||||||
{
|
{
|
||||||
cameraID: 'camera-1',
|
cameraID: 'camera-1',
|
||||||
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
|
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user