fix: Reolink PTZ support should detect presets (#1997)
- Related: #1964
This commit is contained in:
@@ -3,6 +3,7 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
@@ -18,7 +19,7 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
|
||||
|
||||
class ReolinkInitializationError extends CameraInitializationError {}
|
||||
|
||||
interface PTZButtonEntities {
|
||||
interface PTZEntities {
|
||||
stop?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
@@ -26,13 +27,14 @@ interface PTZButtonEntities {
|
||||
down?: string;
|
||||
zoom_in?: string;
|
||||
zoom_out?: string;
|
||||
presets?: string;
|
||||
}
|
||||
type PTZButton = keyof PTZButtonEntities;
|
||||
type PTZEntity = keyof PTZEntities;
|
||||
|
||||
export class ReolinkCamera extends BrowseMediaCamera {
|
||||
protected _channel: number | null = null;
|
||||
protected _reolinkUniqueID: string | null = null;
|
||||
protected _ptzButtons: PTZButtonEntities | null = null;
|
||||
protected _ptzEntities: PTZEntities | null = null;
|
||||
|
||||
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||
await super.initialize(options);
|
||||
@@ -65,30 +67,14 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
||||
entityRegistry: EntityRegistryManager,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
|
||||
const ptzButtons = await this._getPTZButtons(hass, entityRegistry);
|
||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
|
||||
const reolinkPTZCapabilities: PTZCapabilities = {};
|
||||
for (const key of Object.keys(ptzButtons ?? {})) {
|
||||
switch (key) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
case 'up':
|
||||
case 'down':
|
||||
reolinkPTZCapabilities[key] = [PTZMovementType.Continuous];
|
||||
break;
|
||||
case 'zoom_in':
|
||||
reolinkPTZCapabilities.zoomIn = [PTZMovementType.Continuous];
|
||||
break;
|
||||
case 'zoom_out':
|
||||
reolinkPTZCapabilities.zoomOut = [PTZMovementType.Continuous];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ptzEntities = await this._getPTZEntities(hass, entityRegistry);
|
||||
const reolinkPTZCapabilities = ptzEntities
|
||||
? this._entitiesToCapabilities(hass, ptzEntities)
|
||||
: null;
|
||||
|
||||
const combinedPTZCapabilities: PTZCapabilities | null =
|
||||
configPTZCapabilities || Object.keys(reolinkPTZCapabilities).length
|
||||
configPTZCapabilities || reolinkPTZCapabilities
|
||||
? {
|
||||
...reolinkPTZCapabilities,
|
||||
...configPTZCapabilities,
|
||||
@@ -115,13 +101,47 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
||||
disableExcept: config.capabilities?.disable_except,
|
||||
},
|
||||
);
|
||||
this._ptzButtons = ptzButtons;
|
||||
this._ptzEntities = ptzEntities;
|
||||
}
|
||||
|
||||
protected async _getPTZButtons(
|
||||
protected _entitiesToCapabilities(
|
||||
hass: HomeAssistant,
|
||||
ptzEntities: PTZEntities,
|
||||
): PTZCapabilities | null {
|
||||
const reolinkPTZCapabilities: PTZCapabilities = {};
|
||||
for (const key of Object.keys(ptzEntities)) {
|
||||
switch (key) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
case 'up':
|
||||
case 'down':
|
||||
reolinkPTZCapabilities[key] = [PTZMovementType.Continuous];
|
||||
break;
|
||||
case 'zoom_in':
|
||||
reolinkPTZCapabilities.zoomIn = [PTZMovementType.Continuous];
|
||||
break;
|
||||
case 'zoom_out':
|
||||
reolinkPTZCapabilities.zoomOut = [PTZMovementType.Continuous];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const ptzPresetsEntityState = ptzEntities?.presets
|
||||
? hass.states[ptzEntities.presets]
|
||||
: null;
|
||||
if (Array.isArray(ptzPresetsEntityState?.attributes.options)) {
|
||||
reolinkPTZCapabilities.presets = ptzPresetsEntityState.attributes.options;
|
||||
}
|
||||
|
||||
/* istanbul ignore next: this path cannot be reached as ptzEntities will
|
||||
always have contents when this function is called -- @preserve */
|
||||
return Object.keys(reolinkPTZCapabilities).length ? reolinkPTZCapabilities : null;
|
||||
}
|
||||
|
||||
protected async _getPTZEntities(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryManager,
|
||||
): Promise<PTZButtonEntities | null> {
|
||||
): 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) {
|
||||
@@ -129,17 +149,24 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
||||
}
|
||||
|
||||
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
|
||||
const buttonEntities = await entityRegistry.getMatchingEntities(
|
||||
const allRelevantEntities = 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.'),
|
||||
!ent.disabled_by,
|
||||
);
|
||||
const buttonEntities = allRelevantEntities.filter((ent: Entity) =>
|
||||
ent.entity_id.startsWith('button.'),
|
||||
);
|
||||
const ptzPresetEntities = allRelevantEntities.filter(
|
||||
(ent: Entity) =>
|
||||
ent.unique_id === `${uniqueIDPrefix}ptz_preset` &&
|
||||
ent.entity_id.startsWith('select.'),
|
||||
);
|
||||
|
||||
const uniqueSuffixes: PTZButton[] = [
|
||||
const uniqueSuffixes: PTZEntity[] = [
|
||||
'stop',
|
||||
'left',
|
||||
'right',
|
||||
@@ -149,19 +176,23 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
||||
'zoom_out',
|
||||
];
|
||||
|
||||
const buttons: PTZButtonEntities = {};
|
||||
const ptzEntities: PTZEntities = {};
|
||||
for (const buttonEntity of buttonEntities) {
|
||||
for (const uniqueIDSuffix of uniqueSuffixes) {
|
||||
if (
|
||||
buttonEntity.unique_id &&
|
||||
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
|
||||
) {
|
||||
buttons[uniqueIDSuffix] = buttonEntity.entity_id;
|
||||
ptzEntities[uniqueIDSuffix] = buttonEntity.entity_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(buttons).length ? buttons : null;
|
||||
if (ptzPresetEntities.length === 1) {
|
||||
ptzEntities.presets = ptzPresetEntities[0].entity_id;
|
||||
}
|
||||
|
||||
return Object.keys(ptzEntities).length ? ptzEntities : null;
|
||||
}
|
||||
|
||||
public getChannel(): number | null {
|
||||
@@ -202,11 +233,28 @@ export class ReolinkCamera extends BrowseMediaCamera {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._ptzEntities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === 'preset') {
|
||||
const entityID = this._ptzEntities.presets;
|
||||
const preset = options?.preset;
|
||||
if (!preset || !entityID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await executor.executeActions({
|
||||
actions: [createSelectOptionAction('select', entityID, preset)],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const entityID =
|
||||
options?.phase === 'start'
|
||||
? this._ptzButtons?.[action]
|
||||
? this._ptzEntities[action]
|
||||
: options?.phase === 'stop'
|
||||
? this._ptzButtons?.stop
|
||||
? this._ptzEntities.stop
|
||||
: null;
|
||||
if (!entityID) {
|
||||
return false;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RemoteControlEntityPriority } from '../../config/schema/remote-control'
|
||||
import {
|
||||
createCameraAction,
|
||||
createInternalCallbackAction,
|
||||
createPerformAction,
|
||||
createSelectOptionAction,
|
||||
} from '../../utils/action';
|
||||
import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types';
|
||||
|
||||
@@ -21,16 +21,6 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
const cameraPriority: RemoteControlEntityPriority =
|
||||
remoteControlConfig.entities.camera_priority;
|
||||
|
||||
const createSelectOptionAction = (option: string) =>
|
||||
createPerformAction('input_select.select_option', {
|
||||
target: {
|
||||
entity_id: cameraControlEntity,
|
||||
},
|
||||
data: {
|
||||
option: option,
|
||||
},
|
||||
});
|
||||
|
||||
// Control entities functionality is implemented entirely by populating
|
||||
// automations.
|
||||
|
||||
@@ -59,7 +49,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
],
|
||||
actions: [
|
||||
// When the camera changes, update the entity to match.
|
||||
createSelectOptionAction('{{ advanced_camera_card.trigger.camera.to }}'),
|
||||
createSelectOptionAction(
|
||||
'input_select',
|
||||
cameraControlEntity,
|
||||
'{{ advanced_camera_card.trigger.camera.to }}',
|
||||
),
|
||||
],
|
||||
tag: automationTag,
|
||||
},
|
||||
@@ -82,7 +76,11 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
`{{ hass.states["${cameraControlEntity}"].state }}`,
|
||||
)
|
||||
: // Set the selected option in the entity to the current camera ID.
|
||||
createSelectOptionAction('{{ advanced_camera_card.camera }}'),
|
||||
createSelectOptionAction(
|
||||
'input_select',
|
||||
cameraControlEntity,
|
||||
'{{ advanced_camera_card.camera }}',
|
||||
),
|
||||
],
|
||||
tag: automationTag,
|
||||
},
|
||||
|
||||
@@ -10,9 +10,11 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js';
|
||||
import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js';
|
||||
import { computeDomain } from '../../ha/compute-domain.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import menuButtonStyle from '../../scss/menu-button.scss';
|
||||
import { Icon } from '../../types.js';
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { getEntityTitle, isHassDifferent } from '../../utils/ha';
|
||||
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js';
|
||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js';
|
||||
@@ -83,6 +85,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
||||
}
|
||||
|
||||
const entityID = this.submenuSelect.entity;
|
||||
const entityDomain = computeDomain(entityID);
|
||||
const stateObj = this.hass.states[entityID];
|
||||
const options = stateObj?.attributes?.options;
|
||||
if (!stateObj || !options) {
|
||||
@@ -98,19 +101,8 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
|
||||
selected: stateObj.state === option,
|
||||
enabled: true,
|
||||
title: title || option,
|
||||
...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && {
|
||||
tap_action: {
|
||||
action: 'perform-action',
|
||||
perform_action: entityID.startsWith('select.')
|
||||
? 'select.select_option'
|
||||
: 'input_select.select_option',
|
||||
target: {
|
||||
entity_id: entityID,
|
||||
},
|
||||
data: {
|
||||
option: option,
|
||||
},
|
||||
},
|
||||
...((entityDomain === 'select' || entityDomain === 'input_select') && {
|
||||
tap_action: createSelectOptionAction(entityDomain, entityID, option),
|
||||
}),
|
||||
// Apply overrides the user may have specified for a given option.
|
||||
...(this.submenuSelect.options && this.submenuSelect.options[option]),
|
||||
|
||||
@@ -218,6 +218,25 @@ export function createPerformAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createSelectOptionAction(
|
||||
domain: 'select' | 'input_select',
|
||||
entityID: string,
|
||||
option: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
},
|
||||
): PerformActionActionConfig {
|
||||
return createPerformAction(`${domain}.select_option`, {
|
||||
...options,
|
||||
target: {
|
||||
entity_id: entityID,
|
||||
},
|
||||
data: {
|
||||
option: option,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
Reference in New Issue
Block a user