From 2eb0d9e35e150a24f09cf47368e0b8408634cc45 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 16 Apr 2025 10:58:18 +0100 Subject: [PATCH] fix: Reolink PTZ support should work with hub-connected cameras (#2014) - Related: #1964 --- docs/configuration/cameras/engine.md | 11 +- src/camera-manager/reolink/camera.ts | 63 +++++++++-- src/config/schema/cameras.ts | 1 + tests/camera-manager/reolink/camera.test.ts | 110 +++++++++++++++++--- 4 files changed, 156 insertions(+), 29 deletions(-) diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index 558384a0..1ace5962 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -96,10 +96,13 @@ cameras: # [...] ``` -| Option | Default | Description | -| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------ | -| `media_resolution` | `low` | Whether to retrieve `high` or `low` resolution media items. | -| `url` | | The URL of the Reolink camera/NVR UI. If set, this value will be (exclusively) used for a `Camera UI` menu button. | +| Option | Default | Description | +| ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `channel` | autodetected (direct), or `0` (via NVR/Hub) | The channel number for the Reolink camera, used by the Reolink integration to identify different cameras. For cameras directly connected to Home Assistant (w/o an NVR), this value will be autodetected and need not be set. For cameras connected via a Hub/NVR, this value cannot currently be autodetected -- a default value of `0` will be used in this case. | +| `media_resolution` | `low` | Whether to retrieve `high` or `low` resolution media items. | +| `url` | | The URL of the Reolink camera/NVR UI. If set, this value will be (exclusively) used for a `Camera UI` menu button. | + +?> If media for the "wrong" Reolink camera is showing up and you have an NVR connected camera, your `channel` value is likely incorrect. Try increasing it until you find the correct camera. ### PTZ Support diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index ada9dc75..bb9c67ab 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -12,6 +12,9 @@ import { CameraInitializationError } from '../error'; import { CameraProxyConfig } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; +// Reolink channels are zero indexed. +const REOLINK_DEFAULT_CHANNEL = 0; + interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; hass: HomeAssistant; @@ -32,8 +35,16 @@ interface PTZEntities { type PTZEntity = keyof PTZEntities; export class ReolinkCamera extends BrowseMediaCamera { - protected _channel: number | null = null; - protected _reolinkUniqueID: string | null = null; + // The HostID identifying the camera or NVR. + protected _reolinkHostID: string | null = null; + + // For NVRs, the Camera UID. + protected _reolinkCameraUID: string | null = null; + + // The channel number as used by the Reolink integration. + protected _reolinkChannel: number | null = null; + + // Entities used for PTZ control. protected _ptzEntities: PTZEntities | null = null; public async initialize(options: ReolinkCameraInitializationOptions): Promise { @@ -45,21 +56,47 @@ export class ReolinkCamera extends BrowseMediaCamera { protected _initializeChannel(): void { const uniqueID = this._entity?.unique_id; + + // Reolink camera unique IDs are dual-mode, they may be in either of these + // forms: + // - Directly connected cameras: [HostID]_[Channel #]_[...] + // (e.g. `95270002FS8D4RUP_0_sub`) + // - NVR/Hub connected cameras: [HostID]_[Camera UID]_[...] + // (e.g. `9527000HXU4V1VHZ_9527000I7E5F1GYU_sub`) + // + // The channel number is always numeric and assumed to be <1000, see similar + // comparisons in the integration itself: + // https://github.com/home-assistant/core/blob/dev/homeassistant/components/reolink/media_source.py#L174 + // + // In the latter form, the channel number cannot be inferred from the entity + // and must only be taken from the user config instead. + const match = uniqueID - ? String(uniqueID).match(/(?.*)_(?\d+)/) + ? String(uniqueID).match( + /^(?[A-Za-z0-9]+)_(?[A-Za-z0-9]+)_/, + ) : null; - const channel = match && match.groups?.channel ? Number(match.groups.channel) : null; - const reolinkUniqueID = match?.groups?.uniqueid ?? null; + const hostid = match?.groups?.hostid ?? null; + const channelOrUID = match?.groups?.channel_or_uid ?? null; - if (channel === null || reolinkUniqueID === null) { + if (hostid === null || channelOrUID === null) { throw new ReolinkInitializationError( localize('error.camera_initialization_reolink'), this.getConfig(), ); } - this._channel = channel; - this._reolinkUniqueID = reolinkUniqueID; + + const channelCandidate = Number(channelOrUID); + const isValidChannel = !isNaN(channelCandidate) && channelCandidate <= 999; + const channel = + this._config.reolink.channel ?? + (isValidChannel ? channelCandidate : REOLINK_DEFAULT_CHANNEL); + const reolinkCameraUID = !isValidChannel ? channelOrUID : null; + + this._reolinkChannel = channel; + this._reolinkHostID = hostid; + this._reolinkCameraUID = reolinkCameraUID; } protected async _initializeCapabilities( @@ -144,11 +181,11 @@ export class ReolinkCamera extends BrowseMediaCamera { ): Promise { /* istanbul ignore next: this path cannot be reached as an exception is thrown in initialize() if this value is not found -- @preserve */ - if (!this._reolinkUniqueID) { + if (!this._reolinkHostID) { return null; } - const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`; + const uniqueIDPrefix = this._getPTZEntityUniqueIDPrefix(); const allRelevantEntities = await entityRegistry.getMatchingEntities( hass, (ent: Entity) => @@ -196,7 +233,11 @@ export class ReolinkCamera extends BrowseMediaCamera { } public getChannel(): number | null { - return this._channel; + return this._reolinkChannel; + } + + protected _getPTZEntityUniqueIDPrefix(): string { + return `${this._reolinkHostID}_${this._reolinkCameraUID ?? this._reolinkChannel}_`; } public getProxyConfig(): CameraProxyConfig { diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index e9c22d39..01a6bdfa 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -228,6 +228,7 @@ export const cameraConfigSchema = z reolink: z .object({ url: z.string().optional(), + channel: z.number().optional(), media_resolution: z .enum(['high', 'low']) .default(cameraConfigDefault.reolink.media_resolution), diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 41d6d8d6..3a9e95e1 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -126,7 +126,7 @@ describe('ReolinkCamera', () => { ).rejects.toThrowError('Could not initialize Reolink camera'); }); - it('without a channel in the unique_id', async () => { + it('without a valid unique_id', async () => { const config = createCameraConfig({ camera_entity: 'camera.office_reolink', }); @@ -148,26 +148,74 @@ describe('ReolinkCamera', () => { }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); - }); - it('successfully with main camera', async () => { - const config = createCameraConfig({ - camera_entity: 'camera.office_reolink', - }); - const camera = new ReolinkCamera(config, mock()); - const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1964 + it('successfully with an NVR-connected camera with user-specified channel', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + reolink: { + channel: 42, + }, + }); + const camera = new ReolinkCamera(config, mock()); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.office_reolink', + unique_id: '9527000HXU4V1VHZ_9527000I7E5F1GYU_main', + platform: 'reolink', + }), + ]); - await camera.initialize({ - hass: createHASS(), - entityRegistryManager, - stateWatcher: mock(), + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(42); }); - expect(camera.getChannel()).toBe(0); + it('successfully with an NVR-connected camera without user-specified channel', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + const entityRegistryManager = new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.office_reolink', + unique_id: '9527000HXU4V1VHZ_9527000I7E5F1GYU_main', + platform: 'reolink', + }), + ]); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); + + it('successfully with a directly connected camera', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); }); describe('successfully with PTZ', () => { - it('should find PTZ button entities', async () => { + it('should find PTZ button entities with a directly connected camera', async () => { const config = createCameraConfig({ camera_entity: 'camera.office_reolink', }); @@ -189,6 +237,40 @@ describe('ReolinkCamera', () => { }); }); + it('should find PTZ button entities with NVR-connected camera', async () => { + const config = createCameraConfig({ + camera_entity: 'camera.office_reolink', + }); + const camera = new ReolinkCamera(config, mock()); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager: new EntityRegistryManagerMock([ + createRegistryEntity({ + entity_id: 'camera.office_reolink', + unique_id: '9527000HXU4V1VHZ_9527000I7E5F1GYU_main', + platform: 'reolink', + }), + createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_zoom_in', + unique_id: '9527000HXU4V1VHZ_9527000I7E5F1GYU_ptz_zoom_in', + platform: 'reolink', + }), + createRegistryEntity({ + entity_id: 'button.office_reolink_ptz_zoom_out', + unique_id: '9527000HXU4V1VHZ_9527000I7E5F1GYU_ptz_zoom_out', + platform: 'reolink', + }), + ]), + stateWatcher: mock(), + }); + + expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({ + zoomIn: ['continuous'], + zoomOut: ['continuous'], + }); + }); + it('should find PTZ select entity', async () => { const config = createCameraConfig({ camera_entity: 'camera.office_reolink',