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
+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,
});