fix: Add support for Reolink number based zooming (#2408)

- Closes #2034
This commit is contained in:
Dermot Duffy
2026-03-13 19:55:27 -07:00
committed by GitHub
parent ebe6b2f8c6
commit 95faddd9a0
8 changed files with 571 additions and 21 deletions
+1
View File
@@ -225,6 +225,7 @@ export class Camera {
executor: ActionsExecutor,
action: PTZAction,
options?: {
hass?: HomeAssistant;
phase?: PTZActionPhase;
preset?: string;
},
+1
View File
@@ -61,6 +61,7 @@ export class FrigateCamera extends Camera {
executor: ActionsExecutor,
action: PTZAction,
options?: {
hass?: HomeAssistant;
phase?: PTZActionPhase;
preset?: string;
},
+5 -1
View File
@@ -943,8 +943,12 @@ export class CameraManager {
action,
camera.getConfig().dimensions?.rotation,
);
const hass = this._api.getHASSManager().getHASS();
await this._requestLimit.add(() =>
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options),
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, {
...options,
hass: hass ?? undefined,
}),
);
}
}
+124 -16
View File
@@ -1,5 +1,9 @@
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import {
PTZAction,
PTZActionPhase,
PTZBaseAction,
} from '../../config/schema/actions/custom/ptz';
import { DeviceRegistryManager } from '../../ha/registry/device/index';
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import { HomeAssistant } from '../../ha/types';
@@ -20,6 +24,21 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
// Reolink channels are zero indexed.
const REOLINK_DEFAULT_CHANNEL = 0;
// Reolink cameras expose zoom via two independent entity types:
// - Button entities (ptz_zoom_in / ptz_zoom_out): continuous start/stop
// movement, disabled by default in the Reolink integration.
// - Number entity (zoom): absolute position, always enabled. Present on
// digital-zoom-only models that lack button entities entirely.
//
// When only the number entity is available we call `number.set_value` with a
// target computed as a fraction of the entity's range per tap (so ~10 taps
// covers it). Rapid taps may collapse into a single step since HA state lags
// (e.g. 3 rapid taps may only result in a single apparent zoom -- local state
// may need to be introduced if this presents an unacceptable UX).
// Fraction of the zoom range to step per zoom action when using number entity.
const ZOOM_POSITION_STEP_FRACTION = 0.1;
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
deviceRegistryManager: DeviceRegistryManager;
@@ -27,7 +46,10 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
class ReolinkInitializationError extends CameraInitializationError {}
interface PTZEntities {
// Button entities for continuous PTZ movement (press to start, press stop to
// end). Discovered from button.{name}_ptz_{action} entities. Zoom button
// entities are disabled by default in the Reolink integration.
interface PTZButtonEntities {
stop?: string;
left?: string;
right?: string;
@@ -35,9 +57,28 @@ interface PTZEntities {
down?: string;
zoom_in?: string;
zoom_out?: string;
}
interface PTZEntities extends PTZButtonEntities {
// Number entity for absolute zoom positioning (number.{name}_zoom).
// Used as a fallback when zoom_in/zoom_out button entities are absent.
// Some Reolink cameras (e.g. digital-zoom-only models) expose zoom only
// through this entity.
zoom?: string;
// Select entity for PTZ presets (select.{name}_ptz_preset).
presets?: string;
}
type PTZEntity = keyof PTZEntities;
const PTZ_BUTTON_ENTITY_KEYS: readonly (keyof PTZButtonEntities)[] = [
'stop',
'left',
'right',
'up',
'down',
'zoom_in',
'zoom_out',
];
export class ReolinkCamera extends EntityCamera {
// The HostID identifying the camera or NVR.
@@ -191,6 +232,13 @@ export class ReolinkCamera extends EntityCamera {
}
}
if (!reolinkPTZCapabilities.zoomIn && ptzEntities.zoom) {
reolinkPTZCapabilities.zoomIn = [PTZMovementType.Relative];
}
if (!reolinkPTZCapabilities.zoomOut && ptzEntities.zoom) {
reolinkPTZCapabilities.zoomOut = [PTZMovementType.Relative];
}
const ptzPresetsEntityState = ptzEntities?.presets
? hass.states[ptzEntities.presets]
: null;
@@ -231,19 +279,9 @@ export class ReolinkCamera extends EntityCamera {
ent.entity_id.startsWith('select.'),
);
const uniqueSuffixes: PTZEntity[] = [
'stop',
'left',
'right',
'up',
'down',
'zoom_in',
'zoom_out',
];
const ptzEntities: PTZEntities = {};
for (const buttonEntity of buttonEntities) {
for (const uniqueIDSuffix of uniqueSuffixes) {
for (const uniqueIDSuffix of PTZ_BUTTON_ENTITY_KEYS) {
if (
buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
@@ -257,6 +295,14 @@ export class ReolinkCamera extends EntityCamera {
ptzEntities.presets = ptzPresetEntities[0].entity_id;
}
const zoomNumberEntities = allRelevantEntities.filter(
(ent: Entity) =>
ent.unique_id === `${uniqueIDPrefix}zoom` && ent.entity_id.startsWith('number.'),
);
if (zoomNumberEntities.length === 1) {
ptzEntities.zoom = zoomNumberEntities[0].entity_id;
}
return Object.keys(ptzEntities).length ? ptzEntities : null;
}
@@ -294,6 +340,7 @@ export class ReolinkCamera extends EntityCamera {
executor: ActionsExecutor,
action: PTZAction,
options?: {
hass?: HomeAssistant;
phase?: PTZActionPhase;
preset?: string;
},
@@ -319,11 +366,32 @@ export class ReolinkCamera extends EntityCamera {
return true;
}
if (action === 'zoom_in' || action === 'zoom_out') {
return (
// Try a continuous action first, if not available fall back to an
// absolute step.
(await this._executeContinuousPTZAction(executor, action, options)) ||
(await this._executeAbsoluteZoomAction(executor, action, options))
);
}
return this._executeContinuousPTZAction(executor, action, options);
}
// Handles PTZ via button entities (button.press) for continuous start/stop
// movement.
private async _executeContinuousPTZAction(
executor: ActionsExecutor,
action: PTZBaseAction,
options?: {
phase?: PTZActionPhase;
},
): Promise<boolean> {
const entityID =
options?.phase === 'start'
? this._ptzEntities[action]
? this._ptzEntities?.[action]
: options?.phase === 'stop'
? this._ptzEntities.stop
? this._ptzEntities?.stop
: null;
if (!entityID) {
return false;
@@ -340,4 +408,44 @@ export class ReolinkCamera extends EntityCamera {
});
return true;
}
// Handles zoom via the number entity (number.set_value) when zoom button
// entities are absent.
private async _executeAbsoluteZoomAction(
executor: ActionsExecutor,
action: 'zoom_in' | 'zoom_out',
options?: {
hass?: HomeAssistant;
},
): Promise<boolean> {
if (!this._ptzEntities?.zoom) {
return false;
}
const state = options?.hass?.states[this._ptzEntities.zoom];
const min = Number(state?.attributes?.min);
const max = Number(state?.attributes?.max);
const current = Number(state?.state);
if (isNaN(min) || isNaN(max) || isNaN(current)) {
return false;
}
const step = Math.max(1, Math.round((max - min) * ZOOM_POSITION_STEP_FRACTION));
const target =
action === 'zoom_in'
? Math.min(current + step, max)
: Math.max(current - step, min);
await executor.executeActions({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: target },
target: { entity_id: this._ptzEntities.zoom },
},
],
});
return true;
}
}
+1
View File
@@ -95,6 +95,7 @@ export class TPLinkCamera extends EntityCamera {
executor: ActionsExecutor,
action: PTZAction,
options?: {
hass?: HomeAssistant;
phase?: PTZActionPhase;
preset?: string;
},
+4 -4
View File
@@ -33,7 +33,7 @@ export type NotificationActionConfig = z.infer<
advanced_camera_card_action: 'notification';
notification: Notification;
};
export const notificationActionConfigSchema: z.ZodSchema<NotificationActionConfig> =
const notificationActionConfigSchema: z.ZodSchema<NotificationActionConfig> =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('notification'),
notification: z.lazy(() => notificationSchema),
@@ -122,18 +122,18 @@ const notificationBaseSchema = z.object({
severity: severitySchema.optional(),
});
export const notificationDetailSchema = notificationBaseSchema.extend({
const notificationDetailSchema = notificationBaseSchema.extend({
text: z.string(),
});
export type NotificationDetail = z.infer<typeof notificationDetailSchema>;
export const notificationControlSchema = notificationBaseSchema.extend({
const notificationControlSchema = notificationBaseSchema.extend({
actions: actionsBaseSchema.optional(),
dismiss: z.boolean().default(true),
});
export type NotificationControl = z.infer<typeof notificationControlSchema>;
export const notificationSchema = z.object({
const notificationSchema = z.object({
heading: notificationDetailSchema.optional(),
controls: notificationControlSchema.array().optional(),
details: notificationDetailSchema.array().optional(),
+30
View File
@@ -1328,6 +1328,36 @@ describe('CameraManager', () => {
// No visible action.
});
it('successfully with null hass', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const action = {
action: 'perform-action' as const,
perform_action: 'action',
};
const manager = createCameraManager(api, engine, [
{
config: createCameraConfig({
baseCameraConfig,
id: 'another',
ptz: {
actions_left: action,
},
}),
},
]);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
manager.executePTZAction('another', 'left');
expect(api.getActionsManager().executeActions).toBeCalledWith({
actions: action,
});
});
it('successfully', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
+405
View File
@@ -62,6 +62,12 @@ describe('ReolinkCamera', () => {
platform: 'reolink',
});
const numberEntityZoom = createRegistryEntity({
entity_id: 'number.office_reolink_zoom',
unique_id: '85270002TS7D4RUP_0_zoom',
platform: 'reolink',
});
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZLeft,
@@ -854,5 +860,404 @@ describe('ReolinkCamera', () => {
expect(executor.executeActions).not.toHaveBeenCalled();
});
});
describe('should execute absolute zoom action', () => {
it('should discover zoom number entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
zoomIn: ['relative'],
zoomOut: ['relative'],
});
});
it('should ignore disabled zoom number entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
createRegistryEntity({
entity_id: 'number.office_reolink_zoom',
unique_id: '85270002TS7D4RUP_0_zoom',
platform: 'reolink',
disabled_by: 'user',
}),
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toBeNull();
});
it('should prefer button entities over number entity for zoom', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZZoomIn,
buttonEntityPTZZoomOut,
buttonEntityPTZStop,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
zoomIn: ['continuous'],
zoomOut: ['continuous'],
});
});
it('should zoom in via number entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '10',
attributes: { min: 0, max: 33 },
}),
}),
});
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: 13 },
target: { entity_id: 'number.office_reolink_zoom' },
},
],
});
});
it('should zoom out via number entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_out', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '10',
attributes: { min: 0, max: 33 },
}),
}),
});
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: 7 },
target: { entity_id: 'number.office_reolink_zoom' },
},
],
});
});
it('should clamp zoom in to max', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '32',
attributes: { min: 0, max: 33 },
}),
}),
});
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: 33 },
target: { entity_id: 'number.office_reolink_zoom' },
},
],
});
});
it('should clamp zoom out to min', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_out', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '1',
attributes: { min: 0, max: 33 },
}),
}),
});
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: 0 },
target: { entity_id: 'number.office_reolink_zoom' },
},
],
});
});
it('should not execute when state is unavailable', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: 'unavailable',
attributes: { min: 0, max: 33 },
}),
}),
});
expect(executor.executeActions).not.toHaveBeenCalled();
});
it('should not execute when min/max attributes are missing', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '10',
}),
}),
});
expect(executor.executeActions).not.toHaveBeenCalled();
});
it('should not execute when hass is not provided', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in');
expect(executor.executeActions).not.toHaveBeenCalled();
});
it('should use continuous zoom when button entities exist', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZZoomIn,
buttonEntityPTZZoomOut,
buttonEntityPTZStop,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', { phase: 'start' });
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: { entity_id: 'button.office_reolink_ptz_zoom_in' },
},
],
});
});
it('should not zoom when neither zoom button nor number entity exists', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZLeft,
buttonEntityPTZStop,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'zoom_in', { phase: 'start' });
expect(executor.executeActions).not.toHaveBeenCalled();
});
it('should use step of at least 1', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
numberEntityZoom,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
// Range of 5: 10% = 0.5, rounds to 1, step = max(1, 1) = 1.
await camera.executePTZAction(executor, 'zoom_in', {
hass: createHASS({
'number.office_reolink_zoom': createStateEntity({
state: '2',
attributes: { min: 0, max: 5 },
}),
}),
});
expect(executor.executeActions).toHaveBeenCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'number.set_value',
data: { value: 3 },
target: { entity_id: 'number.office_reolink_zoom' },
},
],
});
});
});
});
});