fix: Reolink PTZ support should work with hub-connected cameras (#2014)

- Related: #1964
This commit is contained in:
Dermot Duffy
2025-04-16 10:58:18 +01:00
committed by GitHub
parent 168b9b0c90
commit 2eb0d9e35e
4 changed files with 156 additions and 29 deletions
+7 -4
View File
@@ -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
+52 -11
View File
@@ -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<Camera> {
@@ -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(/(?<uniqueid>.*)_(?<channel>\d+)/)
? String(uniqueID).match(
/^(?<hostid>[A-Za-z0-9]+)_(?<channel_or_uid>[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<PTZEntities | null> {
/* 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 {
+1
View File
@@ -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),
+96 -14
View File
@@ -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<CameraManagerEngine>());
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<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>(),
await camera.initialize({
hass: createHASS(),
entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
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<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(0);
});
it('successfully with a directly connected camera', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
await camera.initialize({
hass: createHASS(),
entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
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<CameraManagerEngine>());
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<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
zoomIn: ['continuous'],
zoomOut: ['continuous'],
});
});
it('should find PTZ select entity', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',