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
+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' },
},
],
});
});
});
});
});