From f276b8c57d38ad43d9b20b7c22c4c06ed5e9adf6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 7 Dec 2025 22:48:33 -0800 Subject: [PATCH] feat: Automatically detect Reolink NVR channel number (#2259) - Closes: #2032, #1899 - May or may not help with #2254 - Caution: I do not have an NVR-based Reolink camera to test. If suddenly Reolink cameras don't work in the card, or in particular if media doesn't correctly load, this is likely the culprit PR. --- docs/configuration/cameras/engine.md | 11 +- src/camera-manager/engine-factory.ts | 10 +- src/camera-manager/manager.ts | 5 +- src/camera-manager/reolink/camera.ts | 41 +++- src/camera-manager/reolink/engine-reolink.ts | 29 +++ src/card-controller/types.ts | 2 + src/config/schema/cameras.ts | 1 - src/ha/registry/device/types.ts | 1 + tests/camera-manager/reolink/camera.test.ts | 195 +++++++++++++++--- .../reolink/engine-reolink.test.ts | 2 + 10 files changed, 256 insertions(+), 41 deletions(-) diff --git a/docs/configuration/cameras/engine.md b/docs/configuration/cameras/engine.md index dae483f8..b7aeb3f0 100644 --- a/docs/configuration/cameras/engine.md +++ b/docs/configuration/cameras/engine.md @@ -98,14 +98,11 @@ cameras: # [...] ``` -| 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. | +| Option | Default | Description | +| ------ | ------- | ----------- | -> [!TIP] -> 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. +| `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. | ### PTZ Support diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 989b5068..76ec4589 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -1,6 +1,7 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { CameraConfig } from '../config/schema/cameras'; import { BrowseMediaWalker } from '../ha/browse-media/walker'; +import { DeviceRegistryManager } from '../ha/registry/device'; import { EntityRegistryManager } from '../ha/registry/entity/types'; import { ResolvedMediaCache } from '../ha/resolved-media'; import { HomeAssistant } from '../ha/types'; @@ -18,11 +19,15 @@ interface CameraManagerEngineFactoryOptions { } export class CameraManagerEngineFactory { - // Entity registry manager is required for the actual function of the factory. protected _entityRegistryManager: EntityRegistryManager; + protected _deviceRegistryManager: DeviceRegistryManager; - constructor(entityRegistryManager: EntityRegistryManager) { + constructor( + entityRegistryManager: EntityRegistryManager, + deviceRegistryManager: DeviceRegistryManager, + ) { this._entityRegistryManager = entityRegistryManager; + this._deviceRegistryManager = deviceRegistryManager; } public async createEngine( @@ -65,6 +70,7 @@ export class CameraManagerEngineFactory { const { ReolinkCameraManagerEngine } = await import('./reolink/engine-reolink'); cameraManagerEngine = new ReolinkCameraManagerEngine( this._entityRegistryManager, + this._deviceRegistryManager, options.stateWatcher, new BrowseMediaWalker(), options.resolvedMediaCache, diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 3f5aece3..8a7a7439 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -129,7 +129,10 @@ export class CameraManager { this._api = api; this._engineFactory = options?.factory ?? - new CameraManagerEngineFactory(this._api.getEntityRegistryManager()); + new CameraManagerEngineFactory( + this._api.getEntityRegistryManager(), + this._api.getDeviceRegistryManager(), + ); this._store = options?.store ?? new CameraManagerStore(); } diff --git a/src/camera-manager/reolink/camera.ts b/src/camera-manager/reolink/camera.ts index bf1b3f11..a6ad535d 100644 --- a/src/camera-manager/reolink/camera.ts +++ b/src/camera-manager/reolink/camera.ts @@ -1,6 +1,7 @@ import { ActionsExecutor } from '../../card-controller/actions/types'; import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { PTZAction, PTZActionPhase } 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'; import { localize } from '../../localize/localize'; @@ -18,6 +19,7 @@ const REOLINK_DEFAULT_CHANNEL = 0; interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { entityRegistryManager: EntityRegistryManager; + deviceRegistryManager: DeviceRegistryManager; hass: HomeAssistant; } @@ -50,7 +52,7 @@ export class ReolinkCamera extends EntityCamera { public async initialize(options: ReolinkCameraInitializationOptions): Promise { await super.initialize(options); - this._initializeChannel(); + await this._initializeChannel(options.hass, options.deviceRegistryManager); await this._initializeCapabilities( options.hass, options.entityRegistryManager, @@ -59,7 +61,32 @@ export class ReolinkCamera extends EntityCamera { return this; } - protected _initializeChannel(): void { + protected async _getChannelFromConfigurationURL( + hass: HomeAssistant, + deviceRegistryManager: DeviceRegistryManager, + ): Promise { + const deviceID = this._entity?.device_id; + if (!deviceID) { + return null; + } + const device = await deviceRegistryManager.getDevice(hass, deviceID); + if (!device?.configuration_url) { + return null; + } + try { + const url = new URL(device.configuration_url); + const channel = Number(url.searchParams.get('ch')); + return isNaN(channel) ? null : channel; + } catch { + // Ignore invalid URLs. + return null; + } + } + + protected async _initializeChannel( + hass: HomeAssistant, + deviceRegistryManager: DeviceRegistryManager, + ): Promise { const uniqueID = this._entity?.unique_id; // Reolink camera unique IDs are dual-mode, they may be in either of these @@ -95,8 +122,14 @@ export class ReolinkCamera extends EntityCamera { const channelCandidate = Number(channelOrUID); const isValidChannel = !isNaN(channelCandidate) && channelCandidate <= 999; const channel = - this._config.reolink.channel ?? - (isValidChannel ? channelCandidate : REOLINK_DEFAULT_CHANNEL); + // Channel from the unique ID itself (for directly connected cameras). + (isValidChannel ? channelCandidate : null) ?? + // Channel from the configuration URL (for NVRs where the entity unique + // id is based on the UID). + (await this._getChannelFromConfigurationURL(hass, deviceRegistryManager)) ?? + // Fallback. + REOLINK_DEFAULT_CHANNEL; + const reolinkCameraUID = !isValidChannel ? channelOrUID : null; this._reolinkChannel = channel; diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index bd76e3b6..2263193b 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -1,5 +1,6 @@ import { add, endOfDay, parse, startOfDay } from 'date-fns'; import { orderBy } from 'lodash-es'; +import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { CameraConfig } from '../../config/schema/cameras'; import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media'; import { sortMostRecentFirst } from '../../ha/browse-media/sort'; @@ -11,7 +12,11 @@ import { MEDIA_CLASS_VIDEO, RichBrowseMedia, } from '../../ha/browse-media/types'; +import { BrowseMediaWalker } from '../../ha/browse-media/walker'; import { isMediaWithinDates } from '../../ha/browse-media/within-dates'; +import { DeviceRegistryManager } from '../../ha/registry/device'; +import { EntityRegistryManager } from '../../ha/registry/entity/types'; +import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; import { allPromises, formatDate, isValidDate } from '../../utils/basic'; @@ -23,7 +28,9 @@ import { CameraManagerReadOnlyConfigStore } from '../store'; import { CameraEndpoints, CameraEndpointsContext, + CameraEventCallback, CameraManagerCameraMetadata, + CameraManagerRequestCache, Engine, EngineOptions, EventQuery, @@ -50,6 +57,27 @@ export class ReolinkQueryResultsClassifier { export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { protected _camerasCache = new BrowseMediaCache(); protected _cache = new BrowseMediaCache(); + protected _deviceRegistryManager: DeviceRegistryManager; + + public constructor( + entityRegistryManager: EntityRegistryManager, + deviceRegistryManager: DeviceRegistryManager, + stateWatcher: StateWatcherSubscriptionInterface, + browseMediaManager: BrowseMediaWalker, + resolvedMediaCache: ResolvedMediaCache, + requestCache: CameraManagerRequestCache, + eventCallback?: CameraEventCallback, + ) { + super( + entityRegistryManager, + stateWatcher, + browseMediaManager, + resolvedMediaCache, + requestCache, + eventCallback, + ); + this._deviceRegistryManager = deviceRegistryManager; + } public getEngineType(): Engine { return Engine.Reolink; @@ -147,6 +175,7 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { }); return await camera.initialize({ entityRegistryManager: this._entityRegistryManager, + deviceRegistryManager: this._deviceRegistryManager, hass, stateWatcher: this._stateWatcher, }); diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 05a34e26..b4eab40a 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -1,6 +1,7 @@ import type { CameraManager } from '../camera-manager/manager'; import type { ConditionStateManager } from '../conditions/state-manager'; import type { Automation } from '../config/schema/automations'; +import type { DeviceRegistryManager } from '../ha/registry/device'; import type { EntityRegistryManager } from '../ha/registry/entity/types'; import type { ResolvedMediaCache } from '../ha/resolved-media'; import type { EffectsControllerAPI } from '../types'; @@ -70,6 +71,7 @@ export interface CardAutomationsAPI { export interface CardCameraAPI { getActionsManager(): ActionsManager; getConfigManager(): ConfigManager; + getDeviceRegistryManager(): DeviceRegistryManager; getEntityRegistryManager(): EntityRegistryManager; getHASSManager(): HASSManager; getMessageManager(): MessageManager; diff --git a/src/config/schema/cameras.ts b/src/config/schema/cameras.ts index 9d145924..b0b0b8a7 100644 --- a/src/config/schema/cameras.ts +++ b/src/config/schema/cameras.ts @@ -250,7 +250,6 @@ 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/src/ha/registry/device/types.ts b/src/ha/registry/device/types.ts index f3dc6c2c..a98d9d49 100644 --- a/src/ha/registry/device/types.ts +++ b/src/ha/registry/device/types.ts @@ -6,6 +6,7 @@ const deviceSchema = z.object({ model: z.string().nullable(), config_entries: z.string().array(), manufacturer: z.string().nullable(), + configuration_url: z.string().nullable().optional(), }); export type Device = z.infer; diff --git a/tests/camera-manager/reolink/camera.test.ts b/tests/camera-manager/reolink/camera.test.ts index 812d4879..efd54c38 100644 --- a/tests/camera-manager/reolink/camera.test.ts +++ b/tests/camera-manager/reolink/camera.test.ts @@ -6,6 +6,7 @@ import { CameraProxyConfig } from '../../../src/camera-manager/types'; import { ActionsExecutor } from '../../../src/card-controller/actions/types'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { EntityRegistryManagerLive } from '../../../src/ha/registry/entity'; +import { DeviceRegistryManager } from '../../../src/ha/registry/device'; import { EntityRegistryManagerMock } from '../../ha/registry/entity/mock'; import { createCameraConfig, @@ -97,6 +98,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: mock(), + deviceRegistryManager: mock(), stateWatcher: mock(), }), ).rejects.toThrowError('Could not find camera entity'); @@ -143,37 +145,12 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }), ).rejects.toThrowError('Could not initialize Reolink camera'); }); - // 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(), - }); - - expect(camera.getChannel()).toBe(42); - }); - it('successfully with an NVR-connected camera without user-specified channel', async () => { const config = createCameraConfig({ camera_entity: 'camera.office_reolink', @@ -190,6 +167,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); @@ -206,11 +184,166 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); expect(camera.getChannel()).toBe(0); }); + + it('successfully with an NVR-connected camera with channel in configuration_url', 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', + device_id: 'device-id', + platform: 'reolink', + }), + ]); + const deviceRegistryManager = mock(); + deviceRegistryManager.getDevice.mockResolvedValue({ + id: 'device-id', + configuration_url: 'http://192.168.1.1?ch=3', + config_entries: [], + manufacturer: 'Reolink', + model: 'RLN8-410', + }); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + deviceRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(3); + }); + + describe('should detect channel from configuration URL', () => { + it('should return null if device_id is missing', 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', + // No device_id + platform: 'reolink', + }), + ]); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + deviceRegistryManager: mock(), + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); + + it('should return null if device has no configuration_url', 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', + device_id: 'device-id', + platform: 'reolink', + }), + ]); + const deviceRegistryManager = mock(); + deviceRegistryManager.getDevice.mockResolvedValue({ + id: 'device-id', + // No configuration_url + config_entries: [], + manufacturer: 'Reolink', + model: 'RLN8-410', + }); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + deviceRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); + + it('should return null if configuration_url is invalid', 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', + device_id: 'device-id', + platform: 'reolink', + }), + ]); + const deviceRegistryManager = mock(); + deviceRegistryManager.getDevice.mockResolvedValue({ + id: 'device-id', + configuration_url: 'invalid-url', + config_entries: [], + manufacturer: 'Reolink', + model: 'RLN8-410', + }); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + deviceRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); + + it('should return null if ch parameter is not a number', 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', + device_id: 'device-id', + platform: 'reolink', + }), + ]); + const deviceRegistryManager = mock(); + deviceRegistryManager.getDevice.mockResolvedValue({ + id: 'device-id', + configuration_url: 'http://192.168.1.1?ch=NOT_A_NUMBER', + config_entries: [], + manufacturer: 'Reolink', + model: 'RLN8-410', + }); + + await camera.initialize({ + hass: createHASS(), + entityRegistryManager, + deviceRegistryManager, + stateWatcher: mock(), + }); + + expect(camera.getChannel()).toBe(0); + }); + }); }); describe('successfully with PTZ', () => { @@ -223,6 +356,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); @@ -261,6 +395,7 @@ describe('ReolinkCamera', () => { platform: 'reolink', }), ]), + deviceRegistryManager: mock(), stateWatcher: mock(), }); @@ -286,6 +421,7 @@ describe('ReolinkCamera', () => { }), }), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); @@ -318,6 +454,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); @@ -504,6 +641,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]), + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); @@ -535,6 +673,7 @@ describe('ReolinkCamera', () => { cameraEntity, buttonEntityPTZLeft, ]), + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); @@ -561,6 +700,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); @@ -601,6 +741,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); @@ -626,6 +767,7 @@ describe('ReolinkCamera', () => { }), }), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); @@ -656,6 +798,7 @@ describe('ReolinkCamera', () => { await camera.initialize({ hass: createHASS(), entityRegistryManager: ptzPopulatedEntityRegistryManager, + deviceRegistryManager: mock(), stateWatcher: mock(), }); const executor = mock(); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index af436c8f..c8cf1d07 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -26,6 +26,7 @@ import { import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { BrowseMedia, browseMediaSchema } from '../../../src/ha/browse-media/types'; import { BrowseMediaWalker } from '../../../src/ha/browse-media/walker'; +import { DeviceRegistryManager } from '../../../src/ha/registry/device'; import { EntityRegistryManager } from '../../../src/ha/registry/entity/types'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { homeAssistantWSRequest } from '../../../src/ha/ws-request'; @@ -183,6 +184,7 @@ const createEngine = (options?: { }): ReolinkCameraManagerEngine => { return new ReolinkCameraManagerEngine( options?.entityRegistryManager ?? new EntityRegistryManagerMock(), + mock(), mock(), options?.browseMediaManager ?? new BrowseMediaWalker(), new ResolvedMediaCache(),