feat: Add out of the box support for Reolink PTZ (#1982)

- Closes: #1964
This commit is contained in:
Dermot Duffy
2025-03-26 21:26:02 -07:00
committed by GitHub
parent f6bb8001e3
commit b5eee5878a
50 changed files with 953 additions and 679 deletions
+1 -2
View File
@@ -1,7 +1,6 @@
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
import { Entity } from '../../utils/ha/registry/entity/types';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { CameraInitializationError } from '../error';
@@ -4,7 +4,7 @@ import { HomeAssistant } from '../../ha/types';
import { canonicalizeHAURL } from '../../utils/ha';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
+19
View File
@@ -1,4 +1,6 @@
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 { CameraConfig } from '../config/schema/cameras';
import { localize } from '../localize/localize';
import { HassStateDifference, isTriggeredState } from '../utils/ha';
@@ -6,6 +8,7 @@ import { Capabilities } from './capabilities';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import { CameraEventCallback, CameraProxyConfig } from './types';
import { getConfiguredPTZAction } from './utils/ptz';
export interface CameraInitializationOptions {
stateWatcher: StateWatcherSubscriptionInterface;
@@ -83,6 +86,22 @@ export class Camera {
};
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
const configuredAction = getConfiguredPTZAction(this.getConfig(), action, options);
if (configuredAction) {
await executor.executeActions({ actions: configuredAction });
return true;
}
return false;
}
protected _stateChangeHandler = (difference: HassStateDifference): void => {
this._eventCallback?.({
cameraID: this.getID(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { EntityRegistryManager } from '../utils/ha/registry/entity';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine';
-11
View File
@@ -1,4 +1,3 @@
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types';
import { ViewMedia } from '../view/media';
@@ -128,14 +127,4 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null;
executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void>;
}
+54 -4
View File
@@ -1,12 +1,13 @@
import uniq from 'lodash-es/uniq';
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 { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
import { Entity } from '../../utils/ha/registry/entity/types';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
@@ -57,6 +58,55 @@ export class FrigateCamera extends Camera {
return await super.initialize(options);
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
if (await super.executePTZAction(executor, action, options)) {
return true;
}
const cameraEntity = this.getConfig().camera_entity;
if ((action === 'preset' && !options?.preset) || !cameraEntity) {
return false;
}
// Awkward translation between card action and service parameters:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml
await executor.executeActions({
actions: {
action: 'perform-action',
perform_action: 'frigate.ptz',
data: {
action:
options?.phase === 'stop'
? 'stop'
: action === 'zoom_in' || action === 'zoom_out'
? 'zoom'
: action === 'preset'
? 'preset'
: 'move',
...(options?.phase !== 'stop' && {
argument:
action === 'zoom_in'
? 'in'
: action === 'zoom_out'
? 'out'
: action === 'preset'
? options?.preset
: action,
}),
},
target: { entity_id: cameraEntity },
},
});
return true;
}
protected async _initializeConfig(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
@@ -194,10 +244,10 @@ export class FrigateCamera extends Camera {
// Note: The Frigate integration only supports continuous PTZ movements
// (regardless of the actual underlying camera capability).
const panTilt: PTZMovementType[] = [
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('pt') ? [PTZMovementType.Continuous] : []),
];
const zoom: PTZMovementType[] = [
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('zoom') ? [PTZMovementType.Continuous] : []),
];
const presets = ptzInfo.presets;
+1 -42
View File
@@ -4,7 +4,6 @@ import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types';
import {
@@ -14,7 +13,7 @@ import {
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
@@ -1005,44 +1004,4 @@ export class FrigateCameraManagerEngine
...(jsmpeg && { jsmpeg: jsmpeg }),
};
}
public async executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void> {
const cameraEntity = cameraConfig.camera_entity;
if (action === 'preset' && !options?.preset) {
return;
}
// Awkward translation between card action and service parameters:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml
await hass.callService('frigate', 'ptz', {
entity_id: cameraEntity,
action:
options?.phase === 'stop'
? 'stop'
: action === 'zoom_in' || action === 'zoom_out'
? 'zoom'
: action === 'preset'
? 'preset'
: 'move',
...(options?.phase !== 'stop' && {
argument:
action === 'zoom_in'
? 'in'
: action === 'zoom_out'
? 'out'
: action === 'preset'
? options?.preset
: action,
}),
});
}
}
@@ -235,16 +235,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}
: null;
}
public async executePTZAction(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_action: PTZAction,
_options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void> {
// Pass.
}
}
+4 -16
View File
@@ -55,7 +55,6 @@ import {
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js';
import { getConfiguredPTZAction } from './utils/ptz.js';
import { sortMedia } from './utils/sort-media.js';
export class QueryClassifier {
@@ -789,23 +788,12 @@ export class CameraManager {
preset?: string;
},
): Promise<void> {
const cameraConfig = this._store.getCameraConfig(cameraID);
if (!cameraConfig) {
const camera = this._store.getCamera(cameraID);
if (!camera) {
return;
}
const configuredAction = getConfiguredPTZAction(cameraConfig, action, options);
if (configuredAction) {
return await this._api.getActionsManager().executeActions(configuredAction);
}
const hass = this._api.getHASSManager().getHASS();
const engine = this._store.getEngineForCameraID(cameraID);
if (!engine || !hass) {
return;
}
return await this._requestLimit.add(() =>
engine.executePTZAction(hass, cameraConfig, action, options),
await this._requestLimit.add(() =>
camera.executePTZAction(this._api.getActionsManager(), action, options),
);
}
}
+154 -4
View File
@@ -1,10 +1,15 @@
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraInitializationError } from '../error';
import { CameraProxyConfig } from '../types';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager;
@@ -13,27 +18,138 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
class ReolinkInitializationError extends CameraInitializationError {}
interface PTZActionToButtonEntity {
stop?: string;
left?: string;
right?: string;
up?: string;
down?: string;
zoomIn?: string;
zoomOut?: string;
}
export class ReolinkCamera extends BrowseMediaCamera {
protected _channel: number | null = null;
protected _reolinkUniqueID: string | null = null;
protected _ptzButtons: PTZActionToButtonEntity | null = null;
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
await super.initialize(options);
this._initializeChannel();
await this._initializeCapabilities(options.hass, options.entityRegistryManager);
return this;
}
protected _initializeChannel(): void {
const uniqueID = this._entity?.unique_id;
const match = uniqueID ? String(uniqueID).match(/(.*)_(?<channel>\d+)/) : null;
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
const match = uniqueID
? String(uniqueID).match(/(?<uniqueid>.*)_(?<channel>\d+)/)
: null;
if (channel === null) {
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
const reolinkUniqueID = match?.groups?.uniqueid ?? null;
if (channel === null || reolinkUniqueID === null) {
throw new ReolinkInitializationError(
localize('error.camera_initialization_reolink'),
this.getConfig(),
);
}
this._channel = channel;
this._reolinkUniqueID = reolinkUniqueID;
}
protected async _initializeCapabilities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
): Promise<void> {
const config = this.getConfig();
const ptzButtonMap = await this._getPTZButtonEntities(hass, entityRegistry);
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
const reolinkPTZCapabilities = ptzButtonMap
? Object.keys(ptzButtonMap).reduce(
(acc, key) =>
key === 'stop' ? acc : { [key]: [PTZMovementType.Continuous], ...acc },
{},
)
: null;
const combinedPTZCapabilities: PTZCapabilities | null =
configPTZCapabilities || reolinkPTZCapabilities
? {
...reolinkPTZCapabilities,
...configPTZCapabilities,
}
: null;
this._capabilities = new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
'remote-control-entity': true,
clips: true,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: false,
substream: true,
trigger: true,
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
},
{
disable: config.capabilities?.disable,
disableExcept: config.capabilities?.disable_except,
},
);
this._ptzButtons = ptzButtonMap;
}
protected async _getPTZButtonEntities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
): Promise<PTZActionToButtonEntity | 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) {
return null;
}
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
const buttonEntities = await entityRegistry.getMatchingEntities(
hass,
(ent: Entity) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!!ent.unique_id &&
String(ent.unique_id).startsWith(uniqueIDPrefix) &&
!ent.disabled_by &&
ent.entity_id.startsWith('button.'),
);
const capabilityMap = {
_ptz_stop: 'stop',
_ptz_left: 'left',
_ptz_right: 'right',
_ptz_up: 'up',
_ptz_down: 'down',
_ptz_zoom_in: 'zoomIn',
_ptz_zoom_out: 'zoomOut',
};
const buttonMap: PTZActionToButtonEntity = {};
for (const buttonEntity of buttonEntities) {
for (const [uniqueIDSuffix, capability] of Object.entries(capabilityMap)) {
if (
buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
) {
buttonMap[capability] = buttonEntity.entity_id;
}
}
}
return Object.keys(buttonMap).length ? buttonMap : null;
}
public getChannel(): number | null {
@@ -61,4 +177,38 @@ export class ReolinkCamera extends BrowseMediaCamera {
: this._config.proxy.ssl_ciphers,
};
}
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
if (await super.executePTZAction(executor, action, options)) {
return true;
}
const entityID =
options?.phase === 'start'
? this._ptzButtons?.[action]
: options?.phase === 'stop'
? this._ptzButtons?.stop
: null;
if (!entityID) {
return false;
}
await executor.executeActions({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: { entity_id: entityID },
},
],
});
return true;
}
}
@@ -17,7 +17,6 @@ import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-m
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
@@ -37,7 +36,6 @@ import {
QueryResultsType,
QueryReturnType,
} from '../types';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
import { ReolinkCamera } from './camera';
import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types';
@@ -144,26 +142,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
cameraConfig: CameraConfig,
): Promise<Camera> {
const camera = new ReolinkCamera(cameraConfig, this, {
capabilities: new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
'remote-control-entity': true,
clips: true,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: false,
substream: true,
trigger: true,
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
},
{
disable: cameraConfig.capabilities?.disable,
disableExcept: cameraConfig.capabilities?.disable_except,
},
),
eventCallback: this._eventCallback,
});
return await camera.initialize({
+2 -2
View File
@@ -48,8 +48,8 @@ export const getConfiguredPTZMovementType = (
return continuous || relative
? [
...(continuous ? ['continuous' as const] : []),
...(relative ? ['relative' as const] : []),
...(continuous ? [PTZMovementType.Continuous] : []),
...(relative ? [PTZMovementType.Relative] : []),
]
: null;
};