fix: Honor configured home preset / merge presets. (#2553)
- Closes: https://github.com/dermotduffy/advanced-camera-card/issues/2525
This commit is contained in:
committed by
dermotduffy
parent
d833edb65b
commit
5c97e55a56
@@ -21,7 +21,7 @@ import {
|
|||||||
getGo2RTCMetadataEndpoint,
|
getGo2RTCMetadataEndpoint,
|
||||||
getGo2RTCStreamEndpoint,
|
getGo2RTCStreamEndpoint,
|
||||||
} from '../utils/go2rtc/endpoint';
|
} from '../utils/go2rtc/endpoint';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz';
|
||||||
import { getPTZInfo } from './requests';
|
import { getPTZInfo } from './requests';
|
||||||
import {
|
import {
|
||||||
FRIGATE_SEVERITY_MAP,
|
FRIGATE_SEVERITY_MAP,
|
||||||
@@ -217,8 +217,7 @@ export class FrigateCamera extends Camera {
|
|||||||
|
|
||||||
const frigatePTZ = await this._getPTZCapabilities(hass, config);
|
const frigatePTZ = await this._getPTZCapabilities(hass, config);
|
||||||
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
|
const configPTZ = getPTZCapabilitiesFromCameraConfig(config);
|
||||||
const combinedPTZ: PTZCapabilities | null =
|
const combinedPTZ = mergePTZCapabilities(frigatePTZ, configPTZ);
|
||||||
configPTZ || frigatePTZ ? { ...frigatePTZ, ...configPTZ } : null;
|
|
||||||
|
|
||||||
const birdseye = isBirdseye(config);
|
const birdseye = isBirdseye(config);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { Camera, CameraInitializationOptions } from '../camera';
|
|||||||
import { EntityCamera } from '../entity-camera';
|
import { EntityCamera } from '../entity-camera';
|
||||||
import { ReolinkInitializationError } from '../error';
|
import { ReolinkInitializationError } from '../error';
|
||||||
import type { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
import type { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz';
|
||||||
|
|
||||||
// Reolink channels are zero indexed.
|
// Reolink channels are zero indexed.
|
||||||
const REOLINK_DEFAULT_CHANNEL = 0;
|
const REOLINK_DEFAULT_CHANNEL = 0;
|
||||||
@@ -194,8 +194,7 @@ export class ReolinkCamera extends EntityCamera {
|
|||||||
? this._entitiesToCapabilities(hass, this._ptzEntities)
|
? this._entitiesToCapabilities(hass, this._ptzEntities)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const combinedPTZ: PTZCapabilities | null =
|
const combinedPTZ = mergePTZCapabilities(reolinkPTZ, configPTZ);
|
||||||
configPTZ || reolinkPTZ ? { ...reolinkPTZ, ...configPTZ } : null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(await super._getRawCapabilities(hass, options)),
|
...(await super._getRawCapabilities(hass, options)),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import type { CameraInitializationOptions } from '../camera';
|
import type { CameraInitializationOptions } from '../camera';
|
||||||
import { EntityCamera } from '../entity-camera';
|
import { EntityCamera } from '../entity-camera';
|
||||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz';
|
||||||
|
|
||||||
interface TPLinkCameraInitializationOptions extends CameraInitializationOptions {
|
interface TPLinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||||
entityRegistryManager: EntityRegistryManager;
|
entityRegistryManager: EntityRegistryManager;
|
||||||
@@ -43,8 +43,7 @@ export class TPLinkCamera extends EntityCamera {
|
|||||||
? this._entitiesToCapabilities(this._ptzEntities)
|
? this._entitiesToCapabilities(this._ptzEntities)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const combinedPTZ: PTZCapabilities | null =
|
const combinedPTZ = mergePTZCapabilities(tplinkPTZ, configPTZ);
|
||||||
configPTZ || tplinkPTZ ? { ...tplinkPTZ, ...configPTZ } : null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(await super._getRawCapabilities(hass, options)),
|
...(await super._getRawCapabilities(hass, options)),
|
||||||
|
|||||||
@@ -54,6 +54,32 @@ export const getConfiguredPTZMovementType = (
|
|||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Combine engine-detected and configured PTZ capabilities. Configured movement
|
||||||
|
// actions override their engine equivalents, but presets from both sources are
|
||||||
|
// kept (configured first) so that configuring a preset does not erase the
|
||||||
|
// auto-detected ones.
|
||||||
|
export const mergePTZCapabilities = (
|
||||||
|
enginePTZ: PTZCapabilities | null,
|
||||||
|
configPTZ: PTZCapabilities | null,
|
||||||
|
): PTZCapabilities | null => {
|
||||||
|
if (!enginePTZ && !configPTZ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
...(configPTZ?.presets ?? []),
|
||||||
|
...(enginePTZ?.presets ?? []).filter(
|
||||||
|
(preset) => !configPTZ?.presets?.includes(preset),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enginePTZ,
|
||||||
|
...configPTZ,
|
||||||
|
...(presets.length ? { presets } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getPTZCapabilitiesFromCameraConfig = (
|
export const getPTZCapabilitiesFromCameraConfig = (
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
): PTZCapabilities | null => {
|
): PTZCapabilities | null => {
|
||||||
|
|||||||
@@ -56,7 +56,18 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!action.ptz_action) {
|
if (!action.ptz_action) {
|
||||||
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
|
// A configured `home` preset takes precedence over the first
|
||||||
|
// auto-detected preset. Without this, engines that auto-detect presets
|
||||||
|
// (e.g. Reolink) populate `capabilities.presets` from a `select` entity
|
||||||
|
// and the home button always targets `presets[0]`, ignoring the
|
||||||
|
// configured action. See:
|
||||||
|
// https://github.com/dermotduffy/advanced-camera-card/issues/2525
|
||||||
|
if (ptzConfiguration.presets?.['home']) {
|
||||||
|
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
|
||||||
|
phase: action.ptz_phase,
|
||||||
|
preset: 'home',
|
||||||
|
});
|
||||||
|
} else if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
|
||||||
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
|
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
|
||||||
phase: action.ptz_phase,
|
phase: action.ptz_phase,
|
||||||
preset: ptzCapabilities.presets[0],
|
preset: ptzCapabilities.presets[0],
|
||||||
|
|||||||
@@ -507,6 +507,47 @@ describe('ReolinkCamera', () => {
|
|||||||
zoomOut: ['continuous'],
|
zoomOut: ['continuous'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should union configured presets with detected presets', async () => {
|
||||||
|
const config = createCameraConfig({
|
||||||
|
camera_entity: 'camera.office_reolink',
|
||||||
|
ptz: {
|
||||||
|
presets: {
|
||||||
|
home: {
|
||||||
|
action: 'perform-action',
|
||||||
|
perform_action: 'button.press',
|
||||||
|
target: { entity_id: 'button.office_guard' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
|
||||||
|
|
||||||
|
await camera.initialize({
|
||||||
|
hassManager: createHASSManager({
|
||||||
|
hass: createHASS({
|
||||||
|
'select.office_reolink_ptz_preset': createStateEntity({
|
||||||
|
state: 'foo',
|
||||||
|
attributes: {
|
||||||
|
options: ['preset-one', 'preset-two'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
entityRegistryManager: ptzPopulatedEntityRegistryManager,
|
||||||
|
deviceRegistryManager: mock<DeviceRegistryManager>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
|
||||||
|
left: ['continuous'],
|
||||||
|
right: ['continuous'],
|
||||||
|
up: ['continuous'],
|
||||||
|
down: ['continuous'],
|
||||||
|
zoomIn: ['continuous'],
|
||||||
|
zoomOut: ['continuous'],
|
||||||
|
presets: ['home', 'preset-one', 'preset-two'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
getConfiguredPTZAction,
|
getConfiguredPTZAction,
|
||||||
getConfiguredPTZMovementType,
|
getConfiguredPTZMovementType,
|
||||||
getPTZCapabilitiesFromCameraConfig,
|
getPTZCapabilitiesFromCameraConfig,
|
||||||
|
mergePTZCapabilities,
|
||||||
} from '../../../src/camera-manager/utils/ptz';
|
} from '../../../src/camera-manager/utils/ptz';
|
||||||
import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
import type { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
|
||||||
|
import { PTZMovementType } from '../../../src/types';
|
||||||
import { createCameraConfig } from '../../test-utils';
|
import { createCameraConfig } from '../../test-utils';
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
@@ -186,3 +188,64 @@ describe('getPTZCapabilitiesFromCameraConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mergePTZCapabilities', () => {
|
||||||
|
it('should return null when both are null', () => {
|
||||||
|
expect(mergePTZCapabilities(null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return engine capabilities when no config capabilities', () => {
|
||||||
|
expect(
|
||||||
|
mergePTZCapabilities(
|
||||||
|
{ left: [PTZMovementType.Continuous], presets: ['Staw', 'Piwnica'] },
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
left: [PTZMovementType.Continuous],
|
||||||
|
presets: ['Staw', 'Piwnica'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return config capabilities when no engine capabilities', () => {
|
||||||
|
expect(mergePTZCapabilities(null, { presets: ['home'] })).toEqual({
|
||||||
|
presets: ['home'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should union presets with configured presets first', () => {
|
||||||
|
expect(
|
||||||
|
mergePTZCapabilities(
|
||||||
|
{ left: [PTZMovementType.Continuous], presets: ['Staw', 'Piwnica'] },
|
||||||
|
{ presets: ['home'] },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
left: [PTZMovementType.Continuous],
|
||||||
|
presets: ['home', 'Staw', 'Piwnica'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not duplicate presets present in both sources', () => {
|
||||||
|
expect(
|
||||||
|
mergePTZCapabilities({ presets: ['home', 'Staw'] }, { presets: ['home'] }),
|
||||||
|
).toEqual({
|
||||||
|
presets: ['home', 'Staw'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should let configured movement actions override engine equivalents', () => {
|
||||||
|
expect(
|
||||||
|
mergePTZCapabilities(
|
||||||
|
{ left: [PTZMovementType.Continuous] },
|
||||||
|
{ left: [PTZMovementType.Relative] },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
left: [PTZMovementType.Relative],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit presets when neither source has any', () => {
|
||||||
|
expect(mergePTZCapabilities({ left: [PTZMovementType.Continuous] }, null)).toEqual({
|
||||||
|
left: [PTZMovementType.Continuous],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -205,6 +205,101 @@ describe('should handle ptz action', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call configured home preset in preference to first preset', async () => {
|
||||||
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2525
|
||||||
|
const api = createCardAPI();
|
||||||
|
const store = createStore([
|
||||||
|
{
|
||||||
|
cameraID: 'camera.office',
|
||||||
|
config: createCameraConfig({
|
||||||
|
ptz: {
|
||||||
|
presets: {
|
||||||
|
home: {
|
||||||
|
action: 'perform-action',
|
||||||
|
perform_action: 'button.press',
|
||||||
|
data: { entity_id: 'button.office_guard' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// An engine (e.g. Reolink) auto-detects presets that would otherwise
|
||||||
|
// shadow the configured home action.
|
||||||
|
capabilities: new Capabilities({
|
||||||
|
ptz: { presets: ['Staw', 'Piwnica'] },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||||
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
|
createView({ camera: 'camera.office' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const action = new PTZAction(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'ptz',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await action.execute(api);
|
||||||
|
|
||||||
|
expect(api.getCameraManager().executePTZAction).toBeCalledWith(
|
||||||
|
'camera.office',
|
||||||
|
'preset',
|
||||||
|
{
|
||||||
|
phase: undefined,
|
||||||
|
preset: 'home',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call first preset when no home preset is configured', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const store = createStore([
|
||||||
|
{
|
||||||
|
cameraID: 'camera.office',
|
||||||
|
config: createCameraConfig({
|
||||||
|
ptz: {
|
||||||
|
presets: {
|
||||||
|
window: {
|
||||||
|
action: 'perform-action',
|
||||||
|
perform_action: 'button.press',
|
||||||
|
data: { entity_id: 'button.office_window' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
capabilities: new Capabilities({
|
||||||
|
ptz: { presets: ['Staw', 'Piwnica'] },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||||
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
|
createView({ camera: 'camera.office' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const action = new PTZAction(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
action: 'fire-dom-event',
|
||||||
|
advanced_camera_card_action: 'ptz',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await action.execute(api);
|
||||||
|
|
||||||
|
expect(api.getCameraManager().executePTZAction).toBeCalledWith(
|
||||||
|
'camera.office',
|
||||||
|
'preset',
|
||||||
|
{
|
||||||
|
phase: undefined,
|
||||||
|
preset: 'Staw',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not call preset when there are no presets', async () => {
|
it('should not call preset when there are no presets', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const store = createStore([
|
const store = createStore([
|
||||||
|
|||||||
Reference in New Issue
Block a user