fix: Add support for Reolink number based zooming (#2408)
- Closes #2034
This commit is contained in:
@@ -225,6 +225,7 @@ export class Camera {
|
|||||||
executor: ActionsExecutor,
|
executor: ActionsExecutor,
|
||||||
action: PTZAction,
|
action: PTZAction,
|
||||||
options?: {
|
options?: {
|
||||||
|
hass?: HomeAssistant;
|
||||||
phase?: PTZActionPhase;
|
phase?: PTZActionPhase;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class FrigateCamera extends Camera {
|
|||||||
executor: ActionsExecutor,
|
executor: ActionsExecutor,
|
||||||
action: PTZAction,
|
action: PTZAction,
|
||||||
options?: {
|
options?: {
|
||||||
|
hass?: HomeAssistant;
|
||||||
phase?: PTZActionPhase;
|
phase?: PTZActionPhase;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -943,8 +943,12 @@ export class CameraManager {
|
|||||||
action,
|
action,
|
||||||
camera.getConfig().dimensions?.rotation,
|
camera.getConfig().dimensions?.rotation,
|
||||||
);
|
);
|
||||||
|
const hass = this._api.getHASSManager().getHASS();
|
||||||
await this._requestLimit.add(() =>
|
await this._requestLimit.add(() =>
|
||||||
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options),
|
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, {
|
||||||
|
...options,
|
||||||
|
hass: hass ?? undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { ActionsExecutor } from '../../card-controller/actions/types';
|
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 { DeviceRegistryManager } from '../../ha/registry/device/index';
|
||||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||||
import { HomeAssistant } from '../../ha/types';
|
import { HomeAssistant } from '../../ha/types';
|
||||||
@@ -20,6 +24,21 @@ import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
|||||||
// Reolink channels are zero indexed.
|
// Reolink channels are zero indexed.
|
||||||
const REOLINK_DEFAULT_CHANNEL = 0;
|
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 {
|
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
deviceRegistryManager: DeviceRegistryManager;
|
deviceRegistryManager: DeviceRegistryManager;
|
||||||
@@ -27,7 +46,10 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
|
|||||||
|
|
||||||
class ReolinkInitializationError extends CameraInitializationError {}
|
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;
|
stop?: string;
|
||||||
left?: string;
|
left?: string;
|
||||||
right?: string;
|
right?: string;
|
||||||
@@ -35,9 +57,28 @@ interface PTZEntities {
|
|||||||
down?: string;
|
down?: string;
|
||||||
zoom_in?: string;
|
zoom_in?: string;
|
||||||
zoom_out?: 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;
|
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 {
|
export class ReolinkCamera extends EntityCamera {
|
||||||
// The HostID identifying the camera or NVR.
|
// 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
|
const ptzPresetsEntityState = ptzEntities?.presets
|
||||||
? hass.states[ptzEntities.presets]
|
? hass.states[ptzEntities.presets]
|
||||||
: null;
|
: null;
|
||||||
@@ -231,19 +279,9 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
ent.entity_id.startsWith('select.'),
|
ent.entity_id.startsWith('select.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueSuffixes: PTZEntity[] = [
|
|
||||||
'stop',
|
|
||||||
'left',
|
|
||||||
'right',
|
|
||||||
'up',
|
|
||||||
'down',
|
|
||||||
'zoom_in',
|
|
||||||
'zoom_out',
|
|
||||||
];
|
|
||||||
|
|
||||||
const ptzEntities: PTZEntities = {};
|
const ptzEntities: PTZEntities = {};
|
||||||
for (const buttonEntity of buttonEntities) {
|
for (const buttonEntity of buttonEntities) {
|
||||||
for (const uniqueIDSuffix of uniqueSuffixes) {
|
for (const uniqueIDSuffix of PTZ_BUTTON_ENTITY_KEYS) {
|
||||||
if (
|
if (
|
||||||
buttonEntity.unique_id &&
|
buttonEntity.unique_id &&
|
||||||
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
|
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
|
||||||
@@ -257,6 +295,14 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
ptzEntities.presets = ptzPresetEntities[0].entity_id;
|
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;
|
return Object.keys(ptzEntities).length ? ptzEntities : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +340,7 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
executor: ActionsExecutor,
|
executor: ActionsExecutor,
|
||||||
action: PTZAction,
|
action: PTZAction,
|
||||||
options?: {
|
options?: {
|
||||||
|
hass?: HomeAssistant;
|
||||||
phase?: PTZActionPhase;
|
phase?: PTZActionPhase;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
},
|
},
|
||||||
@@ -319,11 +366,32 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
return true;
|
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 =
|
const entityID =
|
||||||
options?.phase === 'start'
|
options?.phase === 'start'
|
||||||
? this._ptzEntities[action]
|
? this._ptzEntities?.[action]
|
||||||
: options?.phase === 'stop'
|
: options?.phase === 'stop'
|
||||||
? this._ptzEntities.stop
|
? this._ptzEntities?.stop
|
||||||
: null;
|
: null;
|
||||||
if (!entityID) {
|
if (!entityID) {
|
||||||
return false;
|
return false;
|
||||||
@@ -340,4 +408,44 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
});
|
});
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export class TPLinkCamera extends EntityCamera {
|
|||||||
executor: ActionsExecutor,
|
executor: ActionsExecutor,
|
||||||
action: PTZAction,
|
action: PTZAction,
|
||||||
options?: {
|
options?: {
|
||||||
|
hass?: HomeAssistant;
|
||||||
phase?: PTZActionPhase;
|
phase?: PTZActionPhase;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export type NotificationActionConfig = z.infer<
|
|||||||
advanced_camera_card_action: 'notification';
|
advanced_camera_card_action: 'notification';
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
};
|
};
|
||||||
export const notificationActionConfigSchema: z.ZodSchema<NotificationActionConfig> =
|
const notificationActionConfigSchema: z.ZodSchema<NotificationActionConfig> =
|
||||||
advancedCameraCardCustomActionsBaseSchema.extend({
|
advancedCameraCardCustomActionsBaseSchema.extend({
|
||||||
advanced_camera_card_action: z.literal('notification'),
|
advanced_camera_card_action: z.literal('notification'),
|
||||||
notification: z.lazy(() => notificationSchema),
|
notification: z.lazy(() => notificationSchema),
|
||||||
@@ -122,18 +122,18 @@ const notificationBaseSchema = z.object({
|
|||||||
severity: severitySchema.optional(),
|
severity: severitySchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationDetailSchema = notificationBaseSchema.extend({
|
const notificationDetailSchema = notificationBaseSchema.extend({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
});
|
});
|
||||||
export type NotificationDetail = z.infer<typeof notificationDetailSchema>;
|
export type NotificationDetail = z.infer<typeof notificationDetailSchema>;
|
||||||
|
|
||||||
export const notificationControlSchema = notificationBaseSchema.extend({
|
const notificationControlSchema = notificationBaseSchema.extend({
|
||||||
actions: actionsBaseSchema.optional(),
|
actions: actionsBaseSchema.optional(),
|
||||||
dismiss: z.boolean().default(true),
|
dismiss: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
||||||
|
|
||||||
export const notificationSchema = z.object({
|
const notificationSchema = z.object({
|
||||||
heading: notificationDetailSchema.optional(),
|
heading: notificationDetailSchema.optional(),
|
||||||
controls: notificationControlSchema.array().optional(),
|
controls: notificationControlSchema.array().optional(),
|
||||||
details: notificationDetailSchema.array().optional(),
|
details: notificationDetailSchema.array().optional(),
|
||||||
|
|||||||
@@ -1328,6 +1328,36 @@ describe('CameraManager', () => {
|
|||||||
// No visible action.
|
// 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 () => {
|
it('successfully', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const engine = mock<CameraManagerEngine>();
|
const engine = mock<CameraManagerEngine>();
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ describe('ReolinkCamera', () => {
|
|||||||
platform: 'reolink',
|
platform: 'reolink',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const numberEntityZoom = createRegistryEntity({
|
||||||
|
entity_id: 'number.office_reolink_zoom',
|
||||||
|
unique_id: '85270002TS7D4RUP_0_zoom',
|
||||||
|
platform: 'reolink',
|
||||||
|
});
|
||||||
|
|
||||||
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
|
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
|
||||||
cameraEntity,
|
cameraEntity,
|
||||||
buttonEntityPTZLeft,
|
buttonEntityPTZLeft,
|
||||||
@@ -854,5 +860,404 @@ describe('ReolinkCamera', () => {
|
|||||||
expect(executor.executeActions).not.toHaveBeenCalled();
|
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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user