From 75ae7720d30590689fc3cd949189dbc8953c3657 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 15 Mar 2025 14:32:48 -0700 Subject: [PATCH] fix: Confirmations should apply to all actions (#1959) This also refactors how generic HA actions are handled, and improves typing of actions. [skip ci] --- src/camera-manager/utils/ptz.ts | 4 +- .../actions/actions-manager.ts | 41 ++- src/card-controller/actions/actions/base.ts | 41 ++- .../actions/actions/call-service.ts | 17 ++ .../actions/actions/camera-select.ts | 2 + .../actions/actions/camera-ui.ts | 2 + src/card-controller/actions/actions/custom.ts | 12 + .../actions/actions/default.ts | 2 + .../actions/actions/display-mode-select.ts | 2 + .../actions/actions/download.ts | 2 + src/card-controller/actions/actions/expand.ts | 2 + .../actions/actions/fullscreen.ts | 2 + .../actions/actions/generic.ts | 21 -- .../actions/actions/internal-callback.ts | 2 + src/card-controller/actions/actions/log.ts | 5 +- .../actions/actions/media-player.ts | 2 + .../actions/actions/menu-toggle.ts | 2 + .../actions/actions/microphone-connect.ts | 2 + .../actions/actions/microphone-disconnect.ts | 2 + .../actions/actions/microphone-mute.ts | 2 + .../actions/actions/microphone-unmute.ts | 2 + .../actions/actions/more-info.ts | 19 ++ src/card-controller/actions/actions/mute.ts | 2 + .../actions/actions/navigate.ts | 19 ++ src/card-controller/actions/actions/none.ts | 9 + src/card-controller/actions/actions/pause.ts | 2 + .../actions/actions/perform-action.ts | 17 ++ src/card-controller/actions/actions/play.ts | 2 + .../actions/actions/ptz-controls.ts | 2 + .../actions/actions/ptz-digital.ts | 2 + .../actions/actions/ptz-multi.ts | 2 + src/card-controller/actions/actions/ptz.ts | 2 + .../actions/actions/screenshot.ts | 2 + src/card-controller/actions/actions/set.ts | 6 +- src/card-controller/actions/actions/sleep.ts | 5 +- .../actions/actions/status-bar.ts | 2 + .../actions/actions/substream-off.ts | 2 + .../actions/actions/substream-on.ts | 2 + .../actions/actions/substream-select.ts | 2 + src/card-controller/actions/actions/toggle.ts | 38 +++ src/card-controller/actions/actions/unmute.ts | 2 + src/card-controller/actions/actions/url.ts | 11 + src/card-controller/actions/actions/view.ts | 2 + src/card-controller/actions/factory.ts | 124 ++++---- src/card-controller/actions/types.ts | 12 +- src/card-controller/query-string-manager.ts | 28 +- src/card-controller/templates/index.ts | 15 +- src/components-lib/menu-button-controller.ts | 87 +++--- src/components-lib/menu-controller.ts | 17 +- src/components/submenu/select-button.ts | 55 ++-- src/components/submenu/submenu-button.ts | 2 +- src/components/submenu/types.ts | 7 +- src/config/types.ts | 280 ++++++++---------- src/ha/handle-action.ts | 90 ------ src/ha/has-action.ts | 5 - src/ha/navigate.ts | 20 -- src/ha/toggle-entity.ts | 8 - src/ha/turn-on-off-entity.ts | 25 -- src/ha/types.ts | 81 ----- src/localize/languages/ca.json | 4 + src/localize/languages/en.json | 4 + src/localize/languages/fr.json | 4 + src/localize/languages/it.json | 4 + src/localize/languages/pt-BR.json | 4 + src/localize/languages/pt-PT.json | 4 + src/utils/action.ts | 46 ++- src/utils/basic.ts | 4 +- .../actions/actions-manager.test.ts | 140 ++++++--- .../actions/actions/base.test.ts | 181 ++++++++++- .../actions/actions/call-service.test.ts | 48 +++ .../actions/actions/custom.test.ts | 37 +++ .../actions/actions/generic.test.ts | 46 --- .../actions/actions/more-info.test.ts | 80 +++++ .../actions/actions/navigate.test.ts | 57 ++++ .../actions/actions/none.test.ts | 17 ++ .../actions/actions/perform-action.test.ts | 48 +++ .../actions/actions/toggle.test.ts | 81 +++++ .../actions/actions/url.test.ts | 25 ++ tests/card-controller/actions/factory.test.ts | 47 +-- .../automations-manager.test.ts | 7 +- .../card-element-manager.test.ts | 1 - .../query-string-manager.test.ts | 9 +- tests/components-lib/menu-controller.test.ts | 12 +- tests/config/types.test.ts | 8 +- tests/test-utils.ts | 12 - tests/utils/action.test.ts | 59 ++-- tests/utils/basic.test.ts | 3 + tests/utils/download.test.ts | 14 +- tsconfig.json | 2 +- vite.config.ts | 1 + 90 files changed, 1332 insertions(+), 854 deletions(-) create mode 100644 src/card-controller/actions/actions/call-service.ts create mode 100644 src/card-controller/actions/actions/custom.ts delete mode 100644 src/card-controller/actions/actions/generic.ts create mode 100644 src/card-controller/actions/actions/more-info.ts create mode 100644 src/card-controller/actions/actions/navigate.ts create mode 100644 src/card-controller/actions/actions/none.ts create mode 100644 src/card-controller/actions/actions/perform-action.ts create mode 100644 src/card-controller/actions/actions/toggle.ts create mode 100644 src/card-controller/actions/actions/url.ts delete mode 100644 src/ha/handle-action.ts delete mode 100644 src/ha/has-action.ts delete mode 100644 src/ha/navigate.ts delete mode 100644 src/ha/toggle-entity.ts delete mode 100644 src/ha/turn-on-off-entity.ts create mode 100644 tests/card-controller/actions/actions/call-service.test.ts create mode 100644 tests/card-controller/actions/actions/custom.test.ts delete mode 100644 tests/card-controller/actions/actions/generic.test.ts create mode 100644 tests/card-controller/actions/actions/more-info.test.ts create mode 100644 tests/card-controller/actions/actions/navigate.test.ts create mode 100644 tests/card-controller/actions/actions/none.test.ts create mode 100644 tests/card-controller/actions/actions/perform-action.test.ts create mode 100644 tests/card-controller/actions/actions/toggle.test.ts create mode 100644 tests/card-controller/actions/actions/url.test.ts diff --git a/src/camera-manager/utils/ptz.ts b/src/camera-manager/utils/ptz.ts index c7cb3158..322eb675 100644 --- a/src/camera-manager/utils/ptz.ts +++ b/src/camera-manager/utils/ptz.ts @@ -1,5 +1,5 @@ import { PTZAction, PTZBaseAction } from '../../config/ptz'; -import { ActionPhase, ActionType, CameraConfig } from '../../config/types'; +import { ActionConfig, ActionPhase, CameraConfig } from '../../config/types'; import { PTZCapabilities, PTZMovementType } from '../../types'; export const getConfiguredPTZAction = ( @@ -9,7 +9,7 @@ export const getConfiguredPTZAction = ( phase?: ActionPhase; preset?: string; }, -): ActionType | ActionType[] | null => { +): ActionConfig | ActionConfig[] | null => { if (action === 'preset') { return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null; } diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index 3efd2918..9b27e83b 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -1,12 +1,19 @@ import { ActionContext } from 'action'; import { z } from 'zod'; import { ConditionsTriggerData } from '../../conditions/types.js'; -import { Actions, ActionsConfig, ActionType } from '../../config/types.js'; +import { + ActionConfig, + Actions, + ActionsConfig, + AuxillaryActionConfig, +} from '../../config/types.js'; +import { forwardHaptic } from '../../ha/haptic.js'; import { getActionConfigGivenAction } from '../../utils/action.js'; +import { allPromises } from '../../utils/basic.js'; import { TemplateRenderer } from '../templates/index.js'; import { CardActionsManagerAPI } from '../types.js'; import { ActionSet } from './actions/set.js'; -import { ActionExecutionRequest, AuxillaryActionConfig } from './types.js'; +import { ActionExecutionRequest } from './types.js'; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const; export type InteractionName = (typeof INTERACTIONS)[number]; @@ -60,7 +67,7 @@ export class ActionsManager { /** * Handle an human interaction called on an element (e.g. 'tap'). */ - public handleInteractionEvent = (ev: Event): void => { + public handleInteractionEvent = async (ev: Event): Promise => { const result = interactionEventSchema.safeParse(ev); if (!result.success) { return; @@ -76,7 +83,7 @@ export class ActionsManager { // actions). actionConfig ) { - this.executeActions(actionConfig, { config }); + await this.executeActions(actionConfig, { config }); } }; @@ -85,14 +92,16 @@ export class ActionsManager { * cards to fire custom actions. This card itself should not call this, but * embedded elements may. */ - public handleCustomActionEvent = (ev: Event): void => { + public handleCustomActionEvent = async ( + ev: Event | CustomEvent, + ): Promise => { if (!('detail' in ev)) { // The event may or may not be a CustomEvent object. For example, whilst // this card doesn't use custom-card-helpers, embedded elements may: // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70 return; } - this.executeActions(ev.detail as ActionType); + await this.executeActions(ev.detail as ActionConfig); }; /** @@ -107,25 +116,25 @@ export class ActionsManager { }); }; - public uninitialize(): void { + public async uninitialize(): Promise { // If there are any long-running actions, ensure they are stopped. - this._actionsInFlight.forEach((actionSet) => actionSet.stop()); + await allPromises(this._actionsInFlight, (actionSet) => actionSet.stop()); } public async executeActions( - action: ActionType | ActionType[], + action: ActionConfig | ActionConfig[], options?: { config?: AuxillaryActionConfig; triggerData?: ConditionsTriggerData; }, ): Promise { const hass = this._api.getHASSManager().getHASS(); - const renderedAction = + const renderedAction: ActionConfig | ActionConfig[] = hass && this._templateRenderer - ? this._templateRenderer.renderRecursively(hass, action, { + ? (this._templateRenderer.renderRecursively(hass, action, { conditionState: this._api.getConditionStateManager().getState(), triggerData: options?.triggerData, - }) + }) as ActionConfig | ActionConfig[]) : action; const actionSet = new ActionSet(this._actionContext, renderedAction, { @@ -134,7 +143,13 @@ export class ActionsManager { }); this._actionsInFlight.push(actionSet); - await actionSet.execute(this._api); + + try { + await actionSet.execute(this._api); + forwardHaptic('success'); + } catch (e) { + forwardHaptic('warning'); + } this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet); } } diff --git a/src/card-controller/actions/actions/base.ts b/src/card-controller/actions/actions/base.ts index 5ba18ce9..78e2df54 100644 --- a/src/card-controller/actions/actions/base.ts +++ b/src/card-controller/actions/actions/base.ts @@ -1,9 +1,11 @@ import { ActionContext } from 'action'; -import { AdvancedCameraCardCustomAction } from '../../../config/types'; +import { ActionConfig, AuxillaryActionConfig } from '../../../config/types'; +import { localize } from '../../../localize/localize.js'; +import { isAdvancedCameraCardCustomAction } from '../../../utils/action'; import { CardActionsAPI } from '../../types'; -import { Action, AuxillaryActionConfig } from '../types'; +import { Action, ActionAbortError } from '../types'; -export class BaseAction implements Action { +export class BaseAction implements Action { protected _context: ActionContext; protected _action: T; protected _config?: AuxillaryActionConfig; @@ -14,9 +16,32 @@ export class BaseAction implements Action { this._config = config; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async execute(_api: CardActionsAPI): Promise { - // Pass. + protected _shouldSeekConfirmation(api: CardActionsAPI): boolean { + const hass = api.getHASSManager().getHASS(); + + return ( + (typeof this._action.confirmation === 'boolean' && this._action.confirmation) || + (typeof this._action.confirmation === 'object' && + (!this._action.confirmation.exemptions || + !this._action.confirmation.exemptions.some( + (entry) => entry.user === hass?.user.id, + ))) + ); + } + + public async execute(api: CardActionsAPI): Promise { + if (this._shouldSeekConfirmation(api)) { + const actionName = isAdvancedCameraCardCustomAction(this._action) + ? this._action.advanced_camera_card_action + : this._action.action; + const text = + (typeof this._action.confirmation === 'object' + ? this._action.confirmation.text + : null) ?? `${localize('actions.confirmation')}: ${actionName}`; + if (!confirm(text)) { + throw new ActionAbortError(localize('actions.abort')); + } + } } public async stop(): Promise { @@ -24,6 +49,4 @@ export class BaseAction implements Action { } } -export class AdvancedCameraCardAction< - T extends AdvancedCameraCardCustomAction, -> extends BaseAction {} +export class AdvancedCameraCardAction extends BaseAction {} diff --git a/src/card-controller/actions/actions/call-service.ts b/src/card-controller/actions/actions/call-service.ts new file mode 100644 index 00000000..27b6a46e --- /dev/null +++ b/src/card-controller/actions/actions/call-service.ts @@ -0,0 +1,17 @@ +import { CallServiceActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class CallServiceAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const hass = api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + const [domain, service] = this._action.service.split('.', 2); + await hass.callService(domain, service, this._action.data, this._action.target); + } +} diff --git a/src/card-controller/actions/actions/camera-select.ts b/src/card-controller/actions/actions/camera-select.ts index 2dc9d557..1136bc4c 100644 --- a/src/card-controller/actions/actions/camera-select.ts +++ b/src/card-controller/actions/actions/camera-select.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class CameraSelectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + const selectCameraID = this._action.camera ?? (this._action.triggered diff --git a/src/card-controller/actions/actions/camera-ui.ts b/src/card-controller/actions/actions/camera-ui.ts index 2b588856..803085d6 100644 --- a/src/card-controller/actions/actions/camera-ui.ts +++ b/src/card-controller/actions/actions/camera-ui.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class CameraUIAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getCameraURLManager().openURL(); } } diff --git a/src/card-controller/actions/actions/custom.ts b/src/card-controller/actions/actions/custom.ts new file mode 100644 index 00000000..a17c36de --- /dev/null +++ b/src/card-controller/actions/actions/custom.ts @@ -0,0 +1,12 @@ +import { CustomActionConfig } from '../../../config/types'; +import { fireHASSEvent } from '../../../ha/fire-hass-event'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class CustomAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + fireHASSEvent(api.getCardElementManager().getElement(), 'll-custom', this._action); + } +} diff --git a/src/card-controller/actions/actions/default.ts b/src/card-controller/actions/actions/default.ts index 069b4b90..3a04474e 100644 --- a/src/card-controller/actions/actions/default.ts +++ b/src/card-controller/actions/actions/default.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class DefaultAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getViewManager().setViewDefaultWithNewQuery(); } } diff --git a/src/card-controller/actions/actions/display-mode-select.ts b/src/card-controller/actions/actions/display-mode-select.ts index f6a6c4b9..490b2022 100644 --- a/src/card-controller/actions/actions/display-mode-select.ts +++ b/src/card-controller/actions/actions/display-mode-select.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class DisplayModeSelectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getViewManager().setViewByParametersWithNewQuery({ params: { displayMode: this._action.display_mode, diff --git a/src/card-controller/actions/actions/download.ts b/src/card-controller/actions/actions/download.ts index aa7830b0..ac8c5558 100644 --- a/src/card-controller/actions/actions/download.ts +++ b/src/card-controller/actions/actions/download.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class DownloadAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getDownloadManager().downloadViewerMedia(); } } diff --git a/src/card-controller/actions/actions/expand.ts b/src/card-controller/actions/actions/expand.ts index 3bc92caa..6431297d 100644 --- a/src/card-controller/actions/actions/expand.ts +++ b/src/card-controller/actions/actions/expand.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class ExpandAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getExpandManager().toggleExpanded(); } } diff --git a/src/card-controller/actions/actions/fullscreen.ts b/src/card-controller/actions/actions/fullscreen.ts index cef14dbc..e2cdc03f 100644 --- a/src/card-controller/actions/actions/fullscreen.ts +++ b/src/card-controller/actions/actions/fullscreen.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class FullscreenAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getFullscreenManager().toggleFullscreen(); } } diff --git a/src/card-controller/actions/actions/generic.ts b/src/card-controller/actions/actions/generic.ts deleted file mode 100644 index 186027d3..00000000 --- a/src/card-controller/actions/actions/generic.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { handleActionConfig } from '../../../ha/handle-action'; -import { ActionConfig } from '../../../ha/types'; -import { CardActionsAPI } from '../../types'; -import { BaseAction } from './base'; - -/** - * Handles generic HA actions (e.g. 'more-info') - */ -export class GenericAction extends BaseAction { - public async execute(api: CardActionsAPI): Promise { - const hass = api.getHASSManager().getHASS(); - if (hass) { - handleActionConfig( - api.getCardElementManager().getElement(), - hass, - this._config ?? {}, - this._action, - ); - } - } -} diff --git a/src/card-controller/actions/actions/internal-callback.ts b/src/card-controller/actions/actions/internal-callback.ts index 5eb203ac..19479d40 100644 --- a/src/card-controller/actions/actions/internal-callback.ts +++ b/src/card-controller/actions/actions/internal-callback.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class InternalCallbackAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await this._action.callback(api); } } diff --git a/src/card-controller/actions/actions/log.ts b/src/card-controller/actions/actions/log.ts index e3e4bde7..fcea66a9 100644 --- a/src/card-controller/actions/actions/log.ts +++ b/src/card-controller/actions/actions/log.ts @@ -3,8 +3,9 @@ import { CardActionsAPI } from '../../types'; import { AdvancedCameraCardAction } from './base'; export class LogAction extends AdvancedCameraCardAction { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async execute(_api: CardActionsAPI): Promise { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + console[this._action.level](this._action.message); } } diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts index bb79ac8c..1bb699ae 100644 --- a/src/card-controller/actions/actions/media-player.ts +++ b/src/card-controller/actions/actions/media-player.ts @@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base'; export class MediaPlayerAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + const mediaPlayer = this._action.media_player; const mediaPlayerController = api.getMediaPlayerManager(); const view = api.getViewManager().getView(); diff --git a/src/card-controller/actions/actions/menu-toggle.ts b/src/card-controller/actions/actions/menu-toggle.ts index 1c1903fb..53fd9cdb 100644 --- a/src/card-controller/actions/actions/menu-toggle.ts +++ b/src/card-controller/actions/actions/menu-toggle.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MenuToggleAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getCardElementManager().toggleMenu(); } } diff --git a/src/card-controller/actions/actions/microphone-connect.ts b/src/card-controller/actions/actions/microphone-connect.ts index b4ab7f90..219dafd8 100644 --- a/src/card-controller/actions/actions/microphone-connect.ts +++ b/src/card-controller/actions/actions/microphone-connect.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MicrophoneConnectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMicrophoneManager().connect(); } } diff --git a/src/card-controller/actions/actions/microphone-disconnect.ts b/src/card-controller/actions/actions/microphone-disconnect.ts index 0a7d0bb1..872d4982 100644 --- a/src/card-controller/actions/actions/microphone-disconnect.ts +++ b/src/card-controller/actions/actions/microphone-disconnect.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MicrophoneDisconnectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getMicrophoneManager().disconnect(); } } diff --git a/src/card-controller/actions/actions/microphone-mute.ts b/src/card-controller/actions/actions/microphone-mute.ts index 11658740..e2a64e14 100644 --- a/src/card-controller/actions/actions/microphone-mute.ts +++ b/src/card-controller/actions/actions/microphone-mute.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MicrophoneMuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getMicrophoneManager().mute(); } } diff --git a/src/card-controller/actions/actions/microphone-unmute.ts b/src/card-controller/actions/actions/microphone-unmute.ts index 92ef1796..7a958a06 100644 --- a/src/card-controller/actions/actions/microphone-unmute.ts +++ b/src/card-controller/actions/actions/microphone-unmute.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MicrophoneUnmuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMicrophoneManager().unmute(); } } diff --git a/src/card-controller/actions/actions/more-info.ts b/src/card-controller/actions/actions/more-info.ts new file mode 100644 index 00000000..7311f8dd --- /dev/null +++ b/src/card-controller/actions/actions/more-info.ts @@ -0,0 +1,19 @@ +import { MoreInfoActionConfig } from '../../../config/types'; +import { fireHASSEvent } from '../../../ha/fire-hass-event'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class MoreInfoAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const entityID = this._action.entity ?? this._config?.entity ?? null; + if (!entityID) { + return; + } + + fireHASSEvent(api.getCardElementManager().getElement(), 'hass-more-info', { + entityId: entityID, + }); + } +} diff --git a/src/card-controller/actions/actions/mute.ts b/src/card-controller/actions/actions/mute.ts index b80ccf61..062917c2 100644 --- a/src/card-controller/actions/actions/mute.ts +++ b/src/card-controller/actions/actions/mute.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class MuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.mute(); } } diff --git a/src/card-controller/actions/actions/navigate.ts b/src/card-controller/actions/actions/navigate.ts new file mode 100644 index 00000000..0ad085de --- /dev/null +++ b/src/card-controller/actions/actions/navigate.ts @@ -0,0 +1,19 @@ +import { NavigateActionConfig } from '../../../config/types'; +import { fireHASSEvent } from '../../../ha/fire-hass-event'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class NavigateAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + if (!!this._action.navigation_replace) { + history.replaceState(null, '', this._action.navigation_path); + } else { + history.pushState(null, '', this._action.navigation_path); + } + fireHASSEvent(window, 'location-changed', { + replace: !!this._action.navigation_replace, + }); + } +} diff --git a/src/card-controller/actions/actions/none.ts b/src/card-controller/actions/actions/none.ts new file mode 100644 index 00000000..6a89d647 --- /dev/null +++ b/src/card-controller/actions/actions/none.ts @@ -0,0 +1,9 @@ +import { NoneActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class NoneAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + } +} diff --git a/src/card-controller/actions/actions/pause.ts b/src/card-controller/actions/actions/pause.ts index c6120e71..f3b4fdd6 100644 --- a/src/card-controller/actions/actions/pause.ts +++ b/src/card-controller/actions/actions/pause.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class PauseAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.pause(); } } diff --git a/src/card-controller/actions/actions/perform-action.ts b/src/card-controller/actions/actions/perform-action.ts new file mode 100644 index 00000000..f808eb53 --- /dev/null +++ b/src/card-controller/actions/actions/perform-action.ts @@ -0,0 +1,17 @@ +import { PerformActionActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class PerformActionAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const hass = api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + const [domain, service] = this._action.perform_action.split('.', 2); + await hass.callService(domain, service, this._action.data, this._action.target); + } +} diff --git a/src/card-controller/actions/actions/play.ts b/src/card-controller/actions/actions/play.ts index c307b6b3..93e02081 100644 --- a/src/card-controller/actions/actions/play.ts +++ b/src/card-controller/actions/actions/play.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class PlayAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.play(); } } diff --git a/src/card-controller/actions/actions/ptz-controls.ts b/src/card-controller/actions/actions/ptz-controls.ts index 501bc074..34292ab8 100644 --- a/src/card-controller/actions/actions/ptz-controls.ts +++ b/src/card-controller/actions/actions/ptz-controls.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class PTZControlsAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getViewManager().setViewWithMergedContext({ ptzControls: { enabled: this._action.enabled }, }); diff --git a/src/card-controller/actions/actions/ptz-digital.ts b/src/card-controller/actions/actions/ptz-digital.ts index 0ddab55b..52d6bb20 100644 --- a/src/card-controller/actions/actions/ptz-digital.ts +++ b/src/card-controller/actions/actions/ptz-digital.ts @@ -45,6 +45,8 @@ export class PTZDigitalAction extends AdvancedCameraCardAction { + await super.execute(api); + const view = api.getViewManager().getView(); if (!view) { return; diff --git a/src/card-controller/actions/actions/ptz-multi.ts b/src/card-controller/actions/actions/ptz-multi.ts index 8a5da678..025aba0d 100644 --- a/src/card-controller/actions/actions/ptz-multi.ts +++ b/src/card-controller/actions/actions/ptz-multi.ts @@ -8,6 +8,8 @@ import { PTZDigitalAction } from './ptz-digital'; export class PTZMultiAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + const view = api.getViewManager().getView(); let targetID: string | null = null; let type: PTZType | null = null; diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts index 4814faa0..349f7789 100644 --- a/src/card-controller/actions/actions/ptz.ts +++ b/src/card-controller/actions/actions/ptz.ts @@ -28,6 +28,8 @@ export class PTZAction extends AdvancedCameraCardAction { } public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + const view = api.getViewManager().getView(); if (!view) { return; diff --git a/src/card-controller/actions/actions/screenshot.ts b/src/card-controller/actions/actions/screenshot.ts index 8838ca75..6ab397c5 100644 --- a/src/card-controller/actions/actions/screenshot.ts +++ b/src/card-controller/actions/actions/screenshot.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class ScreenshotAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getDownloadManager().downloadScreenshot(); } } diff --git a/src/card-controller/actions/actions/set.ts b/src/card-controller/actions/actions/set.ts index 2e87c4c2..4db35963 100644 --- a/src/card-controller/actions/actions/set.ts +++ b/src/card-controller/actions/actions/set.ts @@ -1,9 +1,9 @@ import { ActionContext } from 'action'; -import { ActionType } from '../../../config/types'; +import { ActionConfig, AuxillaryActionConfig } from '../../../config/types'; import { arrayify } from '../../../utils/basic'; import { CardActionsAPI } from '../../types'; import { ActionFactory } from '../factory'; -import { Action, AuxillaryActionConfig } from '../types'; +import { Action } from '../types'; export class ActionSet implements Action { protected _context: ActionContext; @@ -13,7 +13,7 @@ export class ActionSet implements Action { constructor( context: ActionContext, - actions: ActionType | ActionType[], + actions: ActionConfig | ActionConfig[], options?: { config?: AuxillaryActionConfig; cardID?: string; diff --git a/src/card-controller/actions/actions/sleep.ts b/src/card-controller/actions/actions/sleep.ts index 98cba66a..9fe85fc6 100644 --- a/src/card-controller/actions/actions/sleep.ts +++ b/src/card-controller/actions/actions/sleep.ts @@ -5,8 +5,9 @@ import { timeDeltaToSeconds } from '../utils/time-delta'; import { AdvancedCameraCardAction } from './base'; export class SleepAction extends AdvancedCameraCardAction { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async execute(_api: CardActionsAPI): Promise { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await sleep(timeDeltaToSeconds(this._action.duration)); } } diff --git a/src/card-controller/actions/actions/status-bar.ts b/src/card-controller/actions/actions/status-bar.ts index 5075bc87..c9fccea3 100644 --- a/src/card-controller/actions/actions/status-bar.ts +++ b/src/card-controller/actions/actions/status-bar.ts @@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base'; export class StatusBarAction extends AdvancedCameraCardAction { // eslint-disable-next-line @typescript-eslint/no-unused-vars public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + switch (this._action.status_bar_action) { case 'reset': api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index cf643d15..b3637884 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base'; export class SubstreamOffAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getViewManager().setViewByParameters({ modifiers: [new SubstreamOffViewModifier()], }); diff --git a/src/card-controller/actions/actions/substream-on.ts b/src/card-controller/actions/actions/substream-on.ts index ec8e0a49..2864f1a8 100644 --- a/src/card-controller/actions/actions/substream-on.ts +++ b/src/card-controller/actions/actions/substream-on.ts @@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base'; export class SubstreamOnAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getViewManager().setViewByParameters({ modifiers: [new SubstreamOnViewModifier(api)], }); diff --git a/src/card-controller/actions/actions/substream-select.ts b/src/card-controller/actions/actions/substream-select.ts index c8131dcb..efbbf57e 100644 --- a/src/card-controller/actions/actions/substream-select.ts +++ b/src/card-controller/actions/actions/substream-select.ts @@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base'; export class SubstreamSelectAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getViewManager().setViewByParameters({ modifiers: [new SubstreamSelectViewModifier(this._action.camera)], }); diff --git a/src/card-controller/actions/actions/toggle.ts b/src/card-controller/actions/actions/toggle.ts new file mode 100644 index 00000000..a5276709 --- /dev/null +++ b/src/card-controller/actions/actions/toggle.ts @@ -0,0 +1,38 @@ +import { ToggleActionConfig } from '../../../config/types'; +import { computeDomain } from '../../../ha/compute-domain'; +import { STATES_OFF } from '../../../ha/const'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class ToggleAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + const hass = api.getHASSManager().getHASS(); + const entityID = this._config?.entity; + if (!hass || !entityID) { + return; + } + const entityState = hass.states[entityID]?.state; + if (!entityState) { + return; + } + + const turnOn = STATES_OFF.includes(entityState); + const stateDomain = computeDomain(entityID); + const serviceDomain = stateDomain === 'group' ? 'homeassistant' : stateDomain; + const service = + stateDomain === 'lock' + ? turnOn + ? 'unlock' + : 'lock' + : stateDomain === 'cover' + ? turnOn + ? 'open_cover' + : 'close_cover' + : turnOn + ? 'turn_on' + : 'turn_off'; + await hass.callService(serviceDomain, service, { entity_id: entityID }); + } +} diff --git a/src/card-controller/actions/actions/unmute.ts b/src/card-controller/actions/actions/unmute.ts index 498292f9..1f5d03f5 100644 --- a/src/card-controller/actions/actions/unmute.ts +++ b/src/card-controller/actions/actions/unmute.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class UnmuteAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.unmute(); } } diff --git a/src/card-controller/actions/actions/url.ts b/src/card-controller/actions/actions/url.ts new file mode 100644 index 00000000..5440f32b --- /dev/null +++ b/src/card-controller/actions/actions/url.ts @@ -0,0 +1,11 @@ +import { URLActionConfig } from '../../../config/types'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class URLAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + window.open(this._action.url_path); + } +} diff --git a/src/card-controller/actions/actions/view.ts b/src/card-controller/actions/actions/view.ts index c04c9cd0..7869ae4b 100644 --- a/src/card-controller/actions/actions/view.ts +++ b/src/card-controller/actions/actions/view.ts @@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base'; export class ViewAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + await api.getViewManager().setViewByParametersWithNewQuery({ params: { view: this._action.advanced_camera_card_action, diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index fea18a34..76b87b8c 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -1,15 +1,19 @@ import { ActionContext } from 'action'; -import { ActionType, INTERNAL_CALLBACK_ACTION } from '../../config/types'; -import { ActionConfig } from '../../ha/types'; -import { convertActionToCardCustomAction } from '../../utils/action'; +import { + ActionConfig, + AuxillaryActionConfig, + INTERNAL_CALLBACK_ACTION, +} from '../../config/types'; +import { isAdvancedCameraCardCustomAction } from '../../utils/action'; +import { CallServiceAction } from './actions/call-service'; import { CameraSelectAction } from './actions/camera-select'; import { CameraUIAction } from './actions/camera-ui'; +import { CustomAction } from './actions/custom'; import { DefaultAction } from './actions/default'; import { DisplayModeSelectAction } from './actions/display-mode-select'; import { DownloadAction } from './actions/download'; import { ExpandAction } from './actions/expand'; import { FullscreenAction } from './actions/fullscreen'; -import { GenericAction } from './actions/generic'; import { InternalCallbackAction } from './actions/internal-callback'; import { LogAction } from './actions/log'; import { MediaPlayerAction } from './actions/media-player'; @@ -18,8 +22,12 @@ import { MicrophoneConnectAction } from './actions/microphone-connect'; import { MicrophoneDisconnectAction } from './actions/microphone-disconnect'; import { MicrophoneMuteAction } from './actions/microphone-mute'; import { MicrophoneUnmuteAction } from './actions/microphone-unmute'; +import { MoreInfoAction } from './actions/more-info'; import { MuteAction } from './actions/mute'; +import { NavigateAction } from './actions/navigate'; +import { NoneAction } from './actions/none'; import { PauseAction } from './actions/pause'; +import { PerformActionAction } from './actions/perform-action'; import { PlayAction } from './actions/play'; import { PTZAction } from './actions/ptz'; import { PTZControlsAction } from './actions/ptz-controls'; @@ -31,39 +39,53 @@ import { StatusBarAction } from './actions/status-bar'; import { SubstreamOffAction } from './actions/substream-off'; import { SubstreamOnAction } from './actions/substream-on'; import { SubstreamSelectAction } from './actions/substream-select'; +import { ToggleAction } from './actions/toggle'; import { UnmuteAction } from './actions/unmute'; +import { URLAction } from './actions/url'; import { ViewAction } from './actions/view'; -import { Action, AuxillaryActionConfig } from './types'; +import { Action } from './types'; export class ActionFactory { public createAction( context: ActionContext, - action: ActionType, + action: ActionConfig, options?: { config?: AuxillaryActionConfig; cardID?: string; }, ): Action | null { - const cardCustomAction = convertActionToCardCustomAction(action); - if (action.action !== 'fire-dom-event' || !cardCustomAction) { - // * There is a slight typing (but not functional) difference between - // ActionType in this card and ActionConfig in `custom-card-helpers`. See - // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and - // reason behind this difference. - return new GenericAction(context, action as ActionConfig, options?.config); - } - if ( // Command not intended for this card (e.g. query string command). - cardCustomAction.card_id && - cardCustomAction.card_id !== options?.cardID + action.card_id && + action.card_id !== options?.cardID ) { return null; } - switch (cardCustomAction.advanced_camera_card_action) { + switch (action.action) { + case 'more-info': + return new MoreInfoAction(context, action, options?.config); + case 'toggle': + return new ToggleAction(context, action, options?.config); + case 'navigate': + return new NavigateAction(context, action, options?.config); + case 'url': + return new URLAction(context, action, options?.config); + case 'perform-action': + return new PerformActionAction(context, action, options?.config); + case 'call-service': + return new CallServiceAction(context, action, options?.config); + case 'none': + return new NoneAction(context, action, options?.config); + } + + if (!isAdvancedCameraCardCustomAction(action)) { + return new CustomAction(context, action, options?.config); + } + + switch (action.advanced_camera_card_action) { case 'default': - return new DefaultAction(context, cardCustomAction, options?.config); + return new DefaultAction(context, action, options?.config); case 'clip': case 'clips': case 'image': @@ -74,72 +96,68 @@ export class ActionFactory { case 'snapshots': case 'timeline': case 'diagnostics': - return new ViewAction(context, cardCustomAction, options?.config); + return new ViewAction(context, action, options?.config); case 'sleep': - return new SleepAction(context, cardCustomAction, options?.config); + return new SleepAction(context, action, options?.config); case 'download': - return new DownloadAction(context, cardCustomAction, options?.config); + return new DownloadAction(context, action, options?.config); case 'camera_ui': - return new CameraUIAction(context, cardCustomAction, options?.config); + return new CameraUIAction(context, action, options?.config); case 'expand': - return new ExpandAction(context, cardCustomAction, options?.config); + return new ExpandAction(context, action, options?.config); case 'fullscreen': - return new FullscreenAction(context, cardCustomAction, options?.config); + return new FullscreenAction(context, action, options?.config); case 'menu_toggle': - return new MenuToggleAction(context, cardCustomAction, options?.config); + return new MenuToggleAction(context, action, options?.config); case 'camera_select': - return new CameraSelectAction(context, cardCustomAction, options?.config); + return new CameraSelectAction(context, action, options?.config); case 'live_substream_select': - return new SubstreamSelectAction(context, cardCustomAction, options?.config); + return new SubstreamSelectAction(context, action, options?.config); case 'live_substream_off': - return new SubstreamOffAction(context, cardCustomAction, options?.config); + return new SubstreamOffAction(context, action, options?.config); case 'live_substream_on': - return new SubstreamOnAction(context, cardCustomAction, options?.config); + return new SubstreamOnAction(context, action, options?.config); case 'media_player': - return new MediaPlayerAction(context, cardCustomAction, options?.config); + return new MediaPlayerAction(context, action, options?.config); case 'microphone_connect': - return new MicrophoneConnectAction(context, cardCustomAction, options?.config); + return new MicrophoneConnectAction(context, action, options?.config); case 'microphone_disconnect': - return new MicrophoneDisconnectAction( - context, - cardCustomAction, - options?.config, - ); + return new MicrophoneDisconnectAction(context, action, options?.config); case 'microphone_mute': - return new MicrophoneMuteAction(context, cardCustomAction, options?.config); + return new MicrophoneMuteAction(context, action, options?.config); case 'microphone_unmute': - return new MicrophoneUnmuteAction(context, cardCustomAction, options?.config); + return new MicrophoneUnmuteAction(context, action, options?.config); case 'mute': - return new MuteAction(context, cardCustomAction, options?.config); + return new MuteAction(context, action, options?.config); case 'unmute': - return new UnmuteAction(context, cardCustomAction, options?.config); + return new UnmuteAction(context, action, options?.config); case 'play': - return new PlayAction(context, cardCustomAction, options?.config); + return new PlayAction(context, action, options?.config); case 'pause': - return new PauseAction(context, cardCustomAction, options?.config); + return new PauseAction(context, action, options?.config); case 'screenshot': - return new ScreenshotAction(context, cardCustomAction, options?.config); + return new ScreenshotAction(context, action, options?.config); case 'display_mode_select': - return new DisplayModeSelectAction(context, cardCustomAction, options?.config); + return new DisplayModeSelectAction(context, action, options?.config); case 'ptz': - return new PTZAction(context, cardCustomAction, options?.config); + return new PTZAction(context, action, options?.config); case 'ptz_digital': - return new PTZDigitalAction(context, cardCustomAction, options?.config); + return new PTZDigitalAction(context, action, options?.config); case 'ptz_multi': - return new PTZMultiAction(context, cardCustomAction, options?.config); + return new PTZMultiAction(context, action, options?.config); case 'ptz_controls': - return new PTZControlsAction(context, cardCustomAction, options?.config); + return new PTZControlsAction(context, action, options?.config); case 'log': - return new LogAction(context, cardCustomAction, options?.config); + return new LogAction(context, action, options?.config); case 'status_bar': - return new StatusBarAction(context, cardCustomAction, options?.config); + return new StatusBarAction(context, action, options?.config); case INTERNAL_CALLBACK_ACTION: - return new InternalCallbackAction(context, cardCustomAction, options?.config); + return new InternalCallbackAction(context, action, options?.config); } /* istanbul ignore next: this path cannot be reached -- @preserve */ console.warn( - `Advanced Camera Card received unknown card action: ${cardCustomAction['advanced_camera_card_action']}`, + `Advanced Camera Card received unknown card action: ${action['advanced_camera_card_action']}`, ); /* istanbul ignore next: this path cannot be reached -- @preserve */ return null; diff --git a/src/card-controller/actions/types.ts b/src/card-controller/actions/types.ts index 45197585..5abbffaf 100644 --- a/src/card-controller/actions/types.ts +++ b/src/card-controller/actions/types.ts @@ -1,18 +1,14 @@ -import { ActionType } from '../../config/types'; +import { AdvancedCameraCardError } from '../../types.js'; +import { ActionConfig, AuxillaryActionConfig } from '../../config/types'; import { CardActionsAPI } from '../types'; -export interface AuxillaryActionConfig { - camera_image?: string; - entity?: string; -} - export interface Action { execute(api: CardActionsAPI): Promise; stop(): Promise; } export interface ActionExecutionRequest { - action: ActionType[] | ActionType; + action: ActionConfig[] | ActionConfig; config?: AuxillaryActionConfig; } @@ -21,3 +17,5 @@ export interface TargetedActionContext { inProgressAction?: Action; }; } + +export class ActionAbortError extends AdvancedCameraCardError {} diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index fee5e9d1..d7f846d7 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -1,4 +1,4 @@ -import { AdvancedCameraCardCustomAction, ViewActionConfig } from '../config/types'; +import { AdvancedCameraCardCustomActionConfig, ViewActionConfig } from '../config/types'; import { createCameraAction, createGeneralAction, @@ -13,7 +13,7 @@ interface QueryStringViewIntent { default?: boolean; substream?: string; }; - other?: AdvancedCameraCardCustomAction[]; + other?: AdvancedCameraCardCustomActionConfig[]; } export class QueryStringManager { @@ -92,9 +92,9 @@ export class QueryStringManager { return result; } - protected _getActions(): AdvancedCameraCardCustomAction[] { + protected _getActions(): AdvancedCameraCardCustomActionConfig[] { const params = new URLSearchParams(window.location.search); - const actions: AdvancedCameraCardCustomAction[] = []; + const actions: AdvancedCameraCardCustomActionConfig[] = []; const actionRE = new RegExp( /^(advanced-camera-card|frigate-card)-action([.:](?\w+))?[.:](?\w+)/, ); @@ -104,14 +104,14 @@ export class QueryStringManager { continue; } const cardID: string | undefined = match.groups['cardID']; - const action = match.groups['action']; + const actionName = match.groups['action']; - let customAction: AdvancedCameraCardCustomAction | null = null; - switch (action) { + let action: AdvancedCameraCardCustomActionConfig | null = null; + switch (actionName) { case 'camera_select': case 'live_substream_select': if (value) { - customAction = createCameraAction(action, value, { + action = createCameraAction(actionName, value, { cardID: cardID, }); } @@ -121,7 +121,7 @@ export class QueryStringManager { case 'download': case 'expand': case 'menu_toggle': - customAction = createGeneralAction(action, { + action = createGeneralAction(actionName, { cardID: cardID, }); break; @@ -135,24 +135,24 @@ export class QueryStringManager { case 'snapshot': case 'snapshots': case 'timeline': - customAction = createViewAction(action, { + action = createViewAction(actionName, { cardID: cardID, }); break; default: console.warn( - `Advanced Camera Card received unknown card action in query string: ${action}`, + `Advanced Camera Card received unknown card action in query string: ${actionName}`, ); } - if (customAction) { - actions.push(customAction); + if (action) { + actions.push(action); } } return actions; } protected _isViewAction = ( - action: AdvancedCameraCardCustomAction, + action: AdvancedCameraCardCustomActionConfig, ): action is ViewActionConfig => { switch (action.advanced_camera_card_action) { case 'clip': diff --git a/src/card-controller/templates/index.ts b/src/card-controller/templates/index.ts index 47e74a07..b455bd41 100644 --- a/src/card-controller/templates/index.ts +++ b/src/card-controller/templates/index.ts @@ -1,6 +1,5 @@ import { HASS, renderTemplate } from 'ha-nunjucks/dist'; import { ConditionState, ConditionsTriggerData } from '../../conditions/types'; -import { ActionType } from '../../config/types'; import { HomeAssistant } from '../../ha/types'; interface TemplateContextInternal { @@ -24,7 +23,7 @@ export class TemplateRenderer { conditionState?: ConditionState; triggerData?: ConditionsTriggerData; }, - ): ActionType => { + ): unknown => { return this._renderTemplateRecursively( hass, data, @@ -59,11 +58,15 @@ export class TemplateRenderer { hass: HomeAssistant, data: unknown, templateContext?: TemplateContext, - ): ActionType { + ): unknown { if (typeof data === 'string') { - // ha-nunjucks has a more complete model of the Home Assistant object, but - // does not export it as a type. - return renderTemplate(hass as unknown as typeof HASS, data, templateContext); + return renderTemplate( + // ha-nunjucks has a more complete model of the Home Assistant object, but + // does not export it as a type. + hass as unknown as typeof HASS, + data, + templateContext, + ); } else if (Array.isArray(data)) { return data.map((item) => this._renderTemplateRecursively(hass, item, templateContext), diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 1526ffdf..2199cb9b 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -6,7 +6,6 @@ import { MicrophoneManager } from '../card-controller/microphone-manager'; import { ViewManager } from '../card-controller/view/view-manager'; import { AdvancedCameraCardConfig, - AdvancedCameraCardCustomAction, MenuItem, VIEWS_USER_SPECIFIED, } from '../config/types'; @@ -21,8 +20,9 @@ import { createPTZControlsAction, createPTZMultiAction, createViewAction, + isAdvancedCameraCardCustomAction, } from '../utils/action'; -import { isTruthy } from '../utils/basic'; +import { arrayify, isTruthy } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; import { getEntityTitle } from '../utils/ha'; import { getPTZTarget } from '../utils/ptz'; @@ -118,9 +118,9 @@ export class MenuButtonController { permanent: true, tap_action: config.menu?.style === 'hidden' - ? (createGeneralAction('menu_toggle') as AdvancedCameraCardCustomAction) - : (createGeneralAction('default') as AdvancedCameraCardCustomAction), - hold_action: createViewAction('diagnostics') as AdvancedCameraCardCustomAction, + ? createGeneralAction('menu_toggle') + : createGeneralAction('default'), + hold_action: createViewAction('diagnostics'), }; } @@ -191,7 +191,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', tap_action: createGeneralAction( hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', - ) as AdvancedCameraCardCustomAction, + ), }; } else if (streams.length > 2) { const menuItems = Array.from(streams, (streamID) => { @@ -234,7 +234,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.live'), style: view.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('live') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('live'), } : null; } @@ -251,8 +251,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.clips'), style: view?.is('clips') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('clips') as AdvancedCameraCardCustomAction, - hold_action: createViewAction('clip') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('clips'), + hold_action: createViewAction('clip'), } : null; } @@ -269,8 +269,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.snapshots'), style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('snapshots') as AdvancedCameraCardCustomAction, - hold_action: createViewAction('snapshot') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('snapshots'), + hold_action: createViewAction('snapshot'), } : null; } @@ -287,8 +287,8 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.recordings'), style: view.is('recordings') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('recordings') as AdvancedCameraCardCustomAction, - hold_action: createViewAction('recording') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('recordings'), + hold_action: createViewAction('recording'), } : null; } @@ -305,7 +305,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.image'), style: view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('image') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('image'), } : null; } @@ -322,7 +322,7 @@ export class MenuButtonController { type: 'custom:advanced-camera-card-menu-icon', title: localize('config.view.views.timeline'), style: view.is('timeline') ? this._getEmphasizedStyle() : {}, - tap_action: createViewAction('timeline') as AdvancedCameraCardCustomAction, + tap_action: createViewAction('timeline'), } : null; } @@ -342,7 +342,7 @@ export class MenuButtonController { ...config.menu.buttons.download, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.download'), - tap_action: createGeneralAction('download') as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction('download'), }; } return null; @@ -358,7 +358,7 @@ export class MenuButtonController { ...config.menu.buttons.camera_ui, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.camera_ui'), - tap_action: createGeneralAction('camera_ui') as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction('camera_ui'), } : null; } @@ -385,18 +385,14 @@ export class MenuButtonController { style: unavailable || muted ? {} : this._getEmphasizedStyle(true), ...(!unavailable && buttonType === 'momentary' && { - start_tap_action: createGeneralAction( - 'microphone_unmute', - ) as AdvancedCameraCardCustomAction, - end_tap_action: createGeneralAction( - 'microphone_mute', - ) as AdvancedCameraCardCustomAction, + start_tap_action: createGeneralAction('microphone_unmute'), + end_tap_action: createGeneralAction('microphone_mute'), }), ...(!unavailable && buttonType === 'toggle' && { tap_action: createGeneralAction( muted ? 'microphone_unmute' : 'microphone_mute', - ) as AdvancedCameraCardCustomAction, + ), }), }; } @@ -412,7 +408,7 @@ export class MenuButtonController { ...config.menu.buttons.expand, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.expand'), - tap_action: createGeneralAction('expand') as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction('expand'), style: inExpandedMode ? this._getEmphasizedStyle() : {}, }; } @@ -428,9 +424,7 @@ export class MenuButtonController { ...config.menu.buttons.fullscreen, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.fullscreen'), - tap_action: createGeneralAction( - 'fullscreen', - ) as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction('fullscreen'), style: inFullscreen ? this._getEmphasizedStyle() : {}, } : null; @@ -498,9 +492,7 @@ export class MenuButtonController { ...config.menu.buttons.play, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.play'), - tap_action: createGeneralAction( - paused ? 'play' : 'pause', - ) as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction(paused ? 'play' : 'pause'), }; } return null; @@ -521,9 +513,7 @@ export class MenuButtonController { ...config.menu.buttons.mute, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.mute'), - tap_action: createGeneralAction( - muted ? 'unmute' : 'mute', - ) as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction(muted ? 'unmute' : 'mute'), }; } return null; @@ -539,7 +529,7 @@ export class MenuButtonController { ...config.menu.buttons.screenshot, type: 'custom:advanced-camera-card-menu-icon', title: localize('config.menu.buttons.screenshot'), - tap_action: createGeneralAction('screenshot') as AdvancedCameraCardCustomAction, + tap_action: createGeneralAction('screenshot'), }; } return null; @@ -637,7 +627,7 @@ export class MenuButtonController { title: localize('config.menu.buttons.ptz_home'), tap_action: createPTZMultiAction({ targetID: target.targetID, - }) as AdvancedCameraCardCustomAction, + }), }; } @@ -675,30 +665,23 @@ export class MenuButtonController { button.start_tap_action, button.end_tap_action, ]) { - const actions = Array.isArray(actionSet) ? actionSet : [actionSet]; - for (const action of actions) { - // All advanced camera card actions will have action of 'fire-dom-event' and - // styling only applies to those. - if ( - !action || - action.action !== 'fire-dom-event' || - !('advanced_camera_card_action' in action) - ) { + for (const action of arrayify(actionSet)) { + if (!isAdvancedCameraCardCustomAction(action)) { continue; } - const customCardAction = action as AdvancedCameraCardCustomAction; + if ( VIEWS_USER_SPECIFIED.some( (viewName) => - viewName === customCardAction.advanced_camera_card_action && - options?.view?.is(customCardAction.advanced_camera_card_action), + viewName === action.advanced_camera_card_action && + options?.view?.is(action.advanced_camera_card_action), ) || - (customCardAction.advanced_camera_card_action === 'default' && + (action.advanced_camera_card_action === 'default' && options?.view?.is(config.view.default)) || - (customCardAction.advanced_camera_card_action === 'fullscreen' && + (action.advanced_camera_card_action === 'fullscreen' && !!options?.fullscreenManager?.isInFullscreen()) || - (customCardAction.advanced_camera_card_action === 'camera_select' && - options?.view?.camera === customCardAction.camera) + (action.advanced_camera_card_action === 'camera_select' && + options?.view?.camera === action.camera) ) { return this._getEmphasizedStyle(); } diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index f2f4297d..8ea66bb4 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -3,17 +3,14 @@ import { orderBy } from 'lodash-es'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; import { SubmenuInteraction } from '../components/submenu/types.js'; import { + ActionConfig, MENU_PRIORITY_MAX, - type ActionType, type ActionsConfig, type MenuConfig, type MenuItem, } from '../config/types.js'; import { Interaction } from '../types.js'; -import { - convertActionToCardCustomAction, - getActionConfigGivenAction, -} from '../utils/action'; +import { getActionConfigGivenAction } from '../utils/action'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; export class MenuController { @@ -114,7 +111,7 @@ export class MenuController { let menuToggle = false; const toggleLessActions = actions.filter( - (item) => isTruthy(item) && !this._isUnknownActionMenuToggleAction(item), + (item) => isTruthy(item) && !this._isMenuToggleAction(item), ); if (toggleLessActions.length != actions.length) { menuToggle = true; @@ -171,8 +168,10 @@ export class MenuController { return this._config?.style === 'hidden'; } - protected _isUnknownActionMenuToggleAction(action: ActionType): boolean { - const parsedAction = convertActionToCardCustomAction(action); - return !!parsedAction && parsedAction.advanced_camera_card_action == 'menu_toggle'; + protected _isMenuToggleAction(action: ActionConfig): boolean { + return ( + action.action === 'fire-dom-event' && + action.advanced_camera_card_action === 'menu_toggle' + ); } } diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index 1169c53f..ddd45900 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -8,9 +8,10 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../../config/types.js'; +import { MenuSubmenuItem, MenuSubmenuSelect } from '../../config/types.js'; import { HomeAssistant } from '../../ha/types.js'; import menuButtonStyle from '../../scss/menu-button.scss'; +import { Icon } from '../../types.js'; import { getEntityTitle, isHassDifferent } from '../../utils/ha'; import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/index.js'; @@ -31,7 +32,8 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { @state() protected _optionTitles?: Record; - protected _generatedSubmenu?: MenuSubmenu; + protected _generatedSubmenuItems?: MenuSubmenuItem[]; + protected _generatedIcon?: Icon; protected shouldUpdate(changedProps: PropertyValues): boolean { // No need to update the submenu unless the select entity has changed. @@ -86,29 +88,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { return; } - const title = getEntityTitle(this.hass, entityID); - const submenu: MenuSubmenu = { - ...(title && { title }), - - // Override it with anything explicitly set in the submenuSelect. - ...this.submenuSelect, - - icon: { - icon: this.submenuSelect.icon, - entity: entityID, - fallback: 'mdi:format-list-bulleted', - }, - - type: 'custom:advanced-camera-card-menu-submenu', - items: [], - }; - - // For cleanliness remove the options parameter which is unused by the - // submenu rendering itself (above). It is only in this method to populate - // the items correctly (below). - delete submenu['options']; - - const items = submenu.items as MenuSubmenuItem[]; + const items: MenuSubmenuItem[] = []; for (const option of options) { const title = this._optionTitles?.[option] ?? option; @@ -136,31 +116,32 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { }); } - this._generatedSubmenu = submenu; + this._generatedSubmenuItems = items; + this._generatedIcon = { + icon: this.submenuSelect.icon, + entity: entityID, + fallback: 'mdi:format-list-bulleted', + }; } protected render(): TemplateResult { - const submenu = this._generatedSubmenu; - if (!submenu) { + if (!this._generatedSubmenuItems || !this._generatedIcon || !this.submenuSelect) { return html``; } - const style = styleMap(submenu.style || {}); + const title = getEntityTitle(this.hass, this.submenuSelect.entity); + const style = styleMap(this.submenuSelect.style || {}); return html` - + `; diff --git a/src/components/submenu/submenu-button.ts b/src/components/submenu/submenu-button.ts index f0726ed8..27f2db8f 100644 --- a/src/components/submenu/submenu-button.ts +++ b/src/components/submenu/submenu-button.ts @@ -3,9 +3,9 @@ import { customElement, property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../../action-handler-directive.js'; import { MenuSubmenu } from '../../config/types.js'; -import { hasAction } from '../../ha/has-action.js'; import { HomeAssistant } from '../../ha/types.js'; import menuButtonStyle from '../../scss/menu-button.scss'; +import { hasAction } from '../../utils/action.js'; import '../icon.js'; import './index.js'; diff --git a/src/components/submenu/types.ts b/src/components/submenu/types.ts index 4f152c75..d5c83559 100644 --- a/src/components/submenu/types.ts +++ b/src/components/submenu/types.ts @@ -1,6 +1,7 @@ +import { Actions } from '../../config/types'; import { Interaction } from '../../types'; -export interface SubmenuItem { +export interface SubmenuItem extends Actions { title?: string; subtitle?: string; icon?: string; @@ -8,10 +9,6 @@ export interface SubmenuItem { style?: Record; enabled?: boolean; selected?: boolean; - - hold_action?: unknown; - double_tap_action?: unknown; - [key: string]: unknown; } export interface SubmenuInteraction extends Interaction { diff --git a/src/config/types.ts b/src/config/types.ts index a1b4144d..54c292fd 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,16 +1,6 @@ import { HassServiceTarget } from 'home-assistant-js-websocket'; import { z } from 'zod'; import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js'; -import { - CallServiceActionConfig, - ConfirmationRestrictionConfig, - MoreInfoActionConfig, - NavigateActionConfig, - NoActionConfig, - PerformActionActionConfig, - ToggleActionConfig, - UrlActionConfig, -} from '../ha/types.js'; import { capabilityKeys } from '../types.js'; import { deepRemoveDefaults } from '../utils/zod.js'; import { @@ -123,12 +113,11 @@ const viewDisplaySchema = z export type ViewDisplayConfig = z.infer; // ************************************************************************* -// Actions -// -// Declare schemas to existing types: -// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 +// Stock Actions // ************************************************************************* +// Declare schemas for existing types. +// See: https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 const schemaForType = () => // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -152,108 +141,86 @@ const actionBaseSchema = z.object({ }), ) .optional(), + + card_id: z + .string() + .regex(cardIDRegex, 'card_id parameter can only contain [a-z][A-Z][0-9_]-') + .optional(), }); -// HA accepts either a boolean or a ConfirmationRestrictionConfig object. -// `custom-card-helpers` currently only supports the latter. For maximum -// compatibility, this card supports what HA supports. -interface ExtendedConfirmationRestrictionConfig { - confirmation?: boolean | ConfirmationRestrictionConfig; -} - -const toggleActionSchema = schemaForType< - ToggleActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('toggle'), - }), -); +const toggleActionSchema = actionBaseSchema.extend({ + action: z.literal('toggle'), +}); +export type ToggleActionConfig = z.infer; const targetSchema = schemaForType()( z.object({ - entity_id: z.string().optional(), - device_id: z.string().optional(), - area_id: z.string().optional(), + entity_id: z.string().or(z.string().array()).optional(), + device_id: z.string().or(z.string().array()).optional(), + area_id: z.string().or(z.string().array()).optional(), + floor_id: z.string().or(z.string().array()).optional(), + label_id: z.string().or(z.string().array()).optional(), }), ); -const performActionActionSchema = schemaForType< - PerformActionActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('perform-action'), - perform_action: z.string(), - data: z.object({}).passthrough().optional(), - target: targetSchema.optional(), - }), -); +const performActionActionSchema = actionBaseSchema.extend({ + action: z.literal('perform-action'), + perform_action: z.string(), + data: z.object({}).passthrough().optional(), + target: targetSchema.optional(), +}); +export type PerformActionActionConfig = z.infer; // Note: call-service is deprecated and will eventually go away. Please use // perform-action instead. // See: https://www.home-assistant.io/blog/2024/08/07/release-20248/#goodbye-service-calls-hello-actions- -const callServiceActionSchema = schemaForType< - CallServiceActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('call-service'), - service: z.string(), - data: z.object({}).passthrough().optional(), - target: targetSchema.optional(), - }), -); +const callServiceActionSchema = actionBaseSchema.extend({ + action: z.literal('call-service'), + service: z.string(), + data: z.object({}).passthrough().optional(), + target: targetSchema.optional(), +}); +export type CallServiceActionConfig = z.infer; -const navigateActionSchema = schemaForType< - NavigateActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('navigate'), - navigation_path: z.string(), - }), -); +const navigateActionSchema = actionBaseSchema.extend({ + action: z.literal('navigate'), + navigation_path: z.string(), + navigation_replace: z.boolean().optional(), +}); +export type NavigateActionConfig = z.infer; -const urlActionSchema = schemaForType< - UrlActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('url'), - url_path: z.string(), - }), -); +const urlActionSchema = actionBaseSchema.extend({ + action: z.literal('url'), + url_path: z.string(), +}); +export type URLActionConfig = z.infer; -const moreInfoActionSchema = schemaForType< - MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('more-info'), - }), -); +const moreInfoActionSchema = actionBaseSchema.extend({ + action: z.literal('more-info'), + entity: z.string().optional(), +}); +export type MoreInfoActionConfig = z.infer; const customActionSchema = actionBaseSchema .extend({ action: z.literal('fire-dom-event'), }) .passthrough(); +export type CustomActionConfig = z.infer; -const noActionSchema = schemaForType< - NoActionConfig & ExtendedConfirmationRestrictionConfig ->()( - actionBaseSchema.extend({ - action: z.literal('none'), - }), -); +const noneActionSchema = actionBaseSchema.extend({ + action: z.literal('none'), +}); +export type NoneActionConfig = z.infer; -export const advancedCameraCardCustomActionsBaseSchema = customActionSchema.extend({ +export const advancedCameraCardCustomActionsBaseSchema = actionBaseSchema.extend({ action: z - .literal('custom:advanced-camera-card-action') - // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. - .transform((): 'fire-dom-event' => 'fire-dom-event') - .or(z.literal('fire-dom-event')), - - // Card this command is intended for. - card_id: z - .string() - .regex(cardIDRegex, 'card_id parameter can only contain [a-z][A-Z][0-9_]-') - .optional(), + .literal('fire-dom-event') + .or( + z + .literal('custom:advanced-camera-card-action') + .transform((): 'fire-dom-event' => 'fire-dom-event'), + ), }); // ************************************************************************* @@ -374,18 +341,28 @@ const sleepActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend }); export type SleepActionConfig = z.infer; -const statusBarActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ - advanced_camera_card_action: z.literal('status_bar'), - status_bar_action: z.enum(['add', 'remove', 'reset']), - - // This needs to be lazily evaluated since statusBarItemSchema may itself - // contain actions. +// For StatusBarActionConfig provide a manual type definition to avoid the `any` +// that would be created by the lazy() evaluation below. +// See: https://zod.dev/?id=recursive-types +const statusBarActionConfigSchemaBase = advancedCameraCardCustomActionsBaseSchema.extend( + { + advanced_camera_card_action: z.literal('status_bar'), + status_bar_action: z.enum(['add', 'remove', 'reset']), + }, +); +export type StatusBarActionConfig = z.infer & { + items?: StatusBarItem[]; +}; +export const statusBarActionConfigSchema: z.ZodSchema< + StatusBarActionConfig, + z.ZodTypeDef, + unknown +> = statusBarActionConfigSchemaBase.extend({ items: z .lazy(() => statusBarItemSchema) .array() .optional(), }); -export type StatusBarActionConfig = z.infer; const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const; export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number]; @@ -397,27 +374,8 @@ const logActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ }); export type LogActionConfig = z.infer; -const advancedCameraCardCustomActionSchema = z.union([ - cameraSelectActionConfigSchema, - generalActionConfigSchema, - substreamSelectActionConfigSchema, - logActionConfigSchema, - mediaPlayerActionConfigSchema, - ptzActionConfigSchema, - ptzDigitalActionConfigSchema, - ptzMultiActionSchema, - ptzControlsActionConfigSchema, - viewActionConfigSchema, - viewDisplayModeActionConfigSchema, - sleepActionConfigSchema, - statusBarActionConfigSchema, -]); -export type AdvancedCameraCardCustomAction = z.infer< - typeof advancedCameraCardCustomActionSchema ->; - -// An action that can be used internally to call a callback. -// Note: The internal callback action is kept out of schemas that can be user-specified. +// An action that can be used internally to call a callback (it is not possible +// for the user to pass this through via the configuration). export const INTERNAL_CALLBACK_ACTION = '__INTERNAL_CALLBACK_ACTION__'; const internalCallbackActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ @@ -430,57 +388,66 @@ export type InternalCallbackActionConfig = z.infer< typeof internalCallbackActionConfigSchema >; -export const internalAdvancedCameraCardCustomActionSchema = - advancedCameraCardCustomActionSchema.or(internalCallbackActionConfigSchema); -export type InternalAdvancedCameraCardCustomAction = z.infer< - typeof internalAdvancedCameraCardCustomActionSchema ->; - -// Cannot use discriminatedUnion since advancedCameraCardCustomActionSchema uses -// a transform on the discriminated union key. -export const actionSchema = z.union([ - toggleActionSchema, +const stockActionSchema = z.union([ callServiceActionSchema, - performActionActionSchema, - navigateActionSchema, - urlActionSchema, - moreInfoActionSchema, - noActionSchema, customActionSchema, - advancedCameraCardCustomActionSchema, + moreInfoActionSchema, + navigateActionSchema, + noneActionSchema, + performActionActionSchema, + toggleActionSchema, + urlActionSchema, ]); -const internalActionSchema = actionSchema.or(internalCallbackActionConfigSchema); -export type ActionType = z.infer; +const advancedCameraCardCustomActionSchema = z.union([ + cameraSelectActionConfigSchema, + generalActionConfigSchema, + internalCallbackActionConfigSchema, + logActionConfigSchema, + mediaPlayerActionConfigSchema, + ptzActionConfigSchema, + ptzControlsActionConfigSchema, + ptzDigitalActionConfigSchema, + ptzMultiActionSchema, + sleepActionConfigSchema, + statusBarActionConfigSchema, + substreamSelectActionConfigSchema, + viewActionConfigSchema, + viewDisplayModeActionConfigSchema, +]); +export type AdvancedCameraCardCustomActionConfig = z.infer< + typeof advancedCameraCardCustomActionSchema +>; + +const actionConfigSchema = z.union([ + stockActionSchema, + advancedCameraCardCustomActionSchema, +]); +export type ActionConfig = z.infer; + +export interface AuxillaryActionConfig { + entity?: string; +} const actionsBaseSchema = z .object({ - tap_action: actionSchema.or(actionSchema.array()).optional(), - hold_action: actionSchema.or(actionSchema.array()).optional(), - double_tap_action: actionSchema.or(actionSchema.array()).optional(), - start_tap_action: actionSchema.or(actionSchema.array()).optional(), - end_tap_action: actionSchema.or(actionSchema.array()).optional(), + tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), + hold_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), + double_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), + start_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), + end_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), }) // Passthrough to allow (at least) entity/camera_image to go through. This // card doesn't need these attributes, but handleAction() in // custom_card_helpers may depending on how the action is configured. .passthrough(); export type Actions = z.infer; - -export type ActionsConfig = Actions & { - camera_image?: string; - entity?: string; -}; +export type ActionsConfig = Actions & AuxillaryActionConfig; const actionsSchema = z.object({ actions: actionsBaseSchema.optional(), }); -const elementsBaseSchema = actionsBaseSchema.extend({ - style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(), - title: z.string().nullable().optional(), -}); - // ************************************************************************* // Picture Elements // @@ -489,6 +456,11 @@ const elementsBaseSchema = actionsBaseSchema.extend({ // display up-front regardless of where they made their error. // ************************************************************************* +const elementsBaseSchema = actionsBaseSchema.extend({ + style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(), + title: z.string().nullable().optional(), +}); + // https://www.home-assistant.io/lovelace/picture-elements/#state-badge const stateBadgeIconSchema = elementsBaseSchema.extend({ type: z.literal('state-badge'), @@ -2009,7 +1981,7 @@ export type Overrides = z.infer; // Automation Configuration // ************************************************************************* -const automationActionSchema = actionSchema.array(); +const automationActionSchema = actionConfigSchema.array(); export type AutomationActions = z.infer; const automationSchema = z diff --git a/src/ha/handle-action.ts b/src/ha/handle-action.ts deleted file mode 100644 index e504a3e0..00000000 --- a/src/ha/handle-action.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { fireHASSEvent } from './fire-hass-event.js'; -import { forwardHaptic } from './haptic.js'; -import { navigate } from './navigate.js'; -import { toggleEntity } from './toggle-entity.js'; -import { ActionConfig, HomeAssistant } from './types.js'; - -export const handleActionConfig = ( - node: HTMLElement, - hass: HomeAssistant, - config: { - entity?: string; - camera_image?: string; - hold_action?: ActionConfig; - tap_action?: ActionConfig; - double_tap_action?: ActionConfig; - }, - actionConfig: ActionConfig | undefined, -): void => { - if (!actionConfig) { - actionConfig = { - action: 'more-info', - }; - } - - if ( - actionConfig.confirmation && - (!actionConfig.confirmation.exemptions || - !actionConfig.confirmation.exemptions.some((e) => e.user === hass!.user!.id)) - ) { - forwardHaptic('warning'); - - if ( - !confirm( - actionConfig.confirmation.text || - `Are you sure you want to ${actionConfig.action}?`, - ) - ) { - return; - } - } - - switch (actionConfig.action) { - case 'more-info': - if (config.entity || config.camera_image) { - fireHASSEvent(node, 'hass-more-info', { - entityId: config.entity ? config.entity : config.camera_image!, - }); - } - break; - case 'navigate': - if (actionConfig.navigation_path) { - navigate(node, actionConfig.navigation_path); - } - break; - case 'url': - if (actionConfig.url_path) { - window.open(actionConfig.url_path); - } - break; - case 'toggle': - if (config.entity) { - toggleEntity(hass, config.entity!); - forwardHaptic('success'); - } - break; - case 'perform-action': { - if (!actionConfig.perform_action) { - forwardHaptic('failure'); - return; - } - const [domain, service] = actionConfig.perform_action.split('.', 2); - hass.callService(domain, service, actionConfig.data, actionConfig.target); - forwardHaptic('success'); - break; - } - case 'call-service': { - if (!actionConfig.service) { - forwardHaptic('failure'); - return; - } - const [domain, service] = actionConfig.service.split('.', 2); - hass.callService(domain, service, actionConfig.data, actionConfig.target); - forwardHaptic('success'); - break; - } - case 'fire-dom-event': { - fireHASSEvent(node, 'll-custom', actionConfig); - } - } -}; diff --git a/src/ha/has-action.ts b/src/ha/has-action.ts deleted file mode 100644 index c2a0b15f..00000000 --- a/src/ha/has-action.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ActionConfig } from './types.js'; - -export function hasAction(config?: ActionConfig): boolean { - return config !== undefined && config.action !== 'none'; -} diff --git a/src/ha/navigate.ts b/src/ha/navigate.ts deleted file mode 100644 index 79c00511..00000000 --- a/src/ha/navigate.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { fireHASSEvent } from './fire-hass-event.js'; - -declare global { - interface HASSDomEvents { - 'location-changed': { - replace: boolean; - }; - } -} - -export const navigate = (_node: unknown, path: string, replace: boolean = false) => { - if (replace) { - history.replaceState(null, '', path); - } else { - history.pushState(null, '', path); - } - fireHASSEvent(window, 'location-changed', { - replace, - }); -}; diff --git a/src/ha/toggle-entity.ts b/src/ha/toggle-entity.ts deleted file mode 100644 index 438d6ae6..00000000 --- a/src/ha/toggle-entity.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { STATES_OFF } from './const.js'; -import { turnOnOffEntity } from './turn-on-off-entity.js'; -import { HomeAssistant } from './types.js'; - -export const toggleEntity = (hass: HomeAssistant, entityId: string): Promise => { - const turnOn = STATES_OFF.includes(hass.states[entityId].state); - return turnOnOffEntity(hass, entityId, turnOn); -}; diff --git a/src/ha/turn-on-off-entity.ts b/src/ha/turn-on-off-entity.ts deleted file mode 100644 index 490156be..00000000 --- a/src/ha/turn-on-off-entity.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { HomeAssistant } from './types.js'; -import { computeDomain } from './compute-domain.js'; - -export const turnOnOffEntity = ( - hass: HomeAssistant, - entityId: string, - turnOn = true, -): Promise => { - const stateDomain = computeDomain(entityId); - const serviceDomain = stateDomain === 'group' ? 'homeassistant' : stateDomain; - - let service; - switch (stateDomain) { - case 'lock': - service = turnOn ? 'unlock' : 'lock'; - break; - case 'cover': - service = turnOn ? 'open_cover' : 'close_cover'; - break; - default: - service = turnOn ? 'turn_on' : 'turn_off'; - } - - return hass.callService(serviceDomain, service, { entity_id: entityId }); -}; diff --git a/src/ha/types.ts b/src/ha/types.ts index 2362fc85..fe56a28c 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -7,87 +7,6 @@ import { HassServiceTarget, MessageBase, } from 'home-assistant-js-websocket'; -import { HapticType } from './haptic.js'; - -interface ToggleMenuActionConfig extends BaseActionConfig { - action: 'toggle-menu'; -} - -export interface ToggleActionConfig extends BaseActionConfig { - action: 'toggle'; -} - -export interface CallServiceActionConfig extends BaseActionConfig { - action: 'call-service'; - service: string; - data?: { - entity_id?: string | [string]; - [key: string]: unknown; - }; - target?: HassServiceTarget; - repeat?: number; - haptic?: HapticType; -} - -export interface PerformActionActionConfig extends BaseActionConfig { - action: 'perform-action'; - perform_action: string; - data?: { - entity_id?: string | [string]; - [key: string]: unknown; - }; - target?: HassServiceTarget; - repeat?: number; - haptic?: HapticType; -} - -export interface NavigateActionConfig extends BaseActionConfig { - action: 'navigate'; - navigation_path: string; -} - -export interface UrlActionConfig extends BaseActionConfig { - action: 'url'; - url_path: string; -} -export interface MoreInfoActionConfig extends BaseActionConfig { - action: 'more-info'; - entity?: string; -} -export interface NoActionConfig extends BaseActionConfig { - action: 'none'; -} -interface CustomActionConfig extends BaseActionConfig { - action: 'fire-dom-event'; -} -/** - * `repeat` and `haptic` are specifically for use in custom cards like the Button-Card - */ -interface BaseActionConfig { - confirmation?: ConfirmationRestrictionConfig; - repeat?: number; - haptic?: HapticType; -} - -export interface ConfirmationRestrictionConfig { - text?: string; - exemptions?: RestrictionConfig[]; -} - -interface RestrictionConfig { - user: string; -} - -export declare type ActionConfig = - | ToggleActionConfig - | CallServiceActionConfig - | PerformActionActionConfig - | NavigateActionConfig - | UrlActionConfig - | MoreInfoActionConfig - | NoActionConfig - | CustomActionConfig - | ToggleMenuActionConfig; declare global { interface HASSDomEvents { diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 1ce3c871..a1926687 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "", + "confirmation": "" + }, "common": { "advanced_camera_card": "", "advanced_camera_card_description": "", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c313da24..25902fc4 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "Aborted action", + "confirmation": "Are you sure you want to perform this action" + }, "common": { "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 9b3fa6fb..a3cf642e 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "", + "confirmation": "" + }, "common": { "advanced_camera_card": "", "advanced_camera_card_description": "", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index e2fa1c65..2a814974 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "", + "confirmation": "" + }, "common": { "advanced_camera_card": "", "advanced_camera_card_description": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index e9bb3fef..3c807654 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "", + "confirmation": "" + }, "common": { "advanced_camera_card": "", "advanced_camera_card_description": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 7f72a1b6..b76848b3 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -1,4 +1,8 @@ { + "actions": { + "abort": "", + "confirmation": "" + }, "common": { "advanced_camera_card": "", "advanced_camera_card_description": "", diff --git a/src/utils/action.ts b/src/utils/action.ts index 7ef37d6c..49be3df6 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -2,49 +2,31 @@ import { CardActionsAPI } from '../card-controller/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; import { PTZAction } from '../config/ptz.js'; import { + ActionConfig, ActionPhase, - ActionType, ActionsConfig, + AdvancedCameraCardCustomActionConfig, AdvancedCameraCardGeneralAction, AdvancedCameraCardUserSpecifiedView, CameraSelectActionConfig, DisplayModeActionConfig, GeneralActionConfig, INTERNAL_CALLBACK_ACTION, - InternalAdvancedCameraCardCustomAction, InternalCallbackActionConfig, LogActionConfig, LogActionLevel, MediaPlayerActionConfig, + PerformActionActionConfig, PTZActionConfig, PTZControlsActionConfig, PTZDigitialActionConfig, PTZMultiActionConfig, SubstreamSelectActionConfig, ViewActionConfig, - internalAdvancedCameraCardCustomActionSchema, } from '../config/types.js'; -import { hasAction as customCardHasAction } from '../ha/has-action.js'; -import { ActionConfig, ServiceCallRequest } from '../ha/types.js'; +import { ServiceCallRequest } from '../ha/types.js'; import { arrayify } from './basic.js'; -/** - * Convert a generic Action to a AdvancedCameraCardCustomAction if it parses correctly. - * @param action The generic action configuration. - * @returns A AdvancedCameraCardCustomAction or null if it cannot be converted. - */ -export function convertActionToCardCustomAction( - action: unknown, -): InternalAdvancedCameraCardCustomAction | null { - if (!action) { - return null; - } - // Parse a custom event as other things could generate ll-custom events that - // are not related to Advanced Camera Card. - const parseResult = internalAdvancedCameraCardCustomActionSchema.safeParse(action); - return parseResult.success ? parseResult.data : null; -} - export function createGeneralAction( action: AdvancedCameraCardGeneralAction, options?: { @@ -221,7 +203,7 @@ export function createPerformAction( data?: ServiceCallRequest['serviceData']; target?: ServiceCallRequest['target']; }, -): ActionType { +): PerformActionActionConfig { return { action: 'perform-action' as const, perform_action: perform_action, @@ -240,7 +222,7 @@ export function createPerformAction( export function getActionConfigGivenAction( interaction?: string, config?: ActionsConfig | null, -): ActionType | ActionType[] | null { +): ActionConfig | ActionConfig[] | null { if (!interaction || !config) { return null; } @@ -270,11 +252,17 @@ export function getActionConfigGivenAction( * @param config The action config in question. * @returns `true` if there's a real action defined, `false` otherwise. */ -export const hasAction = (config?: ActionType | ActionType[]): boolean => { - // See note above on 'ActionConfig vs ActionType' for why this cast is - // necessary and harmless. - return arrayify(config).some((item) => - customCardHasAction(item as ActionConfig | undefined), +export const hasAction = (config?: ActionConfig | ActionConfig[]): boolean => { + return arrayify(config).some((item) => item.action !== 'none'); +}; + +export const isAdvancedCameraCardCustomAction = ( + action: ActionConfig, +): action is AdvancedCameraCardCustomActionConfig => { + return ( + action.action === 'fire-dom-event' && + 'advanced_camera_card_action' in action && + typeof action.advanced_camera_card_action === 'string' ); }; diff --git a/src/utils/basic.ts b/src/utils/basic.ts index 179cb4bc..484f0f23 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -50,8 +50,8 @@ export function arrayMove(target: unknown[], from: number, to: number): unknown[ * @param value: A value (which may be an array). * @returns An array. */ -export const arrayify = (value: T | T[]): T[] => { - return Array.isArray(value) ? value : [value]; +export const arrayify = (value?: T | T[]): T[] => { + return value ? (Array.isArray(value) ? value : [value]) : []; }; /** diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index 352febfa..e0688e44 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -1,4 +1,13 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { mock } from 'vitest-mock-extended'; import { ActionsManager, @@ -8,13 +17,7 @@ import { import { TemplateRenderer } from '../../../src/card-controller/templates'; import { AdvancedCameraCardView } from '../../../src/config/types'; import { createLogAction } from '../../../src/utils/action'; -import { - createAction, - createCardAPI, - createConfig, - createHASS, - createView, -} from '../../test-utils'; +import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils'; describe('ActionsManager', () => { describe('getMergedActions', () => { @@ -138,7 +141,7 @@ describe('ActionsManager', () => { vi.restoreAllMocks(); }); - it('should handle interaction', () => { + it('should handle interaction', async () => { const api = createCardAPI(); const element = document.createElement('div'); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); @@ -158,7 +161,7 @@ describe('ActionsManager', () => { vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - manager.handleInteractionEvent( + await manager.handleInteractionEvent( new CustomEvent('event', { detail: { action: 'tap' } }), ); expect(consoleSpy).toBeCalled(); @@ -203,7 +206,7 @@ describe('ActionsManager', () => { vi.restoreAllMocks(); }); - it('should handle event', () => { + it('should handle event', async () => { const action = createLogAction('Hello, world!'); const event = new CustomEvent('ll-custom', { detail: action, @@ -213,15 +216,15 @@ describe('ActionsManager', () => { const manager = new ActionsManager(api); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - manager.handleCustomActionEvent(event); + await manager.handleCustomActionEvent(event); expect(consoleSpy).toBeCalled(); }); - it('should not handle event without detail', () => { + it('should not handle event without detail', async () => { const manager = new ActionsManager(createCardAPI()); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); - manager.handleCustomActionEvent(new Event('ll-custom')); + await manager.handleCustomActionEvent(new Event('ll-custom')); expect(consoleSpy).not.toBeCalled(); }); }); @@ -250,6 +253,77 @@ describe('ActionsManager', () => { await manager.executeActions(createLogAction('Hello, world!')); expect(consoleSpy).toBeCalled(); }); + + it('should execute actions', async () => { + const api = createCardAPI(); + const manager = new ActionsManager(api); + + const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); + await manager.executeActions(createLogAction('Hello, world!')); + expect(consoleSpy).toBeCalled(); + }); + + it('should render templates', async () => { + const action = createLogAction('{{ acc.camera }}'); + + const templateRenderer = mock(); + templateRenderer.renderRecursively.mockReturnValue(action); + + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const conditionState = { + camera: 'camera', + }; + vi.mocked(api.getConditionStateManager().getState).mockReturnValue(conditionState); + + const manager = new ActionsManager(api, templateRenderer); + const config = { entity: 'light.office' }; + const triggerData = { view: { from: 'previous-view', to: 'view' } }; + + await manager.executeActions(action, { + config, + triggerData, + }); + + expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { + conditionState, + triggerData, + }); + }); + + describe('should forward haptics', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should forward success haptic', async () => { + const handler = vi.fn(); + window.addEventListener('haptic', handler); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + + await manager.executeActions({ action: 'none' }); + + expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'success' })); + }); + + it('should forward warning haptic', async () => { + const handler = vi.fn(); + window.addEventListener('haptic', handler); + + const api = createCardAPI(); + const manager = new ActionsManager(api); + + vi.stubGlobal('confirm', vi.fn().mockReturnValue(false)); + + await manager.executeActions({ action: 'none', confirmation: true }); + + expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'warning' })); + }); + }); }); describe('uninitialize', () => { @@ -266,18 +340,18 @@ describe('ActionsManager', () => { const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const promise = manager.executeActions([ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - createAction({ + { + action: 'fire-dom-event', advanced_camera_card_action: 'sleep', duration: { m: 1, }, - })!, + }, createLogAction('Hello, world!'), ]); // Stop inflight actions. - manager.uninitialize(); + await manager.uninitialize(); // Advance timers (causes the sleep to end). vi.runOnlyPendingTimers(); @@ -288,34 +362,4 @@ describe('ActionsManager', () => { expect(consoleSpy).not.toBeCalled(); }); }); - - it('should render templates', async () => { - const action = createLogAction('{{ acc.camera }}'); - - const templateRenderer = mock(); - templateRenderer.renderRecursively.mockReturnValue(action); - - const api = createCardAPI(); - const hass = createHASS(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); - - const conditionState = { - camera: 'camera', - }; - vi.mocked(api.getConditionStateManager().getState).mockReturnValue(conditionState); - - const manager = new ActionsManager(api, templateRenderer); - const config = { camera_image: 'camera-image' }; - const triggerData = { view: { from: 'previous-view', to: 'view' } }; - - await manager.executeActions(action, { - config, - triggerData, - }); - - expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { - conditionState, - triggerData, - }); - }); }); diff --git a/tests/card-controller/actions/actions/base.test.ts b/tests/card-controller/actions/actions/base.test.ts index 72ec9e72..9914af97 100644 --- a/tests/card-controller/actions/actions/base.test.ts +++ b/tests/card-controller/actions/actions/base.test.ts @@ -1,19 +1,172 @@ -import { it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseAction } from '../../../../src/card-controller/actions/actions/base'; -import { createCardAPI } from '../../../test-utils'; +import { createViewAction } from '../../../../src/utils/action'; +import { createCardAPI, createHASS, createUser } from '../../../test-utils'; -it('should construct', async () => { - const api = createCardAPI(); - const action = new BaseAction( - {}, - { - action: 'fire-dom-event', - }, - ); +describe('should handle base action', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await action.execute(api); - await action.stop(); + beforeAll(() => { + vi.stubGlobal('confirm', vi.fn()); + }); - // These methods have no observable effect on the base class, so this test is - // currently only providing coverage and proof of no exceptions! + afterAll(() => { + vi.unstubAllGlobals(); + }); + + it('should construct', async () => { + const api = createCardAPI(); + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + }, + ); + + await action.execute(api); + await action.stop(); + + // These methods have no observable effect on the base class, so this test is + // currently only providing coverage and proof of no exceptions! + }); + + it('should not confirm when not necessary', async () => { + const api = createCardAPI(); + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + }, + ); + + await action.execute(api); + + expect(confirm).not.toBeCalled(); + }); + + it('should continue execution when confirmed', async () => { + const api = createCardAPI(); + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + confirmation: true, + }, + ); + + vi.mocked(confirm).mockReturnValue(true); + + await action.execute(api); + + expect(confirm).toBeCalled(); + }); + + it('should abort execution when not confirmed', async () => { + const api = createCardAPI(); + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + confirmation: true, + }, + ); + + vi.mocked(confirm).mockReturnValue(false); + + expect(async () => await action.execute(api)).rejects.toThrowError(/Aborted action/); + }); + + it('should not confirm when exempted', async () => { + const api = createCardAPI(); + const hass = createHASS({}, createUser({ id: 'user-id' })); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new BaseAction( + {}, + { + action: 'fire-dom-event', + confirmation: { + exemptions: [ + { + user: 'user-id', + }, + ], + }, + }, + ); + + await action.execute(api); + + expect(confirm).not.toBeCalled(); + }); + + describe('should show correct confirmation text', () => { + it('should show action name in confirmation text', async () => { + const api = createCardAPI(); + const hass = createHASS({}, createUser({ id: 'user-id' })); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new BaseAction( + {}, + { + action: 'more-info', + confirmation: true, + }, + ); + + vi.mocked(confirm).mockReturnValue(true); + + await action.execute(api); + + expect(confirm).toBeCalledWith( + 'Are you sure you want to perform this action: more-info', + ); + }); + + it('should show advanced camera card action name in confirmation text', async () => { + const api = createCardAPI(); + const hass = createHASS({}, createUser({ id: 'user-id' })); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new BaseAction( + {}, + { + ...createViewAction('clips'), + confirmation: true, + }, + ); + + vi.mocked(confirm).mockReturnValue(true); + + await action.execute(api); + + expect(confirm).toBeCalledWith( + 'Are you sure you want to perform this action: clips', + ); + }); + + it('should show configured confirmation text', async () => { + const api = createCardAPI(); + const hass = createHASS({}, createUser({ id: 'user-id' })); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new BaseAction( + {}, + { + action: 'more-info', + confirmation: { + text: 'Test text', + }, + }, + ); + + vi.mocked(confirm).mockReturnValue(true); + + await action.execute(api); + + expect(confirm).toBeCalledWith('Test text'); + }); + }); }); diff --git a/tests/card-controller/actions/actions/call-service.test.ts b/tests/card-controller/actions/actions/call-service.test.ts new file mode 100644 index 00000000..fda25aa7 --- /dev/null +++ b/tests/card-controller/actions/actions/call-service.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CallServiceAction } from '../../../../src/card-controller/actions/actions/call-service'; +import { createCardAPI, createHASS } from '../../../test-utils'; + +describe('CallServiceAction', () => { + it('should call service', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new CallServiceAction( + {}, + { + action: 'call-service', + service: 'light.turn_on', + data: { brightness_pct: 80 }, + target: { entity_id: 'light.office' }, + }, + ); + await action.execute(api); + + expect(hass.callService).toBeCalledWith( + 'light', + 'turn_on', + { + brightness_pct: 80, + }, + { entity_id: 'light.office' }, + ); + }); + + it('should not call service without hass', async () => { + const api = createCardAPI(); + + const action = new CallServiceAction( + {}, + { + action: 'call-service', + service: 'light.turn_on', + data: { brightness_pct: 80 }, + target: { entity_id: 'light.office' }, + }, + ); + await action.execute(api); + + // No observable effect. + }); +}); diff --git a/tests/card-controller/actions/actions/custom.test.ts b/tests/card-controller/actions/actions/custom.test.ts new file mode 100644 index 00000000..09d84aea --- /dev/null +++ b/tests/card-controller/actions/actions/custom.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CustomAction } from '../../../../src/card-controller/actions/actions/custom'; +import { createCardAPI } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('CustomAction', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should open the URL in a new window', async () => { + const handler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('ll-custom', handler); + + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + + const action = new CustomAction( + {}, + { + action: 'fire-dom-event' as const, + foo: 'bar', + 1: 2, + }, + {}, + ); + + await action.execute(api); + + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { action: 'fire-dom-event', foo: 'bar', 1: 2 }, + }), + ); + }); +}); diff --git a/tests/card-controller/actions/actions/generic.test.ts b/tests/card-controller/actions/actions/generic.test.ts deleted file mode 100644 index af50b09c..00000000 --- a/tests/card-controller/actions/actions/generic.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createCardAPI, createHASS, createLitElement } from '../../../test-utils.js'; -import { GenericAction } from '../../../../src/card-controller/actions/actions/generic.js'; -import { handleActionConfig } from '../../../../src/ha/handle-action.js'; - -vi.mock('../../../../src/ha/handle-action.js'); - -describe('should handle generic action', () => { - it('without hass', async () => { - const api = createCardAPI(); - const action = new GenericAction( - {}, - { - action: 'fire-dom-event', - }, - ); - - await action.execute(api); - - expect(handleActionConfig).not.toBeCalled(); - }); - - // @vitest-environment jsdom - it('with hass', async () => { - const api = createCardAPI(); - const hass = createHASS(); - const element = createLitElement(); - vi.mocked(api.getCardElementManager()).getElement.mockReturnValue(element); - vi.mocked(api.getHASSManager()).getHASS.mockReturnValue(hass); - const action = new GenericAction( - {}, - { - action: 'fire-dom-event', - }, - ); - - await action.execute(api); - - expect(handleActionConfig).toBeCalledWith( - element, - hass, - {}, - { action: 'fire-dom-event' }, - ); - }); -}); diff --git a/tests/card-controller/actions/actions/more-info.test.ts b/tests/card-controller/actions/actions/more-info.test.ts new file mode 100644 index 00000000..5e38cc58 --- /dev/null +++ b/tests/card-controller/actions/actions/more-info.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MoreInfoAction } from '../../../../src/card-controller/actions/actions/more-info'; +import { createCardAPI } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('should handle more-info action', () => { + it('should handle more-info with entity in action', async () => { + const handler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('hass-more-info', handler); + + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + + const action = new MoreInfoAction( + {}, + { + action: 'more-info', + entity: 'light.office', + }, + {}, + ); + + await action.execute(api); + + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { entityId: 'light.office' }, + }), + ); + }); + + it('should handle more-info with entity in auxilliary config', async () => { + const handler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('hass-more-info', handler); + + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + + const action = new MoreInfoAction( + {}, + { + action: 'more-info', + }, + { + entity: 'light.office', + }, + ); + + await action.execute(api); + + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { entityId: 'light.office' }, + }), + ); + }); + + it('should take no action with any entity', async () => { + const handler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('hass-more-info', handler); + + const api = createCardAPI(); + vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); + + const action = new MoreInfoAction( + {}, + { + action: 'more-info', + }, + {}, + ); + + await action.execute(api); + + expect(handler).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/navigate.test.ts b/tests/card-controller/actions/actions/navigate.test.ts new file mode 100644 index 00000000..54633893 --- /dev/null +++ b/tests/card-controller/actions/actions/navigate.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NavigateAction } from '../../../../src/card-controller/actions/actions/navigate'; +import { createCardAPI } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('should handle navigate action', () => { + it('should handle navigate action', async () => { + const handler = vi.fn(); + window.addEventListener('location-changed', handler); + + const action = new NavigateAction( + {}, + { + action: 'navigate', + navigation_path: '/path', + }, + {}, + ); + + const historyLength = history.length; + + await action.execute(createCardAPI()); + + expect(history.length).toBe(historyLength + 1); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { replace: false }, + }), + ); + }); + + it('should handle navigate action that replaces', async () => { + const handler = vi.fn(); + window.addEventListener('location-changed', handler); + + const action = new NavigateAction( + {}, + { + action: 'navigate', + navigation_path: '/path', + navigation_replace: true, + }, + {}, + ); + + const historyLength = history.length; + + await action.execute(createCardAPI()); + + expect(history.length).toBe(historyLength); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { replace: true }, + }), + ); + }); +}); diff --git a/tests/card-controller/actions/actions/none.test.ts b/tests/card-controller/actions/actions/none.test.ts new file mode 100644 index 00000000..7e866ea6 --- /dev/null +++ b/tests/card-controller/actions/actions/none.test.ts @@ -0,0 +1,17 @@ +import { it } from 'vitest'; +import { NoneAction } from '../../../../src/card-controller/actions/actions/none'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle none action', async () => { + const api = createCardAPI(); + const action = new NoneAction( + {}, + { + action: 'none' as const, + }, + ); + + await action.execute(api); + + // No observable side effects. +}); diff --git a/tests/card-controller/actions/actions/perform-action.test.ts b/tests/card-controller/actions/actions/perform-action.test.ts new file mode 100644 index 00000000..ae5e9f99 --- /dev/null +++ b/tests/card-controller/actions/actions/perform-action.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCardAPI, createHASS } from '../../../test-utils'; +import { PerformActionAction } from '../../../../src/card-controller/actions/actions/perform-action'; + +describe('PerformActionAction', () => { + it('should perform action', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new PerformActionAction( + {}, + { + action: 'perform-action', + perform_action: 'light.turn_on', + data: { brightness_pct: 80 }, + target: { entity_id: 'light.office' }, + }, + ); + await action.execute(api); + + expect(hass.callService).toBeCalledWith( + 'light', + 'turn_on', + { + brightness_pct: 80, + }, + { entity_id: 'light.office' }, + ); + }); + + it('should not perform action without hass', async () => { + const api = createCardAPI(); + + const action = new PerformActionAction( + {}, + { + action: 'perform-action', + perform_action: 'light.turn_on', + data: { brightness_pct: 80 }, + target: { entity_id: 'light.office' }, + }, + ); + await action.execute(api); + + // No observable effect. + }); +}); diff --git a/tests/card-controller/actions/actions/toggle.test.ts b/tests/card-controller/actions/actions/toggle.test.ts new file mode 100644 index 00000000..1f2c6413 --- /dev/null +++ b/tests/card-controller/actions/actions/toggle.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ToggleAction } from '../../../../src/card-controller/actions/actions/toggle'; +import { createCardAPI, createHASS, createStateEntity } from '../../../test-utils'; + +describe('ToggleAction', () => { + describe('should toggle entities', () => { + it.each([ + ['light.office' as const, 'off' as const, 'light' as const, 'turn_on' as const], + ['light.office' as const, 'on' as const, 'light' as const, 'turn_off' as const], + [ + 'cover.door' as const, + 'closed' as const, + 'cover' as const, + 'open_cover' as const, + ], + ['cover.door' as const, 'open' as const, 'cover' as const, 'close_cover' as const], + ['lock.door' as const, 'locked' as const, 'lock' as const, 'unlock' as const], + ['lock.door' as const, 'unlocked' as const, 'lock' as const, 'lock' as const], + [ + 'group.foo' as const, + 'off' as const, + 'homeassistant' as const, + 'turn_on' as const, + ], + [ + 'group.foo' as const, + 'on' as const, + 'homeassistant' as const, + 'turn_off' as const, + ], + ])( + '%s %s', + async ( + entityID: string, + state: string, + expectedServiceDomain: string, + expectedService: string, + ) => { + const api = createCardAPI(); + const hass = createHASS({ + [entityID]: createStateEntity({ entity_id: entityID, state: state }), + }); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new ToggleAction({}, { action: 'toggle' }, { entity: entityID }); + await action.execute(api); + + expect(hass.callService).toBeCalledWith(expectedServiceDomain, expectedService, { + entity_id: entityID, + }); + }, + ); + }); + + it('should do nothing without an entity ID', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new ToggleAction({}, { action: 'toggle' }, {}); + + await action.execute(api); + + expect(hass.callService).not.toBeCalled(); + }); + + it('should do nothing without an entity state', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const action = new ToggleAction( + {}, + { action: 'toggle' }, + { entity: 'light.NOT_FOUND' }, + ); + await action.execute(api); + + expect(hass.callService).not.toBeCalled(); + }); +}); diff --git a/tests/card-controller/actions/actions/url.test.ts b/tests/card-controller/actions/actions/url.test.ts new file mode 100644 index 00000000..4fd8ebf6 --- /dev/null +++ b/tests/card-controller/actions/actions/url.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { URLAction } from '../../../../src/card-controller/actions/actions/url'; +import { createCardAPI } from '../../../test-utils'; + +// @vitest-environment jsdom +describe('URLAction', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should open the URL in a new window', async () => { + const urlAction = new URLAction( + {}, + { + action: 'url', + url_path: 'https://example.com', + }, + ); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + await urlAction.execute(createCardAPI()); + + expect(windowOpenSpy).toHaveBeenCalledWith('https://example.com'); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index e2b733e3..098a6cc3 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; +import { CallServiceAction } from '../../../src/card-controller/actions/actions/call-service'; import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select'; import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui'; +import { CustomAction } from '../../../src/card-controller/actions/actions/custom'; import { DefaultAction } from '../../../src/card-controller/actions/actions/default'; import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select'; import { DownloadAction } from '../../../src/card-controller/actions/actions/download'; import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; -import { GenericAction } from '../../../src/card-controller/actions/actions/generic'; import { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback'; import { LogAction } from '../../../src/card-controller/actions/actions/log'; import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player'; @@ -15,8 +16,12 @@ import { MicrophoneConnectAction } from '../../../src/card-controller/actions/ac import { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect'; import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute'; import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute'; +import { MoreInfoAction } from '../../../src/card-controller/actions/actions/more-info'; import { MuteAction } from '../../../src/card-controller/actions/actions/mute'; +import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate'; +import { NoneAction } from '../../../src/card-controller/actions/actions/none'; import { PauseAction } from '../../../src/card-controller/actions/actions/pause'; +import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action'; import { PlayAction } from '../../../src/card-controller/actions/actions/play'; import { PTZAction } from '../../../src/card-controller/actions/actions/ptz'; import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls'; @@ -28,13 +33,12 @@ import { StatusBarAction } from '../../../src/card-controller/actions/actions/st import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off'; import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on'; import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select'; +import { ToggleAction } from '../../../src/card-controller/actions/actions/toggle'; import { UnmuteAction } from '../../../src/card-controller/actions/actions/unmute'; +import { URLAction } from '../../../src/card-controller/actions/actions/url'; import { ViewAction } from '../../../src/card-controller/actions/actions/view'; import { ActionFactory } from '../../../src/card-controller/actions/factory'; -import { - AdvancedCameraCardCustomAction, - INTERNAL_CALLBACK_ACTION, -} from '../../../src/config/types'; +import { ActionConfig, INTERNAL_CALLBACK_ACTION } from '../../../src/config/types'; // @vitest-environment jsdom describe('ActionFactory', () => { @@ -55,23 +59,26 @@ describe('ActionFactory', () => { ).toBeNull(); }); - describe('generic', () => { - it('non advanced camera card action', () => { + describe('stock actions', () => { + it.each([ + [{ action: 'more-info' as const }, MoreInfoAction], + [{ action: 'toggle' as const }, ToggleAction], + [{ action: 'navigate' as const, navigation_path: '/foo' }, NavigateAction], + [{ action: 'url' as const, url_path: 'https://card.camera' }, URLAction], + [ + { action: 'perform-action' as const, perform_action: 'action' }, + PerformActionAction, + ], + [{ action: 'call-service' as const, service: 'service' }, CallServiceAction], + [{ action: 'none' as const }, NoneAction], + [{ action: 'fire-dom-event' as const }, CustomAction], + ])('action: $action', (action: ActionConfig, classObject: object) => { const factory = new ActionFactory(); - expect(factory.createAction({}, { action: 'fire-dom-event' })).toBeInstanceOf( - GenericAction, - ); - }); - - it('non fire-dom-event', () => { - const factory = new ActionFactory(); - expect(factory.createAction({}, { action: 'more-info' })).toBeInstanceOf( - GenericAction, - ); + expect(factory.createAction({}, action)).toBeInstanceOf(classObject); }); }); - describe('actions', () => { + describe('custom actions', () => { it.each([ [{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction], [{ advanced_camera_card_action: 'camera_ui' as const }, CameraUIAction], @@ -178,10 +185,10 @@ describe('ActionFactory', () => { ], ])( 'advanced_camera_card_action: $advanced_camera_card_action', - (action: Partial, classObject: object) => { + (action: Partial, classObject: object) => { const factory = new ActionFactory(); expect( - factory.createAction({}, { action: 'fire-dom-event', ...action }), + factory.createAction({}, { ...action, action: 'fire-dom-event' }), ).toBeInstanceOf(classObject); }, ); diff --git a/tests/card-controller/automations-manager.test.ts b/tests/card-controller/automations-manager.test.ts index 1c69b91d..36f043de 100644 --- a/tests/card-controller/automations-manager.test.ts +++ b/tests/card-controller/automations-manager.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AuxillaryActionConfig } from '../../src/card-controller/actions/types.js'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; import { ConditionStateManager } from '../../src/conditions/state-manager.js'; -import { ActionType } from '../../src/config/types.js'; +import { ActionConfig } from '../../src/config/types.js'; import { createCardAPI } from '../test-utils.js'; describe('AutomationsManager', () => { @@ -151,9 +150,9 @@ describe('AutomationsManager', () => { vi.mocked(api.getActionsManager().executeActions).mockImplementation( async ( // eslint-disable-next-line @typescript-eslint/no-unused-vars - _action: ActionType | ActionType[], + _action: ActionConfig | ActionConfig[], // eslint-disable-next-line @typescript-eslint/no-unused-vars - _config?: AuxillaryActionConfig, + _options?: unknown, ): Promise => { fullscreen = !fullscreen; stateManager.setState({ fullscreen: fullscreen }); diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 008b1eed..6910f6cc 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -14,7 +14,6 @@ import { describe('CardElementManager', () => { afterEach(() => { vi.unstubAllGlobals(); - global.window.location = mock(); }); it('should get element', () => { diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index e2bd9a30..a035d648 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select'; @@ -7,13 +7,14 @@ import { createCardAPI } from '../test-utils'; const setQueryString = (qs: string): void => { const location: Location = mock(); location.search = qs; - global.window.location = location; + + vi.spyOn(window, 'location', 'get').mockReturnValue(location); }; // @vitest-environment jsdom describe('QueryStringManager', () => { - beforeEach(() => { - global.window.location = mock(); + afterEach(() => { + vi.restoreAllMocks(); }); it('should reject malformed query string', async () => { diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 0c94b6c3..d1349c4e 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -2,16 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MenuController } from '../../src/components-lib/menu-controller.js'; import { SubmenuItem } from '../../src/components/submenu/types.js'; import { MenuConfig, menuConfigSchema } from '../../src/config/types.js'; -import { handleActionConfig } from '../../src/ha/handle-action.js'; import { createInteractionActionEvent, createLitElement, createSubmenuInteractionActionEvent, } from '../test-utils'; -vi.mock('../../src/ha/handle-action.js'); -vi.mock('../../src/utils/ha'); - const createMenuConfig = (config: unknown): MenuConfig => { return menuConfigSchema.parse(config); }; @@ -369,9 +365,13 @@ describe('MenuController', () => { describe('should handle actions', () => { it('should bail without config', () => { - const controller = new MenuController(createLitElement()); + const host = createLitElement(); + const handler = vi.fn(); + host.addEventListener('advanced-camera-card:action:execution-request', handler); + + const controller = new MenuController(host); controller.handleAction(createInteractionActionEvent('tap')); - expect(vi.mocked(handleActionConfig)).not.toBeCalled(); + expect(handler).not.toBeCalled(); }); it('should execute simple action in non-hidden menu', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 2d6e2206..9b6b94b1 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + advancedCameraCardConditionSchema, + advancedCameraCardCustomActionsBaseSchema, cameraConfigSchema, conditionalSchema, customSchema, dimensionsConfigSchema, - advancedCameraCardConditionSchema, - advancedCameraCardCustomActionsBaseSchema, - internalAdvancedCameraCardCustomActionSchema, + statusBarActionConfigSchema, } from '../../src/config/types'; import { createConfig } from '../test-utils'; @@ -641,7 +641,7 @@ describe('should lazy evaluate schemas', () => { }, ], }; - expect(internalAdvancedCameraCardCustomActionSchema.parse(input)).toEqual(input); + expect(statusBarActionConfigSchema.parse(input)).toEqual(input); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 67c5c32c..86e7d67e 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -43,12 +43,10 @@ import { ConditionStateManager } from '../src/conditions/state-manager'; import { AdvancedCameraCardConfig, CameraConfig, - InternalAdvancedCameraCardCustomAction, PerformanceConfig, RawAdvancedCameraCardConfig, advancedCameraCardConfigSchema, cameraConfigSchema, - internalAdvancedCameraCardCustomActionSchema, performanceConfigSchema, } from '../src/config/types'; import { CurrentUser, HomeAssistant } from '../src/ha/types'; @@ -61,16 +59,6 @@ import { ViewMedia, ViewMediaType } from '../src/view/media'; import { MediaQueriesResults } from '../src/view/media-queries-results'; import { View, ViewParameters } from '../src/view/view'; -export const createAction = ( - action: Record, -): InternalAdvancedCameraCardCustomAction | null => { - const result = internalAdvancedCameraCardCustomActionSchema.safeParse({ - action: 'custom:advanced-camera-card-action', - ...action, - }); - return result.success ? result.data : null; -}; - export const createCameraConfig = (config?: unknown): CameraConfig => { return cameraConfigSchema.parse(config ?? {}); }; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index badfba53..fcfb4c54 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,9 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { actionSchema, INTERNAL_CALLBACK_ACTION } from '../../src/config/types.js'; -import { hasAction as customCardHasAction } from '../../src/ha/has-action.js'; +import { ActionConfig, INTERNAL_CALLBACK_ACTION } from '../../src/config/types.js'; import { - convertActionToCardCustomAction, createCameraAction, createDisplayModeAction, createGeneralAction, @@ -21,30 +19,6 @@ import { stopEventFromActivatingCardWideActions, } from '../../src/utils/action.js'; -vi.mock('../../src/ha/has-action.js'); - -describe('convertActionToAdvancedCameraCardCustomAction', () => { - it('should skip null action', () => { - expect(convertActionToCardCustomAction(null)).toBeFalsy(); - }); - - it('should parse valid', () => { - expect( - convertActionToCardCustomAction({ - action: 'custom:advanced-camera-card-action', - advanced_camera_card_action: 'download', - }), - ).toEqual({ - action: 'fire-dom-event', - advanced_camera_card_action: 'download', - }); - }); - - it('should not parse invalid', () => { - expect(convertActionToCardCustomAction('this is garbage')).toBeNull(); - }); -}); - describe('createGeneralAction', () => { it('should create general action', () => { expect( @@ -293,10 +267,7 @@ describe('createPerformAction', () => { }); describe('getActionConfigGivenAction', () => { - const action = actionSchema.parse({ - action: 'fire-dom-event', - advanced_camera_card_action: 'clips', - }); + const action = createViewAction('clips'); it('should not handle undefined arguments', () => { expect(getActionConfigGivenAction()).toBeNull(); @@ -348,21 +319,29 @@ describe('getActionConfigGivenAction', () => { }); describe('hasAction', () => { - const action = actionSchema.parse({ - action: 'toggle', - }); + const realAction = createViewAction('clips'); + const noneAction: ActionConfig = { + action: 'none', + }; afterEach(() => { vi.clearAllMocks(); }); - it('should handle non-array case', () => { - expect(hasAction(action)).toBeFalsy(); - expect(customCardHasAction).toBeCalledTimes(1); + it('should return true for real action', () => { + expect(hasAction(realAction)).toBeTruthy(); }); - it('should handle array case', () => { - expect(hasAction([action, action, action])).toBeFalsy(); - expect(customCardHasAction).toBeCalledTimes(3); + + it('should return false for none action', () => { + expect(hasAction(noneAction)).toBeFalsy(); + }); + + it('should return true with an array of actions some real', () => { + expect(hasAction([noneAction, noneAction, realAction])).toBeTruthy(); + }); + + it('should return false with an array of actions none real', () => { + expect(hasAction([noneAction, noneAction, noneAction])).toBeFalsy(); }); }); diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts index 6365e33e..8f1d8e2a 100644 --- a/tests/utils/basic.test.ts +++ b/tests/utils/basic.test.ts @@ -54,6 +54,9 @@ describe('arrayify', () => { const data = [1, 2, 3]; expect(arrayify(data)).toBe(data); }); + it('should handle undefined', () => { + expect(arrayify()).toEqual([]); + }); }); describe('setify', () => { diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts index 7ff58b69..c16ad1c2 100644 --- a/tests/utils/download.test.ts +++ b/tests/utils/download.test.ts @@ -18,13 +18,13 @@ const media = new ViewMedia('clip', 'camera.office'); describe('downloadURL', () => { afterEach(() => { vi.restoreAllMocks(); - global.window.location = mock(); }); it('should download same origin via link', () => { const location: Location & { origin: string } = mock(); location.origin = 'http://foo'; - global.window.location = location; + + vi.spyOn(window, 'location', 'get').mockReturnValue(location); const link = document.createElement('a'); link.click = vi.fn(); @@ -55,7 +55,8 @@ describe('downloadURL', () => { // Set the origin to the same. const location: Location & { origin: string } = mock(); location.origin = 'http://foo'; - global.window.location = location; + + vi.spyOn(window, 'location', 'get').mockReturnValue(location); const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); @@ -66,8 +67,13 @@ describe('downloadURL', () => { describe('downloadMedia', () => { beforeEach(() => { + vi.spyOn(window, 'location', 'get').mockReturnValue( + mock({ origin: 'https://foo' }), + ); + }); + + afterEach(() => { vi.restoreAllMocks(); - global.window.location = mock({ origin: 'https://foo' }); }); it('should throw error when no media', () => { diff --git a/tsconfig.json b/tsconfig.json index 46ea7f03..aa9e5977 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "node", "lib": ["es2021", "dom", "dom.iterable"], "noEmit": true, - "noErrorTruncation": true, + "noErrorTruncation": false, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, diff --git a/vite.config.ts b/vite.config.ts index 66930b91..cd45a0bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'conditions/**/*.ts', 'config/**/*.ts', 'const.ts', + 'ha/**/*.ts', 'types.ts', 'utils/action.ts', 'utils/audio.ts',