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.
This commit is contained in:
Dermot Duffy
2025-12-07 22:48:33 -08:00
committed by GitHub
parent c38bf192e0
commit f276b8c57d
10 changed files with 256 additions and 41 deletions
+2 -5
View File
@@ -99,14 +99,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. |
> [!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.
### PTZ Support
Zero-configuration PTZ support is available for Reolink if your camera supports it.
+8 -2
View File
@@ -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,
+4 -1
View File
@@ -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();
}
+37 -4
View File
@@ -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<Camera> {
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<number | null> {
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<void> {
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;
@@ -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<BrowseMediaReolinkCameraMetadata>();
protected _cache = new BrowseMediaCache<BrowseMediaMetadata>();
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,
});
+2
View File
@@ -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;
-1
View File
@@ -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),
+1
View File
@@ -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<typeof deviceSchema>;
+169 -26
View File
@@ -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<EntityRegistryManagerLive>(),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
}),
).rejects.toThrowError('Could not find camera entity');
@@ -143,37 +145,12 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
}),
).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<CameraManagerEngine>());
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<StateWatcher>(),
});
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<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
@@ -206,11 +184,166 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
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<CameraManagerEngine>());
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>();
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<StateWatcher>(),
});
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<CameraManagerEngine>());
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<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
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<CameraManagerEngine>());
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>();
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<StateWatcher>(),
});
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<CameraManagerEngine>());
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>();
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<StateWatcher>(),
});
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<CameraManagerEngine>());
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>();
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<StateWatcher>(),
});
expect(camera.getChannel()).toBe(0);
});
});
});
describe('successfully with PTZ', () => {
@@ -223,6 +356,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
@@ -261,6 +395,7 @@ describe('ReolinkCamera', () => {
platform: 'reolink',
}),
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
@@ -286,6 +421,7 @@ describe('ReolinkCamera', () => {
}),
}),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
@@ -318,6 +454,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
@@ -504,6 +641,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -535,6 +673,7 @@ describe('ReolinkCamera', () => {
cameraEntity,
buttonEntityPTZLeft,
]),
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -561,6 +700,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -601,6 +741,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -626,6 +767,7 @@ describe('ReolinkCamera', () => {
}),
}),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -656,6 +798,7 @@ describe('ReolinkCamera', () => {
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
deviceRegistryManager: mock<DeviceRegistryManager>(),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
@@ -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<DeviceRegistryManager>(),
mock<StateWatcher>(),
options?.browseMediaManager ?? new BrowseMediaWalker(),
new ResolvedMediaCache(),