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`
This commit is contained in:
Dermot Duffy
2026-03-07 10:00:39 -08:00
committed by GitHub
parent a81d72375d
commit 1d81e03b04
5 changed files with 249 additions and 21 deletions
+5 -2
View File
@@ -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
+32
View File
@@ -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),
),
];
+42 -16
View File
@@ -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_'),
+80
View File
@@ -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);
});
});
});
});
+90 -3
View File
@@ -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', () => {