fix: Reolink PTZ support should work with hub-connected cameras (#2014)
- Related: #1964
This commit is contained in:
@@ -97,10 +97,13 @@ cameras:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| 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. |
|
| `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. |
|
| `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
|
### PTZ Support
|
||||||
|
|
||||||
Zero-configuration PTZ support is available for Reolink if your camera supports it.
|
Zero-configuration PTZ support is available for Reolink if your camera supports it.
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { CameraInitializationError } from '../error';
|
|||||||
import { CameraProxyConfig } from '../types';
|
import { CameraProxyConfig } from '../types';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||||
|
|
||||||
|
// Reolink channels are zero indexed.
|
||||||
|
const REOLINK_DEFAULT_CHANNEL = 0;
|
||||||
|
|
||||||
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
hass: HomeAssistant;
|
hass: HomeAssistant;
|
||||||
@@ -32,8 +35,16 @@ interface PTZEntities {
|
|||||||
type PTZEntity = keyof PTZEntities;
|
type PTZEntity = keyof PTZEntities;
|
||||||
|
|
||||||
export class ReolinkCamera extends BrowseMediaCamera {
|
export class ReolinkCamera extends BrowseMediaCamera {
|
||||||
protected _channel: number | null = null;
|
// The HostID identifying the camera or NVR.
|
||||||
protected _reolinkUniqueID: string | null = null;
|
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;
|
protected _ptzEntities: PTZEntities | null = null;
|
||||||
|
|
||||||
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||||
@@ -45,21 +56,47 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
|||||||
|
|
||||||
protected _initializeChannel(): void {
|
protected _initializeChannel(): void {
|
||||||
const uniqueID = this._entity?.unique_id;
|
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
|
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;
|
: null;
|
||||||
|
|
||||||
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
|
const hostid = match?.groups?.hostid ?? null;
|
||||||
const reolinkUniqueID = match?.groups?.uniqueid ?? null;
|
const channelOrUID = match?.groups?.channel_or_uid ?? null;
|
||||||
|
|
||||||
if (channel === null || reolinkUniqueID === null) {
|
if (hostid === null || channelOrUID === null) {
|
||||||
throw new ReolinkInitializationError(
|
throw new ReolinkInitializationError(
|
||||||
localize('error.camera_initialization_reolink'),
|
localize('error.camera_initialization_reolink'),
|
||||||
this.getConfig(),
|
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(
|
protected async _initializeCapabilities(
|
||||||
@@ -144,11 +181,11 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
|||||||
): Promise<PTZEntities | null> {
|
): Promise<PTZEntities | null> {
|
||||||
/* istanbul ignore next: this path cannot be reached as an exception is
|
/* istanbul ignore next: this path cannot be reached as an exception is
|
||||||
thrown in initialize() if this value is not found -- @preserve */
|
thrown in initialize() if this value is not found -- @preserve */
|
||||||
if (!this._reolinkUniqueID) {
|
if (!this._reolinkHostID) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
|
const uniqueIDPrefix = this._getPTZEntityUniqueIDPrefix();
|
||||||
const allRelevantEntities = await entityRegistry.getMatchingEntities(
|
const allRelevantEntities = await entityRegistry.getMatchingEntities(
|
||||||
hass,
|
hass,
|
||||||
(ent: Entity) =>
|
(ent: Entity) =>
|
||||||
@@ -196,7 +233,11 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getChannel(): number | null {
|
public getChannel(): number | null {
|
||||||
return this._channel;
|
return this._reolinkChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _getPTZEntityUniqueIDPrefix(): string {
|
||||||
|
return `${this._reolinkHostID}_${this._reolinkCameraUID ?? this._reolinkChannel}_`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getProxyConfig(): CameraProxyConfig {
|
public getProxyConfig(): CameraProxyConfig {
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export const cameraConfigSchema = z
|
|||||||
reolink: z
|
reolink: z
|
||||||
.object({
|
.object({
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
|
channel: z.number().optional(),
|
||||||
media_resolution: z
|
media_resolution: z
|
||||||
.enum(['high', 'low'])
|
.enum(['high', 'low'])
|
||||||
.default(cameraConfigDefault.reolink.media_resolution),
|
.default(cameraConfigDefault.reolink.media_resolution),
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe('ReolinkCamera', () => {
|
|||||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
).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({
|
const config = createCameraConfig({
|
||||||
camera_entity: 'camera.office_reolink',
|
camera_entity: 'camera.office_reolink',
|
||||||
});
|
});
|
||||||
@@ -148,9 +148,56 @@ describe('ReolinkCamera', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrowError('Could not initialize Reolink camera');
|
).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>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
it('successfully with main camera', async () => {
|
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',
|
||||||
|
});
|
||||||
|
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({
|
const config = createCameraConfig({
|
||||||
camera_entity: 'camera.office_reolink',
|
camera_entity: 'camera.office_reolink',
|
||||||
});
|
});
|
||||||
@@ -165,9 +212,10 @@ describe('ReolinkCamera', () => {
|
|||||||
|
|
||||||
expect(camera.getChannel()).toBe(0);
|
expect(camera.getChannel()).toBe(0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('successfully with PTZ', () => {
|
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({
|
const config = createCameraConfig({
|
||||||
camera_entity: 'camera.office_reolink',
|
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 () => {
|
it('should find PTZ select entity', async () => {
|
||||||
const config = createCameraConfig({
|
const config = createCameraConfig({
|
||||||
camera_entity: 'camera.office_reolink',
|
camera_entity: 'camera.office_reolink',
|
||||||
|
|||||||
Reference in New Issue
Block a user