feat: Add UI support for PTZ presets (#1920)
- Closes #1889 Whilst a simple change theoretically, the card has such a collection of surfaces that can overlap other surfaces, it's challenging to get this to work right! There's a real chance this will have broken something z-index related (e.g. X overlaps Y when it should not), or (for related reasons) broken curver corners on the card.
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { handleActionConfig } from '@dermotduffy/custom-card-helpers';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MenuController } from '../../src/components-lib/menu-controller';
|
||||
import { SubmenuItem } from '../../src/components/submenu/types';
|
||||
import { MenuConfig, menuConfigSchema } from '../../src/config/types';
|
||||
import { createInteractionEvent, createLitElement } from '../test-utils';
|
||||
import {
|
||||
createInteractionActionEvent,
|
||||
createLitElement,
|
||||
createSubmenuInteractionActionEvent,
|
||||
} from '../test-utils';
|
||||
|
||||
vi.mock('@dermotduffy/custom-card-helpers');
|
||||
vi.mock('../../src/utils/ha');
|
||||
@@ -365,7 +370,7 @@ describe('MenuController', () => {
|
||||
describe('should handle actions', () => {
|
||||
it('should bail without config', () => {
|
||||
const controller = new MenuController(createLitElement());
|
||||
controller.actionHandler(createInteractionEvent('tap'));
|
||||
controller.handleAction(createInteractionActionEvent('tap'));
|
||||
expect(vi.mocked(handleActionConfig)).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -376,7 +381,7 @@ describe('MenuController', () => {
|
||||
|
||||
const controller = new MenuController(host);
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
|
||||
controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig);
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { action: [action], config: tapActionConfig },
|
||||
@@ -392,7 +397,9 @@ describe('MenuController', () => {
|
||||
|
||||
const controller = new MenuController(host);
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap', tapActionConfig));
|
||||
controller.handleAction(
|
||||
createSubmenuInteractionActionEvent('tap', tapActionConfig as SubmenuItem),
|
||||
);
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { action: [action], config: tapActionConfig },
|
||||
@@ -407,7 +414,7 @@ describe('MenuController', () => {
|
||||
|
||||
const controller = new MenuController(host);
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap'), tapActionConfigMulti);
|
||||
controller.handleAction(createInteractionActionEvent('tap'), tapActionConfigMulti);
|
||||
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -429,7 +436,7 @@ describe('MenuController', () => {
|
||||
controller.setExpanded(true);
|
||||
expect(controller.isExpanded()).toBeTruthy();
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
|
||||
controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig);
|
||||
expect(controller.isExpanded()).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -445,7 +452,7 @@ describe('MenuController', () => {
|
||||
controller.setExpanded(true);
|
||||
expect(controller.isExpanded()).toBeTruthy();
|
||||
|
||||
controller.actionHandler(createInteractionEvent('end_tap'), {
|
||||
controller.handleAction(createInteractionActionEvent('end_tap'), {
|
||||
end_tap_action: action,
|
||||
});
|
||||
expect(controller.isExpanded()).toBeFalsy();
|
||||
@@ -465,7 +472,7 @@ describe('MenuController', () => {
|
||||
controller.setExpanded(true);
|
||||
expect(controller.isExpanded()).toBeTruthy();
|
||||
|
||||
controller.actionHandler(createInteractionEvent('start_tap'), {
|
||||
controller.handleAction(createInteractionActionEvent('start_tap'), {
|
||||
start_tap_action: action,
|
||||
end_tap_action: action,
|
||||
});
|
||||
@@ -484,7 +491,7 @@ describe('MenuController', () => {
|
||||
controller.setExpanded(false);
|
||||
expect(controller.isExpanded()).toBeFalsy();
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap'), {
|
||||
controller.handleAction(createInteractionActionEvent('tap'), {
|
||||
camera_entity: 'foo',
|
||||
tap_action: menuToggleAction,
|
||||
});
|
||||
@@ -503,7 +510,10 @@ describe('MenuController', () => {
|
||||
controller.setExpanded(true);
|
||||
expect(controller.isExpanded()).toBeTruthy();
|
||||
|
||||
controller.actionHandler(createInteractionEvent('end_tap'), tapActionConfig);
|
||||
controller.handleAction(
|
||||
createInteractionActionEvent('end_tap'),
|
||||
tapActionConfig,
|
||||
);
|
||||
expect(controller.isExpanded()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,6 +200,7 @@ describe('PTZController', () => {
|
||||
down: ['relative'],
|
||||
zoomIn: ['relative'],
|
||||
zoomOut: ['relative'],
|
||||
presets: ['door', 'window'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -215,6 +216,148 @@ describe('PTZController', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('only presets', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
controller.setConfig(createConfig());
|
||||
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: new Capabilities({
|
||||
ptz: {
|
||||
presets: ['door', 'window'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const cameraManager = createCameraManager(store);
|
||||
controller.setCamera(cameraManager, 'camera.office');
|
||||
|
||||
expect(controller.getPTZActions()['presets']).toEqual([
|
||||
{
|
||||
actions: {
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'preset',
|
||||
ptz_preset: 'door',
|
||||
},
|
||||
},
|
||||
preset: 'door',
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'preset',
|
||||
ptz_preset: 'window',
|
||||
},
|
||||
},
|
||||
preset: 'window',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return digital PTZ actions without camera capabilities', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
controller.setConfig(createConfig());
|
||||
|
||||
expect(controller.getPTZActions()).toEqual({
|
||||
down: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'down',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'down',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
home: {
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
},
|
||||
},
|
||||
left: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
right: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'right',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'right',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
up: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
zoom_in: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'zoom_in',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'zoom_in',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
zoom_out: {
|
||||
end_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'zoom_out',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
start_tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
ptz_action: 'zoom_out',
|
||||
ptz_phase: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle action', () => {
|
||||
@@ -276,103 +419,4 @@ describe('PTZController', () => {
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should identify useful actions', () => {
|
||||
it('without a camera', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
expect(controller.hasUsefulAction()).toEqual({
|
||||
pt: true,
|
||||
z: true,
|
||||
home: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('without camera PTZ capabilities', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({
|
||||
ptz: {},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const cameraManager = createCameraManager(store);
|
||||
controller.setCamera(cameraManager, 'camera.office');
|
||||
|
||||
expect(controller.hasUsefulAction()).toEqual({
|
||||
pt: true,
|
||||
z: true,
|
||||
home: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('with camera pan and tilt capabilities', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({
|
||||
ptz: {
|
||||
left: ['relative'],
|
||||
right: ['relative'],
|
||||
up: ['relative'],
|
||||
down: ['relative'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
controller.setCamera(createCameraManager(store), 'camera.office');
|
||||
|
||||
expect(controller.hasUsefulAction()).toEqual({
|
||||
pt: true,
|
||||
z: false,
|
||||
home: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('with camera zoom capabilities', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({
|
||||
ptz: {
|
||||
zoomIn: ['relative'],
|
||||
zoomOut: ['relative'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
controller.setCamera(createCameraManager(store), 'camera.office');
|
||||
|
||||
expect(controller.hasUsefulAction()).toEqual({
|
||||
pt: false,
|
||||
z: true,
|
||||
home: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('with camera presets', () => {
|
||||
const controller = new PTZController(document.createElement('div'));
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: createCapabilities({
|
||||
ptz: {
|
||||
presets: ['door'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
controller.setCamera(createCameraManager(store), 'camera.office');
|
||||
|
||||
expect(controller.hasUsefulAction()).toEqual({
|
||||
pt: false,
|
||||
z: false,
|
||||
home: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { StatusBarController } from '../../src/components-lib/status-bar-controller';
|
||||
import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types';
|
||||
import { setOrRemoveAttribute } from '../../src/utils/basic';
|
||||
import { createInteractionEvent, createLitElement } from '../test-utils';
|
||||
import { createInteractionActionEvent, createLitElement } from '../test-utils';
|
||||
|
||||
const createConfig = (config?: unknown): StatusBarConfig => {
|
||||
return statusBarConfigSchema.parse(config);
|
||||
@@ -300,7 +300,7 @@ describe('StatusBarController', () => {
|
||||
host.addEventListener('advanced-camera-card:action:execution-request', handler);
|
||||
|
||||
const controller = new StatusBarController(host);
|
||||
controller.actionHandler(createInteractionEvent('tap'));
|
||||
controller.actionHandler(createInteractionActionEvent('tap'));
|
||||
expect(handler).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('StatusBarController', () => {
|
||||
tap_action: action,
|
||||
};
|
||||
|
||||
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig);
|
||||
controller.actionHandler(createInteractionActionEvent('tap'), tapActionConfig);
|
||||
|
||||
expect(handler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
+23
-8
@@ -1,4 +1,4 @@
|
||||
import { CurrentUser, HASSDomEvent } from '@dermotduffy/custom-card-helpers';
|
||||
import { CurrentUser } from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { LitElement } from 'lit';
|
||||
import screenfull from 'screenfull';
|
||||
@@ -39,9 +39,9 @@ import { StatusBarItemManager } from '../src/card-controller/status-bar-item-man
|
||||
import { StyleManager } from '../src/card-controller/style-manager';
|
||||
import { TriggersManager } from '../src/card-controller/triggers-manager';
|
||||
import { ViewManager } from '../src/card-controller/view/view-manager';
|
||||
import { SubmenuInteraction, SubmenuItem } from '../src/components/submenu/types';
|
||||
import { ConditionStateManager } from '../src/conditions/state-manager';
|
||||
import {
|
||||
ActionsConfig,
|
||||
AdvancedCameraCardConfig,
|
||||
CameraConfig,
|
||||
InternalAdvancedCameraCardCustomAction,
|
||||
@@ -52,7 +52,12 @@ import {
|
||||
internalAdvancedCameraCardCustomActionSchema,
|
||||
performanceConfigSchema,
|
||||
} from '../src/config/types';
|
||||
import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
ExtendedHomeAssistant,
|
||||
Interaction,
|
||||
MediaLoadedInfo,
|
||||
} from '../src/types';
|
||||
import { HassStateDifference } from '../src/utils/ha';
|
||||
import { Device } from '../src/utils/ha/registry/device/types';
|
||||
import { EntityRegistryManager } from '../src/utils/ha/registry/entity';
|
||||
@@ -520,14 +525,24 @@ export const flushPromises = async (): Promise<void> => {
|
||||
await new Promise(process.nextTick);
|
||||
};
|
||||
|
||||
export const createInteractionEvent = (
|
||||
export const createInteractionActionEvent = (
|
||||
action: string,
|
||||
config?: ActionsConfig,
|
||||
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
|
||||
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
|
||||
): CustomEvent<Interaction> => {
|
||||
return new CustomEvent<Interaction>('@action', {
|
||||
detail: {
|
||||
action: action,
|
||||
config: config,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createSubmenuInteractionActionEvent = (
|
||||
action: string,
|
||||
item: SubmenuItem,
|
||||
): CustomEvent<SubmenuInteraction> => {
|
||||
return new CustomEvent<SubmenuInteraction>('@action', {
|
||||
detail: {
|
||||
action,
|
||||
item,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createInternalCallbackAction,
|
||||
createLogAction,
|
||||
createMediaPlayerAction,
|
||||
createPerformAction,
|
||||
createPTZAction,
|
||||
createPTZControlsAction,
|
||||
createPTZDigitalAction,
|
||||
@@ -273,6 +274,24 @@ describe('createInternalCallbackAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPerformAction', () => {
|
||||
it('should create perform action', () => {
|
||||
expect(
|
||||
createPerformAction('toggle', {
|
||||
cardID: 'card_id',
|
||||
target: { entity_id: 'light.office_main_lights' },
|
||||
data: {},
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'perform-action',
|
||||
perform_action: 'toggle',
|
||||
card_id: 'card_id',
|
||||
target: { entity_id: 'light.office_main_lights' },
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionConfigGivenAction', () => {
|
||||
const action = actionSchema.parse({
|
||||
action: 'fire-dom-event',
|
||||
@@ -293,6 +312,18 @@ describe('getActionConfigGivenAction', () => {
|
||||
expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
|
||||
});
|
||||
|
||||
it('should handle default tap action without an entity', () => {
|
||||
expect(getActionConfigGivenAction('tap', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle default tap action with an entity', () => {
|
||||
expect(
|
||||
getActionConfigGivenAction('tap', { entity: 'light.office_main_lights' }),
|
||||
).toEqual({
|
||||
action: 'more-info',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle hold actions', () => {
|
||||
expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user