From 1d81e03b044143e28d9bf3aa52e9922b4ba30fcb Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Mar 2026 10:00:39 -0800 Subject: [PATCH] fix: Should faithfully use WebRTC card PTZ format `data_*` (#2396) - Closes: #2385 BREAKING CHANGE: Requires configuration change (automatic upgrade offered) to move from (e.g.) `data_left_stop` to `data_end_left` --- docs/configuration/cameras/README.md | 7 ++- src/config/management.ts | 32 ++++++++++ src/config/schema/camera/ptz.ts | 58 ++++++++++++----- tests/config/management.test.ts | 80 ++++++++++++++++++++++++ tests/config/types.test.ts | 93 +++++++++++++++++++++++++++- 5 files changed, 249 insertions(+), 21 deletions(-) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 132a64ad..1c735780 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -300,8 +300,8 @@ natively support continuous actions (`actions_left_start`, `actions_left_stop`). | `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out` | Set by camera [engine](./engine.md) of the selected camera | The [perform-action](../actions/stock/README.md?id=perform-action) action that will be called for each PTZ action for relative movements. | | `actions_left_start`, `actions_left_stop`, `actions_right_start`, `actions_right_stop`,`actions_up_start`, `actions_up_stop`,`actions_down_start`, `actions_down_stop`,`actions_zoom_in_start`, `actions_zoom_in_stop`,`actions_zoom_out_start`, `actions_zoom_out_stop` | Set by camera [engine](./engine.md) of the selected camera | The [perform-action](../actions/stock/README.md?id=perform-action) action that will be called for each PTZ action for continous movements. Both a `_start` and `_stop` variety must be provided for an action to be usable. | | `c2r_delay_between_calls_seconds` | `0.2` | When the camera is configured with continuous actions only (e.g. `left_start` and `left_stop`, but not `left`), if something requests a relative action (e.g. a manually configured [action](../actions/README.md)), then `start` will be called, followed by a delay of this number of seconds and finally `stop` will be called. Cameras / integrations that are slower to respond to continuous steps may need to increase this value to avoid the continuous motion being too small. Cameras / integrations that are rapid to respond may need to decrease this value to avoid the "relative step" being too large. | -| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | | Shorthand for relative actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[action]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | -| `data_left_start`, `data_left_stop`, `data_right_start`, `data_right_stop`, `data_up_start`, `data_up_stop`, `data_down_start`, `data_down_stop`, `data_zoom_in_start`, `data_zoom_in_stop`, `data_zoom_out_start`, `data_zoom_out_stop` | | Shorthand for continuous actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[action]_start` and `actions_[action]_stop`. If both `actions_X_*` and `data_X_*` are specified, `actions_X_*` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). Both a `_start` and `_stop` variety must be provided for an action to be usable. | +| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | | Shorthand for relative actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[action]`. `data_home` is automatically converted into a `home` preset. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | +| `data_start_left`, `data_end_left`, `data_start_right`, `data_end_right`, `data_start_up`, `data_end_up`, `data_start_down`, `data_end_down`, `data_start_zoom_in`, `data_end_zoom_in`, `data_start_zoom_out`, `data_end_zoom_out` | | Shorthand for continuous actions that call the service defined by the `service` parameter, with the data provided in this argument. Internally, `data_start_*` and `data_end_*` are translated into `actions_*_start` and `actions_*_stop`. If an equivalent `actions_` key already exists, it takes priority. This uses [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples) key ordering. Both a `data_start_` and `data_end_` variety must be provided for an action to be usable. | | `presets` | | PTZ preset actions. See below. | | `r2c_delay_between_calls_seconds` | `0.5` | When the camera is configured with relative actions only (e.g. `left` but not `left_start` and `left_stop`), if something requests a continuous action (e.g. the card PTZ controls have a button held down), then a delay of this number of seconds will be inserted between each call of the relative action. Cameras / integrations that are slower to respond to relative steps may need to increase this value to avoid multiple simultaneous actions being sent. Cameras / integrations that are rapid to respond may need to decrease this value to increase the appearance of one single continuous motion. | | `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | @@ -325,6 +325,9 @@ cameras: > [!NOTE] > The 'Home' PTZ button (:house:) activates the first preset. +> [!WARNING] +> WebRTC Card's `data_long_*` parameters are not supported. + ## `proxy` Configures whether and how the content is proxied via diff --git a/src/config/management.ts b/src/config/management.ts index 44e790f9..4015cc5f 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -540,6 +540,33 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean { }; }; +// Upgrade old internal `data_*_stop` / `data_*_start` keys to +// WebRTC-compatible `data_end_*` / `data_start_*` ordering. +// WebRTC uses `data_start_left` / `data_end_left` (not `data_left_start` / +// `data_left_stop`). +// See: https://github.com/dermotduffy/advanced-camera-card/issues/2385 +// See: https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js +const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => { + if (typeof data !== 'object' || !data) { + return undefined; + } + let modified = false; + const out = { ...data }; + for (const key of Object.keys(out)) { + const match = key.match(/^data_(.+)_(start|stop)$/); + if (match) { + const phase = match[2] === 'stop' ? 'end' : match[2]; + const webrtcKey = `data_${phase}_${match[1]}`; + if (!(webrtcKey in out)) { + out[webrtcKey] = out[key]; + } + delete out[key]; + modified = true; + } + } + return modified ? out : undefined; +}; + const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { if (typeof data !== 'object' || !data) { return undefined; @@ -967,4 +994,9 @@ const UPGRADES = [ 'view.triggers.untrigger_seconds', CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, ), + upgradeWithOverrides('cameras_global.ptz', ptzIncorrectDataToWebRTCDataTransform), + upgradeArrayOfObjects( + CONF_CAMERAS, + upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform), + ), ]; diff --git a/src/config/schema/camera/ptz.ts b/src/config/schema/camera/ptz.ts index ad0e1711..a0c7f73a 100644 --- a/src/config/schema/camera/ptz.ts +++ b/src/config/schema/camera/ptz.ts @@ -6,32 +6,58 @@ export const ptzCameraConfigDefaults = { c2r_delay_between_calls_seconds: 0.2, }; -// To avoid lots of YAML duplication, provide an easy way to just specify the -// service data as actions for each PTZ action, and it will be preprocessed -// into the full form. This also provides compatability with the AlexIT/WebRTC -// PTZ configuration. -const dataPTZFormatToFullFormat = function (suffix: string): (data: unknown) => unknown { - return (data) => { +// Converts WebRTC `data_*` shorthand keys into full `actions_*` perform-action +// objects (e.g. `data_start_left` → `actions_left_start`, `data_end_left` → +// `actions_left_stop`, `data_left` → `actions_left`, `data_home` → preset). +// See: https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js +const dataPTZFormatToFullFormat = + (suffix: string) => + (data: unknown): unknown => { if (!data || typeof data !== 'object' || !data['service']) { return data; } + + const service = data['service']; const out = { ...data }; - Object.keys(data).forEach((key) => { - const match = key.match(/^data_(.+)$/); - const name = match?.[1]; - if (name && !(`${suffix}${name}` in data)) { + + for (const key of Object.keys(data)) { + const webrtc = key.match(/^data_(start|end)_(.+)$/); + const name = webrtc + ? `${webrtc[2]}_${webrtc[1] === 'end' ? 'stop' : webrtc[1]}` + : key.match(/^data_(.+)$/)?.[1]; + + if (!name) { + continue; + } + + // Route `data_home` into a `home` preset listed first so the PTZ + // home button (which activates the first preset) uses it. + if (suffix && name === 'home') { + const presets = + out['presets'] && typeof out['presets'] === 'object' ? out['presets'] : {}; + if (!('home' in presets)) { + out['presets'] = { + home: { + action: 'perform-action', + perform_action: service, + data: out[key], + }, + ...presets, + }; + } + } else if (!(`${suffix}${name}` in out)) { out[`${suffix}${name}`] = { action: 'perform-action', - perform_action: data['service'], - data: data[key], + perform_action: service, + data: out[key], }; - delete out[key]; - delete out['service']; } - }); + + delete out[key]; + delete out['service']; + } return out; }; -}; export const ptzCameraConfigSchema = z.preprocess( dataPTZFormatToFullFormat('actions_'), diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 6a66b177..1db6fa0f 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -3766,5 +3766,85 @@ describe('should handle version specific upgrades', () => { }); postUpgradeChecks(config); }); + + describe('ptz data_*_start/stop -> data_start/end_* (WebRTC ordering)', () => { + it('in cameras_global.ptz', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + cameras_global: { + ptz: { + service: 'service.ptz', + data_left_start: { cmd: 'left_start' }, + data_left_stop: { cmd: 'left_stop' }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras_global.ptz).toEqual({ + service: 'service.ptz', + data_start_left: { cmd: 'left_start' }, + data_end_left: { cmd: 'left_stop' }, + }); + postUpgradeChecks(config); + }); + + it('in cameras[n].ptz', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [ + { + ptz: { + service: 'service.ptz', + data_right_start: { cmd: 'right_start' }, + data_right_stop: { cmd: 'right_stop' }, + }, + }, + ], + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras[0].ptz).toEqual({ + service: 'service.ptz', + data_start_right: { cmd: 'right_start' }, + data_end_right: { cmd: 'right_stop' }, + }); + postUpgradeChecks(config); + }); + + it('ignores non-object ptz value', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + cameras_global: { + ptz: 'not-an-object', + }, + }; + + expect(upgradeConfig(config)).toBeFalsy(); + }); + + it('does not overwrite existing WebRTC key', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{}], + cameras_global: { + ptz: { + service: 'service.ptz', + data_left_stop: { cmd: 'old' }, + data_end_left: { cmd: 'new' }, + }, + }, + }; + + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.cameras_global.ptz).toEqual({ + service: 'service.ptz', + data_end_left: { cmd: 'new' }, + }); + postUpgradeChecks(config); + }); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 1c3007a6..f58ff869 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -1332,12 +1332,12 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { cameraConfigSchema.parse({ ptz: { service: 'foo', - [`data_${action}_start`]: { + [`data_start_${action}`]: { device: '048123', cmd: action, phase: 'start', }, - [`data_${action}_stop`]: { + [`data_end_${action}`]: { device: '048123', cmd: action, phase: 'stop', @@ -1371,7 +1371,7 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { }); }); - it('presets', () => { + it('presets via presets sub-object', () => { expect( cameraConfigSchema.parse({ ptz: { @@ -1414,6 +1414,93 @@ describe('should convert webrtc card PTZ to Advanced Camera Card PTZ', () => { }), ); }); + + it('actions_left takes priority over data_left', () => { + const result = cameraConfigSchema.parse({ + ptz: { + service: 'foo', + data_left: { cmd: 'from_data' }, + actions_left: { + action: 'perform-action', + perform_action: 'bar', + data: { cmd: 'from_actions' }, + }, + }, + }); + expect(result).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + actions_left: { + action: 'perform-action', + perform_action: 'bar', + data: { cmd: 'from_actions' }, + }, + }), + }), + ); + }); + + it('data_home creates a home preset', () => { + expect( + cameraConfigSchema.parse({ + ptz: { + service: 'foo', + data_home: { + device: '048123', + cmd: 'home', + }, + }, + }), + ).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + presets: { + home: { + action: 'perform-action', + perform_action: 'foo', + data: { + device: '048123', + cmd: 'home', + }, + }, + }, + }), + }), + ); + }); + + it('data_home does not overwrite existing home preset', () => { + expect( + cameraConfigSchema.parse({ + ptz: { + service: 'foo', + data_home: { + device: '048123', + cmd: 'home_data', + }, + presets: { + home: { + action: 'perform-action', + perform_action: 'bar', + data: { cmd: 'home_preset' }, + }, + }, + }, + }), + ).toEqual( + expect.objectContaining({ + ptz: expect.objectContaining({ + presets: { + home: { + action: 'perform-action', + perform_action: 'bar', + data: { cmd: 'home_preset' }, + }, + }, + }), + }), + ); + }); }); describe('should lazy evaluate schemas', () => {