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,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
hass?: HomeAssistant;
|
||||
phase?: PTZActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ export class FrigateCamera extends Camera {
|
||||
executor: ActionsExecutor,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
hass?: HomeAssistant;
|
||||
phase?: PTZActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export class TPLinkCamera extends EntityCamera {
|
||||
executor: ActionsExecutor,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
hass?: HomeAssistant;
|
||||
phase?: PTZActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user