fix: Confirmations should apply to all actions (#1959)

This also refactors how generic HA actions are handled, and improves
typing of actions.


[skip ci]
This commit is contained in:
Dermot Duffy
2025-03-15 14:32:48 -07:00
committed by GitHub
parent a79ffa6edc
commit 75ae7720d3
90 changed files with 1332 additions and 854 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { PTZAction, PTZBaseAction } from '../../config/ptz'; 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'; import { PTZCapabilities, PTZMovementType } from '../../types';
export const getConfiguredPTZAction = ( export const getConfiguredPTZAction = (
@@ -9,7 +9,7 @@ export const getConfiguredPTZAction = (
phase?: ActionPhase; phase?: ActionPhase;
preset?: string; preset?: string;
}, },
): ActionType | ActionType[] | null => { ): ActionConfig | ActionConfig[] | null => {
if (action === 'preset') { if (action === 'preset') {
return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null; return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null;
} }
+27 -12
View File
@@ -1,12 +1,19 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { z } from 'zod'; import { z } from 'zod';
import { ConditionsTriggerData } from '../../conditions/types.js'; 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 { getActionConfigGivenAction } from '../../utils/action.js';
import { allPromises } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js'; import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js'; import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.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; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number]; export type InteractionName = (typeof INTERACTIONS)[number];
@@ -60,7 +67,7 @@ export class ActionsManager {
/** /**
* Handle an human interaction called on an element (e.g. 'tap'). * Handle an human interaction called on an element (e.g. 'tap').
*/ */
public handleInteractionEvent = (ev: Event): void => { public handleInteractionEvent = async (ev: Event): Promise<void> => {
const result = interactionEventSchema.safeParse(ev); const result = interactionEventSchema.safeParse(ev);
if (!result.success) { if (!result.success) {
return; return;
@@ -76,7 +83,7 @@ export class ActionsManager {
// actions). // actions).
actionConfig 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 * cards to fire custom actions. This card itself should not call this, but
* embedded elements may. * embedded elements may.
*/ */
public handleCustomActionEvent = (ev: Event): void => { public handleCustomActionEvent = async (
ev: Event | CustomEvent<ActionConfig>,
): Promise<void> => {
if (!('detail' in ev)) { if (!('detail' in ev)) {
// The event may or may not be a CustomEvent object. For example, whilst // The event may or may not be a CustomEvent object. For example, whilst
// this card doesn't use custom-card-helpers, embedded elements may: // 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 // https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
return; 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<void> {
// If there are any long-running actions, ensure they are stopped. // 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( public async executeActions(
action: ActionType | ActionType[], action: ActionConfig | ActionConfig[],
options?: { options?: {
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
triggerData?: ConditionsTriggerData; triggerData?: ConditionsTriggerData;
}, },
): Promise<void> { ): Promise<void> {
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
const renderedAction = const renderedAction: ActionConfig | ActionConfig[] =
hass && this._templateRenderer hass && this._templateRenderer
? this._templateRenderer.renderRecursively(hass, action, { ? (this._templateRenderer.renderRecursively(hass, action, {
conditionState: this._api.getConditionStateManager().getState(), conditionState: this._api.getConditionStateManager().getState(),
triggerData: options?.triggerData, triggerData: options?.triggerData,
}) }) as ActionConfig | ActionConfig[])
: action; : action;
const actionSet = new ActionSet(this._actionContext, renderedAction, { const actionSet = new ActionSet(this._actionContext, renderedAction, {
@@ -134,7 +143,13 @@ export class ActionsManager {
}); });
this._actionsInFlight.push(actionSet); this._actionsInFlight.push(actionSet);
try {
await actionSet.execute(this._api); await actionSet.execute(this._api);
forwardHaptic('success');
} catch (e) {
forwardHaptic('warning');
}
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet); this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
} }
} }
+32 -9
View File
@@ -1,9 +1,11 @@
import { ActionContext } from 'action'; 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 { CardActionsAPI } from '../../types';
import { Action, AuxillaryActionConfig } from '../types'; import { Action, ActionAbortError } from '../types';
export class BaseAction<T> implements Action { export class BaseAction<T extends ActionConfig> implements Action {
protected _context: ActionContext; protected _context: ActionContext;
protected _action: T; protected _action: T;
protected _config?: AuxillaryActionConfig; protected _config?: AuxillaryActionConfig;
@@ -14,9 +16,32 @@ export class BaseAction<T> implements Action {
this._config = config; this._config = config;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars protected _shouldSeekConfirmation(api: CardActionsAPI): boolean {
public async execute(_api: CardActionsAPI): Promise<void> { const hass = api.getHASSManager().getHASS();
// Pass.
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<void> {
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<void> { public async stop(): Promise<void> {
@@ -24,6 +49,4 @@ export class BaseAction<T> implements Action {
} }
} }
export class AdvancedCameraCardAction< export class AdvancedCameraCardAction<T extends ActionConfig> extends BaseAction<T> {}
T extends AdvancedCameraCardCustomAction,
> extends BaseAction<T> {}
@@ -0,0 +1,17 @@
import { CallServiceActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class CallServiceAction extends AdvancedCameraCardAction<CallServiceActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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);
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class CameraSelectAction extends AdvancedCameraCardAction<CameraSelectActionConfig> { export class CameraSelectAction extends AdvancedCameraCardAction<CameraSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const selectCameraID = const selectCameraID =
this._action.camera ?? this._action.camera ??
(this._action.triggered (this._action.triggered
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class CameraUIAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class CameraUIAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getCameraURLManager().openURL(); api.getCameraURLManager().openURL();
} }
} }
@@ -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<CustomActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
fireHASSEvent(api.getCardElementManager().getElement(), 'll-custom', this._action);
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class DefaultAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class DefaultAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getViewManager().setViewDefaultWithNewQuery(); await api.getViewManager().setViewDefaultWithNewQuery();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class DisplayModeSelectAction extends AdvancedCameraCardAction<DisplayModeActionConfig> { export class DisplayModeSelectAction extends AdvancedCameraCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getViewManager().setViewByParametersWithNewQuery({ await api.getViewManager().setViewByParametersWithNewQuery({
params: { params: {
displayMode: this._action.display_mode, displayMode: this._action.display_mode,
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class DownloadAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class DownloadAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadViewerMedia(); await api.getDownloadManager().downloadViewerMedia();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class ExpandAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class ExpandAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getExpandManager().toggleExpanded(); api.getExpandManager().toggleExpanded();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class FullscreenAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class FullscreenAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getFullscreenManager().toggleFullscreen(); api.getFullscreenManager().toggleFullscreen();
} }
} }
@@ -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<ActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
const hass = api.getHASSManager().getHASS();
if (hass) {
handleActionConfig(
api.getCardElementManager().getElement(),
hass,
this._config ?? {},
this._action,
);
}
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class InternalCallbackAction extends AdvancedCameraCardAction<InternalCallbackActionConfig> { export class InternalCallbackAction extends AdvancedCameraCardAction<InternalCallbackActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await this._action.callback(api); await this._action.callback(api);
} }
} }
+3 -2
View File
@@ -3,8 +3,9 @@ import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base'; import { AdvancedCameraCardAction } from './base';
export class LogAction extends AdvancedCameraCardAction<LogActionConfig> { export class LogAction extends AdvancedCameraCardAction<LogActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars public async execute(api: CardActionsAPI): Promise<void> {
public async execute(_api: CardActionsAPI): Promise<void> { await super.execute(api);
console[this._action.level](this._action.message); console[this._action.level](this._action.message);
} }
} }
@@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base';
export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActionConfig> { export class MediaPlayerAction extends AdvancedCameraCardAction<MediaPlayerActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const mediaPlayer = this._action.media_player; const mediaPlayer = this._action.media_player;
const mediaPlayerController = api.getMediaPlayerManager(); const mediaPlayerController = api.getMediaPlayerManager();
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MenuToggleAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MenuToggleAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getCardElementManager().toggleMenu(); api.getCardElementManager().toggleMenu();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MicrophoneConnectAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MicrophoneConnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMicrophoneManager().connect(); await api.getMicrophoneManager().connect();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MicrophoneDisconnectAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MicrophoneDisconnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getMicrophoneManager().disconnect(); api.getMicrophoneManager().disconnect();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MicrophoneMuteAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MicrophoneMuteAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getMicrophoneManager().mute(); api.getMicrophoneManager().mute();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MicrophoneUnmuteAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MicrophoneUnmuteAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMicrophoneManager().unmute(); await api.getMicrophoneManager().unmute();
} }
} }
@@ -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<MoreInfoActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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,
});
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class MuteAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class MuteAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.mute(); await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.mute();
} }
} }
@@ -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<NavigateActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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,
});
}
}
@@ -0,0 +1,9 @@
import { NoneActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class NoneAction extends AdvancedCameraCardAction<NoneActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class PauseAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class PauseAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.pause(); await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.pause();
} }
} }
@@ -0,0 +1,17 @@
import { PerformActionActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class PerformActionAction extends AdvancedCameraCardAction<PerformActionActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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);
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class PlayAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class PlayAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.play(); await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.play();
} }
} }
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActionConfig> { export class PTZControlsAction extends AdvancedCameraCardAction<PTZControlsActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewWithMergedContext({ api.getViewManager().setViewWithMergedContext({
ptzControls: { enabled: this._action.enabled }, ptzControls: { enabled: this._action.enabled },
}); });
@@ -45,6 +45,8 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
} }
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
if (!view) { if (!view) {
return; return;
@@ -8,6 +8,8 @@ import { PTZDigitalAction } from './ptz-digital';
export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfig> { export class PTZMultiAction extends AdvancedCameraCardAction<PTZMultiActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
let targetID: string | null = null; let targetID: string | null = null;
let type: PTZType | null = null; let type: PTZType | null = null;
@@ -28,6 +28,8 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
} }
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
const view = api.getViewManager().getView(); const view = api.getViewManager().getView();
if (!view) { if (!view) {
return; return;
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class ScreenshotAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getDownloadManager().downloadScreenshot(); await api.getDownloadManager().downloadScreenshot();
} }
} }
+3 -3
View File
@@ -1,9 +1,9 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { ActionType } from '../../../config/types'; import { ActionConfig, AuxillaryActionConfig } from '../../../config/types';
import { arrayify } from '../../../utils/basic'; import { arrayify } from '../../../utils/basic';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { ActionFactory } from '../factory'; import { ActionFactory } from '../factory';
import { Action, AuxillaryActionConfig } from '../types'; import { Action } from '../types';
export class ActionSet implements Action { export class ActionSet implements Action {
protected _context: ActionContext; protected _context: ActionContext;
@@ -13,7 +13,7 @@ export class ActionSet implements Action {
constructor( constructor(
context: ActionContext, context: ActionContext,
actions: ActionType | ActionType[], actions: ActionConfig | ActionConfig[],
options?: { options?: {
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
cardID?: string; cardID?: string;
+3 -2
View File
@@ -5,8 +5,9 @@ import { timeDeltaToSeconds } from '../utils/time-delta';
import { AdvancedCameraCardAction } from './base'; import { AdvancedCameraCardAction } from './base';
export class SleepAction extends AdvancedCameraCardAction<SleepActionConfig> { export class SleepAction extends AdvancedCameraCardAction<SleepActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars public async execute(api: CardActionsAPI): Promise<void> {
public async execute(_api: CardActionsAPI): Promise<void> { await super.execute(api);
await sleep(timeDeltaToSeconds(this._action.duration)); await sleep(timeDeltaToSeconds(this._action.duration));
} }
} }
@@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base';
export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> { export class StatusBarAction extends AdvancedCameraCardAction<StatusBarActionConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
switch (this._action.status_bar_action) { switch (this._action.status_bar_action) {
case 'reset': case 'reset':
api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
@@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base';
export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOffViewModifier()], modifiers: [new SubstreamOffViewModifier()],
}); });
@@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base';
export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOnViewModifier(api)], modifiers: [new SubstreamOnViewModifier(api)],
}); });
@@ -5,6 +5,8 @@ import { AdvancedCameraCardAction } from './base';
export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSelectActionConfig> { export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
modifiers: [new SubstreamSelectViewModifier(this._action.camera)], modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
}); });
@@ -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<ToggleActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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 });
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class UnmuteAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class UnmuteAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.unmute(); await api.getMediaLoadedInfoManager().get()?.mediaPlayerController?.unmute();
} }
} }
@@ -0,0 +1,11 @@
import { URLActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class URLAction extends AdvancedCameraCardAction<URLActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
window.open(this._action.url_path);
}
}
@@ -4,6 +4,8 @@ import { AdvancedCameraCardAction } from './base';
export class ViewAction extends AdvancedCameraCardAction<ViewActionConfig> { export class ViewAction extends AdvancedCameraCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getViewManager().setViewByParametersWithNewQuery({ await api.getViewManager().setViewByParametersWithNewQuery({
params: { params: {
view: this._action.advanced_camera_card_action, view: this._action.advanced_camera_card_action,
+71 -53
View File
@@ -1,15 +1,19 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { ActionType, INTERNAL_CALLBACK_ACTION } from '../../config/types'; import {
import { ActionConfig } from '../../ha/types'; ActionConfig,
import { convertActionToCardCustomAction } from '../../utils/action'; 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 { CameraSelectAction } from './actions/camera-select';
import { CameraUIAction } from './actions/camera-ui'; import { CameraUIAction } from './actions/camera-ui';
import { CustomAction } from './actions/custom';
import { DefaultAction } from './actions/default'; import { DefaultAction } from './actions/default';
import { DisplayModeSelectAction } from './actions/display-mode-select'; import { DisplayModeSelectAction } from './actions/display-mode-select';
import { DownloadAction } from './actions/download'; import { DownloadAction } from './actions/download';
import { ExpandAction } from './actions/expand'; import { ExpandAction } from './actions/expand';
import { FullscreenAction } from './actions/fullscreen'; import { FullscreenAction } from './actions/fullscreen';
import { GenericAction } from './actions/generic';
import { InternalCallbackAction } from './actions/internal-callback'; import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log'; import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player'; import { MediaPlayerAction } from './actions/media-player';
@@ -18,8 +22,12 @@ import { MicrophoneConnectAction } from './actions/microphone-connect';
import { MicrophoneDisconnectAction } from './actions/microphone-disconnect'; import { MicrophoneDisconnectAction } from './actions/microphone-disconnect';
import { MicrophoneMuteAction } from './actions/microphone-mute'; import { MicrophoneMuteAction } from './actions/microphone-mute';
import { MicrophoneUnmuteAction } from './actions/microphone-unmute'; import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
import { MoreInfoAction } from './actions/more-info';
import { MuteAction } from './actions/mute'; import { MuteAction } from './actions/mute';
import { NavigateAction } from './actions/navigate';
import { NoneAction } from './actions/none';
import { PauseAction } from './actions/pause'; import { PauseAction } from './actions/pause';
import { PerformActionAction } from './actions/perform-action';
import { PlayAction } from './actions/play'; import { PlayAction } from './actions/play';
import { PTZAction } from './actions/ptz'; import { PTZAction } from './actions/ptz';
import { PTZControlsAction } from './actions/ptz-controls'; import { PTZControlsAction } from './actions/ptz-controls';
@@ -31,39 +39,53 @@ import { StatusBarAction } from './actions/status-bar';
import { SubstreamOffAction } from './actions/substream-off'; import { SubstreamOffAction } from './actions/substream-off';
import { SubstreamOnAction } from './actions/substream-on'; import { SubstreamOnAction } from './actions/substream-on';
import { SubstreamSelectAction } from './actions/substream-select'; import { SubstreamSelectAction } from './actions/substream-select';
import { ToggleAction } from './actions/toggle';
import { UnmuteAction } from './actions/unmute'; import { UnmuteAction } from './actions/unmute';
import { URLAction } from './actions/url';
import { ViewAction } from './actions/view'; import { ViewAction } from './actions/view';
import { Action, AuxillaryActionConfig } from './types'; import { Action } from './types';
export class ActionFactory { export class ActionFactory {
public createAction( public createAction(
context: ActionContext, context: ActionContext,
action: ActionType, action: ActionConfig,
options?: { options?: {
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
cardID?: string; cardID?: string;
}, },
): Action | null { ): 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 ( if (
// Command not intended for this card (e.g. query string command). // Command not intended for this card (e.g. query string command).
cardCustomAction.card_id && action.card_id &&
cardCustomAction.card_id !== options?.cardID action.card_id !== options?.cardID
) { ) {
return null; 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': case 'default':
return new DefaultAction(context, cardCustomAction, options?.config); return new DefaultAction(context, action, options?.config);
case 'clip': case 'clip':
case 'clips': case 'clips':
case 'image': case 'image':
@@ -74,72 +96,68 @@ export class ActionFactory {
case 'snapshots': case 'snapshots':
case 'timeline': case 'timeline':
case 'diagnostics': case 'diagnostics':
return new ViewAction(context, cardCustomAction, options?.config); return new ViewAction(context, action, options?.config);
case 'sleep': case 'sleep':
return new SleepAction(context, cardCustomAction, options?.config); return new SleepAction(context, action, options?.config);
case 'download': case 'download':
return new DownloadAction(context, cardCustomAction, options?.config); return new DownloadAction(context, action, options?.config);
case 'camera_ui': case 'camera_ui':
return new CameraUIAction(context, cardCustomAction, options?.config); return new CameraUIAction(context, action, options?.config);
case 'expand': case 'expand':
return new ExpandAction(context, cardCustomAction, options?.config); return new ExpandAction(context, action, options?.config);
case 'fullscreen': case 'fullscreen':
return new FullscreenAction(context, cardCustomAction, options?.config); return new FullscreenAction(context, action, options?.config);
case 'menu_toggle': case 'menu_toggle':
return new MenuToggleAction(context, cardCustomAction, options?.config); return new MenuToggleAction(context, action, options?.config);
case 'camera_select': case 'camera_select':
return new CameraSelectAction(context, cardCustomAction, options?.config); return new CameraSelectAction(context, action, options?.config);
case 'live_substream_select': case 'live_substream_select':
return new SubstreamSelectAction(context, cardCustomAction, options?.config); return new SubstreamSelectAction(context, action, options?.config);
case 'live_substream_off': case 'live_substream_off':
return new SubstreamOffAction(context, cardCustomAction, options?.config); return new SubstreamOffAction(context, action, options?.config);
case 'live_substream_on': case 'live_substream_on':
return new SubstreamOnAction(context, cardCustomAction, options?.config); return new SubstreamOnAction(context, action, options?.config);
case 'media_player': case 'media_player':
return new MediaPlayerAction(context, cardCustomAction, options?.config); return new MediaPlayerAction(context, action, options?.config);
case 'microphone_connect': case 'microphone_connect':
return new MicrophoneConnectAction(context, cardCustomAction, options?.config); return new MicrophoneConnectAction(context, action, options?.config);
case 'microphone_disconnect': case 'microphone_disconnect':
return new MicrophoneDisconnectAction( return new MicrophoneDisconnectAction(context, action, options?.config);
context,
cardCustomAction,
options?.config,
);
case 'microphone_mute': case 'microphone_mute':
return new MicrophoneMuteAction(context, cardCustomAction, options?.config); return new MicrophoneMuteAction(context, action, options?.config);
case 'microphone_unmute': case 'microphone_unmute':
return new MicrophoneUnmuteAction(context, cardCustomAction, options?.config); return new MicrophoneUnmuteAction(context, action, options?.config);
case 'mute': case 'mute':
return new MuteAction(context, cardCustomAction, options?.config); return new MuteAction(context, action, options?.config);
case 'unmute': case 'unmute':
return new UnmuteAction(context, cardCustomAction, options?.config); return new UnmuteAction(context, action, options?.config);
case 'play': case 'play':
return new PlayAction(context, cardCustomAction, options?.config); return new PlayAction(context, action, options?.config);
case 'pause': case 'pause':
return new PauseAction(context, cardCustomAction, options?.config); return new PauseAction(context, action, options?.config);
case 'screenshot': case 'screenshot':
return new ScreenshotAction(context, cardCustomAction, options?.config); return new ScreenshotAction(context, action, options?.config);
case 'display_mode_select': case 'display_mode_select':
return new DisplayModeSelectAction(context, cardCustomAction, options?.config); return new DisplayModeSelectAction(context, action, options?.config);
case 'ptz': case 'ptz':
return new PTZAction(context, cardCustomAction, options?.config); return new PTZAction(context, action, options?.config);
case 'ptz_digital': case 'ptz_digital':
return new PTZDigitalAction(context, cardCustomAction, options?.config); return new PTZDigitalAction(context, action, options?.config);
case 'ptz_multi': case 'ptz_multi':
return new PTZMultiAction(context, cardCustomAction, options?.config); return new PTZMultiAction(context, action, options?.config);
case 'ptz_controls': case 'ptz_controls':
return new PTZControlsAction(context, cardCustomAction, options?.config); return new PTZControlsAction(context, action, options?.config);
case 'log': case 'log':
return new LogAction(context, cardCustomAction, options?.config); return new LogAction(context, action, options?.config);
case 'status_bar': case 'status_bar':
return new StatusBarAction(context, cardCustomAction, options?.config); return new StatusBarAction(context, action, options?.config);
case INTERNAL_CALLBACK_ACTION: 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 */ /* istanbul ignore next: this path cannot be reached -- @preserve */
console.warn( 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 */ /* istanbul ignore next: this path cannot be reached -- @preserve */
return null; return null;
+5 -7
View File
@@ -1,18 +1,14 @@
import { ActionType } from '../../config/types'; import { AdvancedCameraCardError } from '../../types.js';
import { ActionConfig, AuxillaryActionConfig } from '../../config/types';
import { CardActionsAPI } from '../types'; import { CardActionsAPI } from '../types';
export interface AuxillaryActionConfig {
camera_image?: string;
entity?: string;
}
export interface Action { export interface Action {
execute(api: CardActionsAPI): Promise<void>; execute(api: CardActionsAPI): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
} }
export interface ActionExecutionRequest { export interface ActionExecutionRequest {
action: ActionType[] | ActionType; action: ActionConfig[] | ActionConfig;
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
} }
@@ -21,3 +17,5 @@ export interface TargetedActionContext {
inProgressAction?: Action; inProgressAction?: Action;
}; };
} }
export class ActionAbortError extends AdvancedCameraCardError {}
+14 -14
View File
@@ -1,4 +1,4 @@
import { AdvancedCameraCardCustomAction, ViewActionConfig } from '../config/types'; import { AdvancedCameraCardCustomActionConfig, ViewActionConfig } from '../config/types';
import { import {
createCameraAction, createCameraAction,
createGeneralAction, createGeneralAction,
@@ -13,7 +13,7 @@ interface QueryStringViewIntent {
default?: boolean; default?: boolean;
substream?: string; substream?: string;
}; };
other?: AdvancedCameraCardCustomAction[]; other?: AdvancedCameraCardCustomActionConfig[];
} }
export class QueryStringManager { export class QueryStringManager {
@@ -92,9 +92,9 @@ export class QueryStringManager {
return result; return result;
} }
protected _getActions(): AdvancedCameraCardCustomAction[] { protected _getActions(): AdvancedCameraCardCustomActionConfig[] {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const actions: AdvancedCameraCardCustomAction[] = []; const actions: AdvancedCameraCardCustomActionConfig[] = [];
const actionRE = new RegExp( const actionRE = new RegExp(
/^(advanced-camera-card|frigate-card)-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/, /^(advanced-camera-card|frigate-card)-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/,
); );
@@ -104,14 +104,14 @@ export class QueryStringManager {
continue; continue;
} }
const cardID: string | undefined = match.groups['cardID']; const cardID: string | undefined = match.groups['cardID'];
const action = match.groups['action']; const actionName = match.groups['action'];
let customAction: AdvancedCameraCardCustomAction | null = null; let action: AdvancedCameraCardCustomActionConfig | null = null;
switch (action) { switch (actionName) {
case 'camera_select': case 'camera_select':
case 'live_substream_select': case 'live_substream_select':
if (value) { if (value) {
customAction = createCameraAction(action, value, { action = createCameraAction(actionName, value, {
cardID: cardID, cardID: cardID,
}); });
} }
@@ -121,7 +121,7 @@ export class QueryStringManager {
case 'download': case 'download':
case 'expand': case 'expand':
case 'menu_toggle': case 'menu_toggle':
customAction = createGeneralAction(action, { action = createGeneralAction(actionName, {
cardID: cardID, cardID: cardID,
}); });
break; break;
@@ -135,24 +135,24 @@ export class QueryStringManager {
case 'snapshot': case 'snapshot':
case 'snapshots': case 'snapshots':
case 'timeline': case 'timeline':
customAction = createViewAction(action, { action = createViewAction(actionName, {
cardID: cardID, cardID: cardID,
}); });
break; break;
default: default:
console.warn( 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) { if (action) {
actions.push(customAction); actions.push(action);
} }
} }
return actions; return actions;
} }
protected _isViewAction = ( protected _isViewAction = (
action: AdvancedCameraCardCustomAction, action: AdvancedCameraCardCustomActionConfig,
): action is ViewActionConfig => { ): action is ViewActionConfig => {
switch (action.advanced_camera_card_action) { switch (action.advanced_camera_card_action) {
case 'clip': case 'clip':
+7 -4
View File
@@ -1,6 +1,5 @@
import { HASS, renderTemplate } from 'ha-nunjucks/dist'; import { HASS, renderTemplate } from 'ha-nunjucks/dist';
import { ConditionState, ConditionsTriggerData } from '../../conditions/types'; import { ConditionState, ConditionsTriggerData } from '../../conditions/types';
import { ActionType } from '../../config/types';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
interface TemplateContextInternal { interface TemplateContextInternal {
@@ -24,7 +23,7 @@ export class TemplateRenderer {
conditionState?: ConditionState; conditionState?: ConditionState;
triggerData?: ConditionsTriggerData; triggerData?: ConditionsTriggerData;
}, },
): ActionType => { ): unknown => {
return this._renderTemplateRecursively( return this._renderTemplateRecursively(
hass, hass,
data, data,
@@ -59,11 +58,15 @@ export class TemplateRenderer {
hass: HomeAssistant, hass: HomeAssistant,
data: unknown, data: unknown,
templateContext?: TemplateContext, templateContext?: TemplateContext,
): ActionType { ): unknown {
if (typeof data === 'string') { if (typeof data === 'string') {
return renderTemplate(
// ha-nunjucks has a more complete model of the Home Assistant object, but // ha-nunjucks has a more complete model of the Home Assistant object, but
// does not export it as a type. // does not export it as a type.
return renderTemplate(hass as unknown as typeof HASS, data, templateContext); hass as unknown as typeof HASS,
data,
templateContext,
);
} else if (Array.isArray(data)) { } else if (Array.isArray(data)) {
return data.map((item) => return data.map((item) =>
this._renderTemplateRecursively(hass, item, templateContext), this._renderTemplateRecursively(hass, item, templateContext),
+35 -52
View File
@@ -6,7 +6,6 @@ import { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view/view-manager'; import { ViewManager } from '../card-controller/view/view-manager';
import { import {
AdvancedCameraCardConfig, AdvancedCameraCardConfig,
AdvancedCameraCardCustomAction,
MenuItem, MenuItem,
VIEWS_USER_SPECIFIED, VIEWS_USER_SPECIFIED,
} from '../config/types'; } from '../config/types';
@@ -21,8 +20,9 @@ import {
createPTZControlsAction, createPTZControlsAction,
createPTZMultiAction, createPTZMultiAction,
createViewAction, createViewAction,
isAdvancedCameraCardCustomAction,
} from '../utils/action'; } from '../utils/action';
import { isTruthy } from '../utils/basic'; import { arrayify, isTruthy } from '../utils/basic';
import { isBeingCasted } from '../utils/casting'; import { isBeingCasted } from '../utils/casting';
import { getEntityTitle } from '../utils/ha'; import { getEntityTitle } from '../utils/ha';
import { getPTZTarget } from '../utils/ptz'; import { getPTZTarget } from '../utils/ptz';
@@ -118,9 +118,9 @@ export class MenuButtonController {
permanent: true, permanent: true,
tap_action: tap_action:
config.menu?.style === 'hidden' config.menu?.style === 'hidden'
? (createGeneralAction('menu_toggle') as AdvancedCameraCardCustomAction) ? createGeneralAction('menu_toggle')
: (createGeneralAction('default') as AdvancedCameraCardCustomAction), : createGeneralAction('default'),
hold_action: createViewAction('diagnostics') as AdvancedCameraCardCustomAction, hold_action: createViewAction('diagnostics'),
}; };
} }
@@ -191,7 +191,7 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
tap_action: createGeneralAction( tap_action: createGeneralAction(
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
) as AdvancedCameraCardCustomAction, ),
}; };
} else if (streams.length > 2) { } else if (streams.length > 2) {
const menuItems = Array.from(streams, (streamID) => { const menuItems = Array.from(streams, (streamID) => {
@@ -234,7 +234,7 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.live'), title: localize('config.view.views.live'),
style: view.is('live') ? this._getEmphasizedStyle() : {}, style: view.is('live') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('live') as AdvancedCameraCardCustomAction, tap_action: createViewAction('live'),
} }
: null; : null;
} }
@@ -251,8 +251,8 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.clips'), title: localize('config.view.views.clips'),
style: view?.is('clips') ? this._getEmphasizedStyle() : {}, style: view?.is('clips') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('clips') as AdvancedCameraCardCustomAction, tap_action: createViewAction('clips'),
hold_action: createViewAction('clip') as AdvancedCameraCardCustomAction, hold_action: createViewAction('clip'),
} }
: null; : null;
} }
@@ -269,8 +269,8 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.snapshots'), title: localize('config.view.views.snapshots'),
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {}, style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('snapshots') as AdvancedCameraCardCustomAction, tap_action: createViewAction('snapshots'),
hold_action: createViewAction('snapshot') as AdvancedCameraCardCustomAction, hold_action: createViewAction('snapshot'),
} }
: null; : null;
} }
@@ -287,8 +287,8 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.recordings'), title: localize('config.view.views.recordings'),
style: view.is('recordings') ? this._getEmphasizedStyle() : {}, style: view.is('recordings') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('recordings') as AdvancedCameraCardCustomAction, tap_action: createViewAction('recordings'),
hold_action: createViewAction('recording') as AdvancedCameraCardCustomAction, hold_action: createViewAction('recording'),
} }
: null; : null;
} }
@@ -305,7 +305,7 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.image'), title: localize('config.view.views.image'),
style: view?.is('image') ? this._getEmphasizedStyle() : {}, style: view?.is('image') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('image') as AdvancedCameraCardCustomAction, tap_action: createViewAction('image'),
} }
: null; : null;
} }
@@ -322,7 +322,7 @@ export class MenuButtonController {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.view.views.timeline'), title: localize('config.view.views.timeline'),
style: view.is('timeline') ? this._getEmphasizedStyle() : {}, style: view.is('timeline') ? this._getEmphasizedStyle() : {},
tap_action: createViewAction('timeline') as AdvancedCameraCardCustomAction, tap_action: createViewAction('timeline'),
} }
: null; : null;
} }
@@ -342,7 +342,7 @@ export class MenuButtonController {
...config.menu.buttons.download, ...config.menu.buttons.download,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.download'), title: localize('config.menu.buttons.download'),
tap_action: createGeneralAction('download') as AdvancedCameraCardCustomAction, tap_action: createGeneralAction('download'),
}; };
} }
return null; return null;
@@ -358,7 +358,7 @@ export class MenuButtonController {
...config.menu.buttons.camera_ui, ...config.menu.buttons.camera_ui,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.camera_ui'), title: localize('config.menu.buttons.camera_ui'),
tap_action: createGeneralAction('camera_ui') as AdvancedCameraCardCustomAction, tap_action: createGeneralAction('camera_ui'),
} }
: null; : null;
} }
@@ -385,18 +385,14 @@ export class MenuButtonController {
style: unavailable || muted ? {} : this._getEmphasizedStyle(true), style: unavailable || muted ? {} : this._getEmphasizedStyle(true),
...(!unavailable && ...(!unavailable &&
buttonType === 'momentary' && { buttonType === 'momentary' && {
start_tap_action: createGeneralAction( start_tap_action: createGeneralAction('microphone_unmute'),
'microphone_unmute', end_tap_action: createGeneralAction('microphone_mute'),
) as AdvancedCameraCardCustomAction,
end_tap_action: createGeneralAction(
'microphone_mute',
) as AdvancedCameraCardCustomAction,
}), }),
...(!unavailable && ...(!unavailable &&
buttonType === 'toggle' && { buttonType === 'toggle' && {
tap_action: createGeneralAction( tap_action: createGeneralAction(
muted ? 'microphone_unmute' : 'microphone_mute', muted ? 'microphone_unmute' : 'microphone_mute',
) as AdvancedCameraCardCustomAction, ),
}), }),
}; };
} }
@@ -412,7 +408,7 @@ export class MenuButtonController {
...config.menu.buttons.expand, ...config.menu.buttons.expand,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.expand'), title: localize('config.menu.buttons.expand'),
tap_action: createGeneralAction('expand') as AdvancedCameraCardCustomAction, tap_action: createGeneralAction('expand'),
style: inExpandedMode ? this._getEmphasizedStyle() : {}, style: inExpandedMode ? this._getEmphasizedStyle() : {},
}; };
} }
@@ -428,9 +424,7 @@ export class MenuButtonController {
...config.menu.buttons.fullscreen, ...config.menu.buttons.fullscreen,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.fullscreen'), title: localize('config.menu.buttons.fullscreen'),
tap_action: createGeneralAction( tap_action: createGeneralAction('fullscreen'),
'fullscreen',
) as AdvancedCameraCardCustomAction,
style: inFullscreen ? this._getEmphasizedStyle() : {}, style: inFullscreen ? this._getEmphasizedStyle() : {},
} }
: null; : null;
@@ -498,9 +492,7 @@ export class MenuButtonController {
...config.menu.buttons.play, ...config.menu.buttons.play,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.play'), title: localize('config.menu.buttons.play'),
tap_action: createGeneralAction( tap_action: createGeneralAction(paused ? 'play' : 'pause'),
paused ? 'play' : 'pause',
) as AdvancedCameraCardCustomAction,
}; };
} }
return null; return null;
@@ -521,9 +513,7 @@ export class MenuButtonController {
...config.menu.buttons.mute, ...config.menu.buttons.mute,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.mute'), title: localize('config.menu.buttons.mute'),
tap_action: createGeneralAction( tap_action: createGeneralAction(muted ? 'unmute' : 'mute'),
muted ? 'unmute' : 'mute',
) as AdvancedCameraCardCustomAction,
}; };
} }
return null; return null;
@@ -539,7 +529,7 @@ export class MenuButtonController {
...config.menu.buttons.screenshot, ...config.menu.buttons.screenshot,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.screenshot'), title: localize('config.menu.buttons.screenshot'),
tap_action: createGeneralAction('screenshot') as AdvancedCameraCardCustomAction, tap_action: createGeneralAction('screenshot'),
}; };
} }
return null; return null;
@@ -637,7 +627,7 @@ export class MenuButtonController {
title: localize('config.menu.buttons.ptz_home'), title: localize('config.menu.buttons.ptz_home'),
tap_action: createPTZMultiAction({ tap_action: createPTZMultiAction({
targetID: target.targetID, targetID: target.targetID,
}) as AdvancedCameraCardCustomAction, }),
}; };
} }
@@ -675,30 +665,23 @@ export class MenuButtonController {
button.start_tap_action, button.start_tap_action,
button.end_tap_action, button.end_tap_action,
]) { ]) {
const actions = Array.isArray(actionSet) ? actionSet : [actionSet]; for (const action of arrayify(actionSet)) {
for (const action of actions) { if (!isAdvancedCameraCardCustomAction(action)) {
// 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)
) {
continue; continue;
} }
const customCardAction = action as AdvancedCameraCardCustomAction;
if ( if (
VIEWS_USER_SPECIFIED.some( VIEWS_USER_SPECIFIED.some(
(viewName) => (viewName) =>
viewName === customCardAction.advanced_camera_card_action && viewName === action.advanced_camera_card_action &&
options?.view?.is(customCardAction.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)) || options?.view?.is(config.view.default)) ||
(customCardAction.advanced_camera_card_action === 'fullscreen' && (action.advanced_camera_card_action === 'fullscreen' &&
!!options?.fullscreenManager?.isInFullscreen()) || !!options?.fullscreenManager?.isInFullscreen()) ||
(customCardAction.advanced_camera_card_action === 'camera_select' && (action.advanced_camera_card_action === 'camera_select' &&
options?.view?.camera === customCardAction.camera) options?.view?.camera === action.camera)
) { ) {
return this._getEmphasizedStyle(); return this._getEmphasizedStyle();
} }
+8 -9
View File
@@ -3,17 +3,14 @@ import { orderBy } from 'lodash-es';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
import { SubmenuInteraction } from '../components/submenu/types.js'; import { SubmenuInteraction } from '../components/submenu/types.js';
import { import {
ActionConfig,
MENU_PRIORITY_MAX, MENU_PRIORITY_MAX,
type ActionType,
type ActionsConfig, type ActionsConfig,
type MenuConfig, type MenuConfig,
type MenuItem, type MenuItem,
} from '../config/types.js'; } from '../config/types.js';
import { Interaction } from '../types.js'; import { Interaction } from '../types.js';
import { import { getActionConfigGivenAction } from '../utils/action';
convertActionToCardCustomAction,
getActionConfigGivenAction,
} from '../utils/action';
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
export class MenuController { export class MenuController {
@@ -114,7 +111,7 @@ export class MenuController {
let menuToggle = false; let menuToggle = false;
const toggleLessActions = actions.filter( const toggleLessActions = actions.filter(
(item) => isTruthy(item) && !this._isUnknownActionMenuToggleAction(item), (item) => isTruthy(item) && !this._isMenuToggleAction(item),
); );
if (toggleLessActions.length != actions.length) { if (toggleLessActions.length != actions.length) {
menuToggle = true; menuToggle = true;
@@ -171,8 +168,10 @@ export class MenuController {
return this._config?.style === 'hidden'; return this._config?.style === 'hidden';
} }
protected _isUnknownActionMenuToggleAction(action: ActionType): boolean { protected _isMenuToggleAction(action: ActionConfig): boolean {
const parsedAction = convertActionToCardCustomAction(action); return (
return !!parsedAction && parsedAction.advanced_camera_card_action == 'menu_toggle'; action.action === 'fire-dom-event' &&
action.advanced_camera_card_action === 'menu_toggle'
);
} }
} }
+18 -37
View File
@@ -8,9 +8,10 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.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 { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss'; import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js';
import { getEntityTitle, isHassDifferent } from '../../utils/ha'; import { getEntityTitle, isHassDifferent } from '../../utils/ha';
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/index.js';
@@ -31,7 +32,8 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
@state() @state()
protected _optionTitles?: Record<string, string>; protected _optionTitles?: Record<string, string>;
protected _generatedSubmenu?: MenuSubmenu; protected _generatedSubmenuItems?: MenuSubmenuItem[];
protected _generatedIcon?: Icon;
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
// No need to update the submenu unless the select entity has changed. // No need to update the submenu unless the select entity has changed.
@@ -86,29 +88,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
return; return;
} }
const title = getEntityTitle(this.hass, entityID); const items: MenuSubmenuItem[] = [];
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[];
for (const option of options) { for (const option of options) {
const title = this._optionTitles?.[option] ?? option; 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 { protected render(): TemplateResult {
const submenu = this._generatedSubmenu; if (!this._generatedSubmenuItems || !this._generatedIcon || !this.submenuSelect) {
if (!submenu) {
return html``; return html``;
} }
const style = styleMap(submenu.style || {}); const title = getEntityTitle(this.hass, this.submenuSelect.entity);
const style = styleMap(this.submenuSelect.style || {});
return html` <advanced-camera-card-submenu return html` <advanced-camera-card-submenu
.hass=${this.hass} .hass=${this.hass}
.items=${submenu?.items} .items=${this._generatedSubmenuItems}
> >
<ha-icon-button style="${style}" .label=${submenu.title || ''}> <ha-icon-button style="${style}" .label=${title || ''}>
<advanced-camera-card-icon <advanced-camera-card-icon
?allow-override-non-active-styles=${true} ?allow-override-non-active-styles=${true}
style="${style}" style="${style}"
title=${submenu.title || ''} title=${title || ''}
.hass=${this.hass} .hass=${this.hass}
.icon=${typeof submenu.icon === 'string' .icon=${this._generatedIcon}
? {
icon: submenu.icon,
}
: submenu.icon}
></advanced-camera-card-icon> ></advanced-camera-card-icon>
</ha-icon-button> </ha-icon-button>
</advanced-camera-card-submenu>`; </advanced-camera-card-submenu>`;
+1 -1
View File
@@ -3,9 +3,9 @@ import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../../action-handler-directive.js'; import { actionHandler } from '../../action-handler-directive.js';
import { MenuSubmenu } from '../../config/types.js'; import { MenuSubmenu } from '../../config/types.js';
import { hasAction } from '../../ha/has-action.js';
import { HomeAssistant } from '../../ha/types.js'; import { HomeAssistant } from '../../ha/types.js';
import menuButtonStyle from '../../scss/menu-button.scss'; import menuButtonStyle from '../../scss/menu-button.scss';
import { hasAction } from '../../utils/action.js';
import '../icon.js'; import '../icon.js';
import './index.js'; import './index.js';
+2 -5
View File
@@ -1,6 +1,7 @@
import { Actions } from '../../config/types';
import { Interaction } from '../../types'; import { Interaction } from '../../types';
export interface SubmenuItem { export interface SubmenuItem extends Actions {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
icon?: string; icon?: string;
@@ -8,10 +9,6 @@ export interface SubmenuItem {
style?: Record<string, string>; style?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
selected?: boolean; selected?: boolean;
hold_action?: unknown;
double_tap_action?: unknown;
[key: string]: unknown;
} }
export interface SubmenuInteraction extends Interaction { export interface SubmenuInteraction extends Interaction {
+108 -136
View File
@@ -1,16 +1,6 @@
import { HassServiceTarget } from 'home-assistant-js-websocket'; import { HassServiceTarget } from 'home-assistant-js-websocket';
import { z } from 'zod'; import { z } from 'zod';
import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js'; 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 { capabilityKeys } from '../types.js';
import { deepRemoveDefaults } from '../utils/zod.js'; import { deepRemoveDefaults } from '../utils/zod.js';
import { import {
@@ -123,12 +113,11 @@ const viewDisplaySchema = z
export type ViewDisplayConfig = z.infer<typeof viewDisplaySchema>; export type ViewDisplayConfig = z.infer<typeof viewDisplaySchema>;
// ************************************************************************* // *************************************************************************
// Actions // Stock Actions
//
// Declare schemas to existing types:
// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330
// ************************************************************************* // *************************************************************************
// Declare schemas for existing types.
// See: https://github.com/colinhacks/zod/issues/372#issuecomment-826380330
const schemaForType = const schemaForType =
<T>() => <T>() =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -152,108 +141,86 @@ const actionBaseSchema = z.object({
}), }),
) )
.optional(), .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. const toggleActionSchema = actionBaseSchema.extend({
// `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'), action: z.literal('toggle'),
}), });
); export type ToggleActionConfig = z.infer<typeof toggleActionSchema>;
const targetSchema = schemaForType<HassServiceTarget>()( const targetSchema = schemaForType<HassServiceTarget>()(
z.object({ z.object({
entity_id: z.string().optional(), entity_id: z.string().or(z.string().array()).optional(),
device_id: z.string().optional(), device_id: z.string().or(z.string().array()).optional(),
area_id: z.string().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< const performActionActionSchema = actionBaseSchema.extend({
PerformActionActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('perform-action'), action: z.literal('perform-action'),
perform_action: z.string(), perform_action: z.string(),
data: z.object({}).passthrough().optional(), data: z.object({}).passthrough().optional(),
target: targetSchema.optional(), target: targetSchema.optional(),
}), });
); export type PerformActionActionConfig = z.infer<typeof performActionActionSchema>;
// Note: call-service is deprecated and will eventually go away. Please use // Note: call-service is deprecated and will eventually go away. Please use
// perform-action instead. // perform-action instead.
// See: https://www.home-assistant.io/blog/2024/08/07/release-20248/#goodbye-service-calls-hello-actions- // See: https://www.home-assistant.io/blog/2024/08/07/release-20248/#goodbye-service-calls-hello-actions-
const callServiceActionSchema = schemaForType< const callServiceActionSchema = actionBaseSchema.extend({
CallServiceActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('call-service'), action: z.literal('call-service'),
service: z.string(), service: z.string(),
data: z.object({}).passthrough().optional(), data: z.object({}).passthrough().optional(),
target: targetSchema.optional(), target: targetSchema.optional(),
}), });
); export type CallServiceActionConfig = z.infer<typeof callServiceActionSchema>;
const navigateActionSchema = schemaForType< const navigateActionSchema = actionBaseSchema.extend({
NavigateActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('navigate'), action: z.literal('navigate'),
navigation_path: z.string(), navigation_path: z.string(),
}), navigation_replace: z.boolean().optional(),
); });
export type NavigateActionConfig = z.infer<typeof navigateActionSchema>;
const urlActionSchema = schemaForType< const urlActionSchema = actionBaseSchema.extend({
UrlActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('url'), action: z.literal('url'),
url_path: z.string(), url_path: z.string(),
}), });
); export type URLActionConfig = z.infer<typeof urlActionSchema>;
const moreInfoActionSchema = schemaForType< const moreInfoActionSchema = actionBaseSchema.extend({
MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('more-info'), action: z.literal('more-info'),
}), entity: z.string().optional(),
); });
export type MoreInfoActionConfig = z.infer<typeof moreInfoActionSchema>;
const customActionSchema = actionBaseSchema const customActionSchema = actionBaseSchema
.extend({ .extend({
action: z.literal('fire-dom-event'), action: z.literal('fire-dom-event'),
}) })
.passthrough(); .passthrough();
export type CustomActionConfig = z.infer<typeof customActionSchema>;
const noActionSchema = schemaForType< const noneActionSchema = actionBaseSchema.extend({
NoActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema.extend({
action: z.literal('none'), action: z.literal('none'),
}), });
); export type NoneActionConfig = z.infer<typeof noneActionSchema>;
export const advancedCameraCardCustomActionsBaseSchema = customActionSchema.extend({ export const advancedCameraCardCustomActionsBaseSchema = actionBaseSchema.extend({
action: z action: z
.literal('fire-dom-event')
.or(
z
.literal('custom:advanced-camera-card-action') .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'),
.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(),
}); });
// ************************************************************************* // *************************************************************************
@@ -374,18 +341,28 @@ const sleepActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend
}); });
export type SleepActionConfig = z.infer<typeof sleepActionConfigSchema>; export type SleepActionConfig = z.infer<typeof sleepActionConfigSchema>;
const statusBarActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({ // 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'), advanced_camera_card_action: z.literal('status_bar'),
status_bar_action: z.enum(['add', 'remove', 'reset']), status_bar_action: z.enum(['add', 'remove', 'reset']),
},
// This needs to be lazily evaluated since statusBarItemSchema may itself );
// contain actions. export type StatusBarActionConfig = z.infer<typeof statusBarActionConfigSchemaBase> & {
items?: StatusBarItem[];
};
export const statusBarActionConfigSchema: z.ZodSchema<
StatusBarActionConfig,
z.ZodTypeDef,
unknown
> = statusBarActionConfigSchemaBase.extend({
items: z items: z
.lazy(() => statusBarItemSchema) .lazy(() => statusBarItemSchema)
.array() .array()
.optional(), .optional(),
}); });
export type StatusBarActionConfig = z.infer<typeof statusBarActionConfigSchema>;
const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const; const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const;
export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number]; export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number];
@@ -397,27 +374,8 @@ const logActionConfigSchema = advancedCameraCardCustomActionsBaseSchema.extend({
}); });
export type LogActionConfig = z.infer<typeof logActionConfigSchema>; export type LogActionConfig = z.infer<typeof logActionConfigSchema>;
const advancedCameraCardCustomActionSchema = z.union([ // An action that can be used internally to call a callback (it is not possible
cameraSelectActionConfigSchema, // for the user to pass this through via the configuration).
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.
export const INTERNAL_CALLBACK_ACTION = '__INTERNAL_CALLBACK_ACTION__'; export const INTERNAL_CALLBACK_ACTION = '__INTERNAL_CALLBACK_ACTION__';
const internalCallbackActionConfigSchema = const internalCallbackActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({ advancedCameraCardCustomActionsBaseSchema.extend({
@@ -430,57 +388,66 @@ export type InternalCallbackActionConfig = z.infer<
typeof internalCallbackActionConfigSchema typeof internalCallbackActionConfigSchema
>; >;
export const internalAdvancedCameraCardCustomActionSchema = const stockActionSchema = z.union([
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,
callServiceActionSchema, callServiceActionSchema,
performActionActionSchema,
navigateActionSchema,
urlActionSchema,
moreInfoActionSchema,
noActionSchema,
customActionSchema, customActionSchema,
advancedCameraCardCustomActionSchema, moreInfoActionSchema,
navigateActionSchema,
noneActionSchema,
performActionActionSchema,
toggleActionSchema,
urlActionSchema,
]); ]);
const internalActionSchema = actionSchema.or(internalCallbackActionConfigSchema); const advancedCameraCardCustomActionSchema = z.union([
export type ActionType = z.infer<typeof internalActionSchema>; 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<typeof actionConfigSchema>;
export interface AuxillaryActionConfig {
entity?: string;
}
const actionsBaseSchema = z const actionsBaseSchema = z
.object({ .object({
tap_action: actionSchema.or(actionSchema.array()).optional(), tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
hold_action: actionSchema.or(actionSchema.array()).optional(), hold_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
double_tap_action: actionSchema.or(actionSchema.array()).optional(), double_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
start_tap_action: actionSchema.or(actionSchema.array()).optional(), start_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
end_tap_action: actionSchema.or(actionSchema.array()).optional(), end_tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
}) })
// Passthrough to allow (at least) entity/camera_image to go through. This // Passthrough to allow (at least) entity/camera_image to go through. This
// card doesn't need these attributes, but handleAction() in // card doesn't need these attributes, but handleAction() in
// custom_card_helpers may depending on how the action is configured. // custom_card_helpers may depending on how the action is configured.
.passthrough(); .passthrough();
export type Actions = z.infer<typeof actionsBaseSchema>; export type Actions = z.infer<typeof actionsBaseSchema>;
export type ActionsConfig = Actions & AuxillaryActionConfig;
export type ActionsConfig = Actions & {
camera_image?: string;
entity?: string;
};
const actionsSchema = z.object({ const actionsSchema = z.object({
actions: actionsBaseSchema.optional(), 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 // Picture Elements
// //
@@ -489,6 +456,11 @@ const elementsBaseSchema = actionsBaseSchema.extend({
// display up-front regardless of where they made their error. // 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 // https://www.home-assistant.io/lovelace/picture-elements/#state-badge
const stateBadgeIconSchema = elementsBaseSchema.extend({ const stateBadgeIconSchema = elementsBaseSchema.extend({
type: z.literal('state-badge'), type: z.literal('state-badge'),
@@ -2009,7 +1981,7 @@ export type Overrides = z.infer<typeof overridesSchema>;
// Automation Configuration // Automation Configuration
// ************************************************************************* // *************************************************************************
const automationActionSchema = actionSchema.array(); const automationActionSchema = actionConfigSchema.array();
export type AutomationActions = z.infer<typeof automationActionSchema>; export type AutomationActions = z.infer<typeof automationActionSchema>;
const automationSchema = z const automationSchema = z
-90
View File
@@ -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);
}
}
};
-5
View File
@@ -1,5 +0,0 @@
import { ActionConfig } from './types.js';
export function hasAction(config?: ActionConfig): boolean {
return config !== undefined && config.action !== 'none';
}
-20
View File
@@ -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,
});
};
-8
View File
@@ -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<void> => {
const turnOn = STATES_OFF.includes(hass.states[entityId].state);
return turnOnOffEntity(hass, entityId, turnOn);
};
-25
View File
@@ -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<void> => {
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 });
};
-81
View File
@@ -7,87 +7,6 @@ import {
HassServiceTarget, HassServiceTarget,
MessageBase, MessageBase,
} from 'home-assistant-js-websocket'; } 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 { declare global {
interface HASSDomEvents { interface HASSDomEvents {
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "",
"confirmation": ""
},
"common": { "common": {
"advanced_camera_card": "", "advanced_camera_card": "",
"advanced_camera_card_description": "", "advanced_camera_card_description": "",
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "Aborted action",
"confirmation": "Are you sure you want to perform this action"
},
"common": { "common": {
"advanced_camera_card": "Advanced Camera Card", "advanced_camera_card": "Advanced Camera Card",
"advanced_camera_card_description": "An Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card",
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "",
"confirmation": ""
},
"common": { "common": {
"advanced_camera_card": "", "advanced_camera_card": "",
"advanced_camera_card_description": "", "advanced_camera_card_description": "",
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "",
"confirmation": ""
},
"common": { "common": {
"advanced_camera_card": "", "advanced_camera_card": "",
"advanced_camera_card_description": "", "advanced_camera_card_description": "",
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "",
"confirmation": ""
},
"common": { "common": {
"advanced_camera_card": "", "advanced_camera_card": "",
"advanced_camera_card_description": "", "advanced_camera_card_description": "",
+4
View File
@@ -1,4 +1,8 @@
{ {
"actions": {
"abort": "",
"confirmation": ""
},
"common": { "common": {
"advanced_camera_card": "", "advanced_camera_card": "",
"advanced_camera_card_description": "", "advanced_camera_card_description": "",
+17 -29
View File
@@ -2,49 +2,31 @@ import { CardActionsAPI } from '../card-controller/types.js';
import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
import { PTZAction } from '../config/ptz.js'; import { PTZAction } from '../config/ptz.js';
import { import {
ActionConfig,
ActionPhase, ActionPhase,
ActionType,
ActionsConfig, ActionsConfig,
AdvancedCameraCardCustomActionConfig,
AdvancedCameraCardGeneralAction, AdvancedCameraCardGeneralAction,
AdvancedCameraCardUserSpecifiedView, AdvancedCameraCardUserSpecifiedView,
CameraSelectActionConfig, CameraSelectActionConfig,
DisplayModeActionConfig, DisplayModeActionConfig,
GeneralActionConfig, GeneralActionConfig,
INTERNAL_CALLBACK_ACTION, INTERNAL_CALLBACK_ACTION,
InternalAdvancedCameraCardCustomAction,
InternalCallbackActionConfig, InternalCallbackActionConfig,
LogActionConfig, LogActionConfig,
LogActionLevel, LogActionLevel,
MediaPlayerActionConfig, MediaPlayerActionConfig,
PerformActionActionConfig,
PTZActionConfig, PTZActionConfig,
PTZControlsActionConfig, PTZControlsActionConfig,
PTZDigitialActionConfig, PTZDigitialActionConfig,
PTZMultiActionConfig, PTZMultiActionConfig,
SubstreamSelectActionConfig, SubstreamSelectActionConfig,
ViewActionConfig, ViewActionConfig,
internalAdvancedCameraCardCustomActionSchema,
} from '../config/types.js'; } from '../config/types.js';
import { hasAction as customCardHasAction } from '../ha/has-action.js'; import { ServiceCallRequest } from '../ha/types.js';
import { ActionConfig, ServiceCallRequest } from '../ha/types.js';
import { arrayify } from './basic.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( export function createGeneralAction(
action: AdvancedCameraCardGeneralAction, action: AdvancedCameraCardGeneralAction,
options?: { options?: {
@@ -221,7 +203,7 @@ export function createPerformAction(
data?: ServiceCallRequest['serviceData']; data?: ServiceCallRequest['serviceData'];
target?: ServiceCallRequest['target']; target?: ServiceCallRequest['target'];
}, },
): ActionType { ): PerformActionActionConfig {
return { return {
action: 'perform-action' as const, action: 'perform-action' as const,
perform_action: perform_action, perform_action: perform_action,
@@ -240,7 +222,7 @@ export function createPerformAction(
export function getActionConfigGivenAction( export function getActionConfigGivenAction(
interaction?: string, interaction?: string,
config?: ActionsConfig | null, config?: ActionsConfig | null,
): ActionType | ActionType[] | null { ): ActionConfig | ActionConfig[] | null {
if (!interaction || !config) { if (!interaction || !config) {
return null; return null;
} }
@@ -270,11 +252,17 @@ export function getActionConfigGivenAction(
* @param config The action config in question. * @param config The action config in question.
* @returns `true` if there's a real action defined, `false` otherwise. * @returns `true` if there's a real action defined, `false` otherwise.
*/ */
export const hasAction = (config?: ActionType | ActionType[]): boolean => { export const hasAction = (config?: ActionConfig | ActionConfig[]): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is return arrayify(config).some((item) => item.action !== 'none');
// necessary and harmless. };
return arrayify(config).some((item) =>
customCardHasAction(item as ActionConfig | undefined), 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'
); );
}; };
+2 -2
View File
@@ -50,8 +50,8 @@ export function arrayMove(target: unknown[], from: number, to: number): unknown[
* @param value: A value (which may be an array). * @param value: A value (which may be an array).
* @returns An array. * @returns An array.
*/ */
export const arrayify = <T>(value: T | T[]): T[] => { export const arrayify = <T>(value?: T | T[]): T[] => {
return Array.isArray(value) ? value : [value]; return value ? (Array.isArray(value) ? value : [value]) : [];
}; };
/** /**
@@ -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 { mock } from 'vitest-mock-extended';
import { import {
ActionsManager, ActionsManager,
@@ -8,13 +17,7 @@ import {
import { TemplateRenderer } from '../../../src/card-controller/templates'; import { TemplateRenderer } from '../../../src/card-controller/templates';
import { AdvancedCameraCardView } from '../../../src/config/types'; import { AdvancedCameraCardView } from '../../../src/config/types';
import { createLogAction } from '../../../src/utils/action'; import { createLogAction } from '../../../src/utils/action';
import { import { createCardAPI, createConfig, createHASS, createView } from '../../test-utils';
createAction,
createCardAPI,
createConfig,
createHASS,
createView,
} from '../../test-utils';
describe('ActionsManager', () => { describe('ActionsManager', () => {
describe('getMergedActions', () => { describe('getMergedActions', () => {
@@ -138,7 +141,7 @@ describe('ActionsManager', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('should handle interaction', () => { it('should handle interaction', async () => {
const api = createCardAPI(); const api = createCardAPI();
const element = document.createElement('div'); const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element); vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
@@ -158,7 +161,7 @@ describe('ActionsManager', () => {
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
manager.handleInteractionEvent( await manager.handleInteractionEvent(
new CustomEvent<Interaction>('event', { detail: { action: 'tap' } }), new CustomEvent<Interaction>('event', { detail: { action: 'tap' } }),
); );
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
@@ -203,7 +206,7 @@ describe('ActionsManager', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('should handle event', () => { it('should handle event', async () => {
const action = createLogAction('Hello, world!'); const action = createLogAction('Hello, world!');
const event = new CustomEvent('ll-custom', { const event = new CustomEvent('ll-custom', {
detail: action, detail: action,
@@ -213,15 +216,15 @@ describe('ActionsManager', () => {
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
manager.handleCustomActionEvent(event); await manager.handleCustomActionEvent(event);
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
}); });
it('should not handle event without detail', () => { it('should not handle event without detail', async () => {
const manager = new ActionsManager(createCardAPI()); const manager = new ActionsManager(createCardAPI());
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); 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(); expect(consoleSpy).not.toBeCalled();
}); });
}); });
@@ -250,43 +253,14 @@ describe('ActionsManager', () => {
await manager.executeActions(createLogAction('Hello, world!')); await manager.executeActions(createLogAction('Hello, world!'));
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
}); });
});
describe('uninitialize', () => { it('should execute actions', async () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should stop actions', async () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
const promise = manager.executeActions([ await manager.executeActions(createLogAction('Hello, world!'));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(consoleSpy).toBeCalled();
createAction({
advanced_camera_card_action: 'sleep',
duration: {
m: 1,
},
})!,
createLogAction('Hello, world!'),
]);
// Stop inflight actions.
manager.uninitialize();
// Advance timers (causes the sleep to end).
vi.runOnlyPendingTimers();
await promise;
// Action set will not continue.
expect(consoleSpy).not.toBeCalled();
});
}); });
it('should render templates', async () => { it('should render templates', async () => {
@@ -305,7 +279,7 @@ describe('ActionsManager', () => {
vi.mocked(api.getConditionStateManager().getState).mockReturnValue(conditionState); vi.mocked(api.getConditionStateManager().getState).mockReturnValue(conditionState);
const manager = new ActionsManager(api, templateRenderer); const manager = new ActionsManager(api, templateRenderer);
const config = { camera_image: 'camera-image' }; const config = { entity: 'light.office' };
const triggerData = { view: { from: 'previous-view', to: 'view' } }; const triggerData = { view: { from: 'previous-view', to: 'view' } };
await manager.executeActions(action, { await manager.executeActions(action, {
@@ -318,4 +292,74 @@ describe('ActionsManager', () => {
triggerData, 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', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should stop actions', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
const promise = manager.executeActions([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'sleep',
duration: {
m: 1,
},
},
createLogAction('Hello, world!'),
]);
// Stop inflight actions.
await manager.uninitialize();
// Advance timers (causes the sleep to end).
vi.runOnlyPendingTimers();
await promise;
// Action set will not continue.
expect(consoleSpy).not.toBeCalled();
});
});
}); });
@@ -1,8 +1,22 @@
import { it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { BaseAction } from '../../../../src/card-controller/actions/actions/base'; 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 () => { describe('should handle base action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeAll(() => {
vi.stubGlobal('confirm', vi.fn());
});
afterAll(() => {
vi.unstubAllGlobals();
});
it('should construct', async () => {
const api = createCardAPI(); const api = createCardAPI();
const action = new BaseAction( const action = new BaseAction(
{}, {},
@@ -16,4 +30,143 @@ it('should construct', async () => {
// These methods have no observable effect on the base class, so this test is // These methods have no observable effect on the base class, so this test is
// currently only providing coverage and proof of no exceptions! // 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');
});
});
}); });
@@ -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.
});
});
@@ -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 },
}),
);
});
});
@@ -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' },
);
});
});
@@ -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();
});
});
@@ -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 },
}),
);
});
});
@@ -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.
});
@@ -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.
});
});
@@ -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();
});
});
@@ -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');
});
});
+27 -20
View File
@@ -1,12 +1,13 @@
import { describe, expect, it, vi } from 'vitest'; 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 { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select';
import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui'; 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 { DefaultAction } from '../../../src/card-controller/actions/actions/default';
import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select'; import { DisplayModeSelectAction } from '../../../src/card-controller/actions/actions/display-mode-select';
import { DownloadAction } from '../../../src/card-controller/actions/actions/download'; import { DownloadAction } from '../../../src/card-controller/actions/actions/download';
import { ExpandAction } from '../../../src/card-controller/actions/actions/expand'; import { ExpandAction } from '../../../src/card-controller/actions/actions/expand';
import { FullscreenAction } from '../../../src/card-controller/actions/actions/fullscreen'; 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 { InternalCallbackAction } from '../../../src/card-controller/actions/actions/internal-callback';
import { LogAction } from '../../../src/card-controller/actions/actions/log'; import { LogAction } from '../../../src/card-controller/actions/actions/log';
import { MediaPlayerAction } from '../../../src/card-controller/actions/actions/media-player'; 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 { MicrophoneDisconnectAction } from '../../../src/card-controller/actions/actions/microphone-disconnect';
import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute'; import { MicrophoneMuteAction } from '../../../src/card-controller/actions/actions/microphone-mute';
import { MicrophoneUnmuteAction } from '../../../src/card-controller/actions/actions/microphone-unmute'; 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 { 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 { 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 { PlayAction } from '../../../src/card-controller/actions/actions/play';
import { PTZAction } from '../../../src/card-controller/actions/actions/ptz'; import { PTZAction } from '../../../src/card-controller/actions/actions/ptz';
import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls'; 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 { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on'; import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on';
import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select'; 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 { 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 { ViewAction } from '../../../src/card-controller/actions/actions/view';
import { ActionFactory } from '../../../src/card-controller/actions/factory'; import { ActionFactory } from '../../../src/card-controller/actions/factory';
import { import { ActionConfig, INTERNAL_CALLBACK_ACTION } from '../../../src/config/types';
AdvancedCameraCardCustomAction,
INTERNAL_CALLBACK_ACTION,
} from '../../../src/config/types';
// @vitest-environment jsdom // @vitest-environment jsdom
describe('ActionFactory', () => { describe('ActionFactory', () => {
@@ -55,23 +59,26 @@ describe('ActionFactory', () => {
).toBeNull(); ).toBeNull();
}); });
describe('generic', () => { describe('stock actions', () => {
it('non advanced camera card action', () => { 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(); const factory = new ActionFactory();
expect(factory.createAction({}, { action: 'fire-dom-event' })).toBeInstanceOf( expect(factory.createAction({}, action)).toBeInstanceOf(classObject);
GenericAction,
);
});
it('non fire-dom-event', () => {
const factory = new ActionFactory();
expect(factory.createAction({}, { action: 'more-info' })).toBeInstanceOf(
GenericAction,
);
}); });
}); });
describe('actions', () => { describe('custom actions', () => {
it.each([ it.each([
[{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction], [{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction],
[{ advanced_camera_card_action: 'camera_ui' as const }, CameraUIAction], [{ advanced_camera_card_action: 'camera_ui' as const }, CameraUIAction],
@@ -178,10 +185,10 @@ describe('ActionFactory', () => {
], ],
])( ])(
'advanced_camera_card_action: $advanced_camera_card_action', 'advanced_camera_card_action: $advanced_camera_card_action',
(action: Partial<AdvancedCameraCardCustomAction>, classObject: object) => { (action: Partial<ActionConfig>, classObject: object) => {
const factory = new ActionFactory(); const factory = new ActionFactory();
expect( expect(
factory.createAction({}, { action: 'fire-dom-event', ...action }), factory.createAction({}, { ...action, action: 'fire-dom-event' }),
).toBeInstanceOf(classObject); ).toBeInstanceOf(classObject);
}, },
); );
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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 { AutomationsManager } from '../../src/card-controller/automations-manager.js';
import { ConditionStateManager } from '../../src/conditions/state-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'; import { createCardAPI } from '../test-utils.js';
describe('AutomationsManager', () => { describe('AutomationsManager', () => {
@@ -151,9 +150,9 @@ describe('AutomationsManager', () => {
vi.mocked(api.getActionsManager().executeActions).mockImplementation( vi.mocked(api.getActionsManager().executeActions).mockImplementation(
async ( async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
_action: ActionType | ActionType[], _action: ActionConfig | ActionConfig[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
_config?: AuxillaryActionConfig, _options?: unknown,
): Promise<void> => { ): Promise<void> => {
fullscreen = !fullscreen; fullscreen = !fullscreen;
stateManager.setState({ fullscreen: fullscreen }); stateManager.setState({ fullscreen: fullscreen });
@@ -14,7 +14,6 @@ import {
describe('CardElementManager', () => { describe('CardElementManager', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
global.window.location = mock<Location>();
}); });
it('should get element', () => { it('should get element', () => {
@@ -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 { mock } from 'vitest-mock-extended';
import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select'; 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 setQueryString = (qs: string): void => {
const location: Location = mock<Location>(); const location: Location = mock<Location>();
location.search = qs; location.search = qs;
global.window.location = location;
vi.spyOn(window, 'location', 'get').mockReturnValue(location);
}; };
// @vitest-environment jsdom // @vitest-environment jsdom
describe('QueryStringManager', () => { describe('QueryStringManager', () => {
beforeEach(() => { afterEach(() => {
global.window.location = mock<Location>(); vi.restoreAllMocks();
}); });
it('should reject malformed query string', async () => { it('should reject malformed query string', async () => {
+6 -6
View File
@@ -2,16 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MenuController } from '../../src/components-lib/menu-controller.js'; import { MenuController } from '../../src/components-lib/menu-controller.js';
import { SubmenuItem } from '../../src/components/submenu/types.js'; import { SubmenuItem } from '../../src/components/submenu/types.js';
import { MenuConfig, menuConfigSchema } from '../../src/config/types.js'; import { MenuConfig, menuConfigSchema } from '../../src/config/types.js';
import { handleActionConfig } from '../../src/ha/handle-action.js';
import { import {
createInteractionActionEvent, createInteractionActionEvent,
createLitElement, createLitElement,
createSubmenuInteractionActionEvent, createSubmenuInteractionActionEvent,
} from '../test-utils'; } from '../test-utils';
vi.mock('../../src/ha/handle-action.js');
vi.mock('../../src/utils/ha');
const createMenuConfig = (config: unknown): MenuConfig => { const createMenuConfig = (config: unknown): MenuConfig => {
return menuConfigSchema.parse(config); return menuConfigSchema.parse(config);
}; };
@@ -369,9 +365,13 @@ describe('MenuController', () => {
describe('should handle actions', () => { describe('should handle actions', () => {
it('should bail without config', () => { 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')); controller.handleAction(createInteractionActionEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled(); expect(handler).not.toBeCalled();
}); });
it('should execute simple action in non-hidden menu', () => { it('should execute simple action in non-hidden menu', () => {
+4 -4
View File
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
advancedCameraCardConditionSchema,
advancedCameraCardCustomActionsBaseSchema,
cameraConfigSchema, cameraConfigSchema,
conditionalSchema, conditionalSchema,
customSchema, customSchema,
dimensionsConfigSchema, dimensionsConfigSchema,
advancedCameraCardConditionSchema, statusBarActionConfigSchema,
advancedCameraCardCustomActionsBaseSchema,
internalAdvancedCameraCardCustomActionSchema,
} from '../../src/config/types'; } from '../../src/config/types';
import { createConfig } from '../test-utils'; 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);
}); });
}); });
-12
View File
@@ -43,12 +43,10 @@ import { ConditionStateManager } from '../src/conditions/state-manager';
import { import {
AdvancedCameraCardConfig, AdvancedCameraCardConfig,
CameraConfig, CameraConfig,
InternalAdvancedCameraCardCustomAction,
PerformanceConfig, PerformanceConfig,
RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfig,
advancedCameraCardConfigSchema, advancedCameraCardConfigSchema,
cameraConfigSchema, cameraConfigSchema,
internalAdvancedCameraCardCustomActionSchema,
performanceConfigSchema, performanceConfigSchema,
} from '../src/config/types'; } from '../src/config/types';
import { CurrentUser, HomeAssistant } from '../src/ha/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 { MediaQueriesResults } from '../src/view/media-queries-results';
import { View, ViewParameters } from '../src/view/view'; import { View, ViewParameters } from '../src/view/view';
export const createAction = (
action: Record<string, unknown>,
): 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 => { export const createCameraConfig = (config?: unknown): CameraConfig => {
return cameraConfigSchema.parse(config ?? {}); return cameraConfigSchema.parse(config ?? {});
}; };
+19 -40
View File
@@ -1,9 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { actionSchema, INTERNAL_CALLBACK_ACTION } from '../../src/config/types.js'; import { ActionConfig, INTERNAL_CALLBACK_ACTION } from '../../src/config/types.js';
import { hasAction as customCardHasAction } from '../../src/ha/has-action.js';
import { import {
convertActionToCardCustomAction,
createCameraAction, createCameraAction,
createDisplayModeAction, createDisplayModeAction,
createGeneralAction, createGeneralAction,
@@ -21,30 +19,6 @@ import {
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
} from '../../src/utils/action.js'; } 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', () => { describe('createGeneralAction', () => {
it('should create general action', () => { it('should create general action', () => {
expect( expect(
@@ -293,10 +267,7 @@ describe('createPerformAction', () => {
}); });
describe('getActionConfigGivenAction', () => { describe('getActionConfigGivenAction', () => {
const action = actionSchema.parse({ const action = createViewAction('clips');
action: 'fire-dom-event',
advanced_camera_card_action: 'clips',
});
it('should not handle undefined arguments', () => { it('should not handle undefined arguments', () => {
expect(getActionConfigGivenAction()).toBeNull(); expect(getActionConfigGivenAction()).toBeNull();
@@ -348,21 +319,29 @@ describe('getActionConfigGivenAction', () => {
}); });
describe('hasAction', () => { describe('hasAction', () => {
const action = actionSchema.parse({ const realAction = createViewAction('clips');
action: 'toggle', const noneAction: ActionConfig = {
}); action: 'none',
};
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should handle non-array case', () => { it('should return true for real action', () => {
expect(hasAction(action)).toBeFalsy(); expect(hasAction(realAction)).toBeTruthy();
expect(customCardHasAction).toBeCalledTimes(1);
}); });
it('should handle array case', () => {
expect(hasAction([action, action, action])).toBeFalsy(); it('should return false for none action', () => {
expect(customCardHasAction).toBeCalledTimes(3); 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();
}); });
}); });
+3
View File
@@ -54,6 +54,9 @@ describe('arrayify', () => {
const data = [1, 2, 3]; const data = [1, 2, 3];
expect(arrayify(data)).toBe(data); expect(arrayify(data)).toBe(data);
}); });
it('should handle undefined', () => {
expect(arrayify()).toEqual([]);
});
}); });
describe('setify', () => { describe('setify', () => {
+10 -4
View File
@@ -18,13 +18,13 @@ const media = new ViewMedia('clip', 'camera.office');
describe('downloadURL', () => { describe('downloadURL', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
global.window.location = mock<Location>();
}); });
it('should download same origin via link', () => { it('should download same origin via link', () => {
const location: Location & { origin: string } = mock<Location>(); const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo'; location.origin = 'http://foo';
global.window.location = location;
vi.spyOn(window, 'location', 'get').mockReturnValue(location);
const link = document.createElement('a'); const link = document.createElement('a');
link.click = vi.fn(); link.click = vi.fn();
@@ -55,7 +55,8 @@ describe('downloadURL', () => {
// Set the origin to the same. // Set the origin to the same.
const location: Location & { origin: string } = mock<Location>(); const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo'; location.origin = 'http://foo';
global.window.location = location;
vi.spyOn(window, 'location', 'get').mockReturnValue(location);
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
@@ -66,8 +67,13 @@ describe('downloadURL', () => {
describe('downloadMedia', () => { describe('downloadMedia', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(window, 'location', 'get').mockReturnValue(
mock<Location>({ origin: 'https://foo' }),
);
});
afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
global.window.location = mock<Location>({ origin: 'https://foo' });
}); });
it('should throw error when no media', () => { it('should throw error when no media', () => {
+1 -1
View File
@@ -5,7 +5,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"lib": ["es2021", "dom", "dom.iterable"], "lib": ["es2021", "dom", "dom.iterable"],
"noEmit": true, "noEmit": true,
"noErrorTruncation": true, "noErrorTruncation": false,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
+1
View File
@@ -16,6 +16,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
'conditions/**/*.ts', 'conditions/**/*.ts',
'config/**/*.ts', 'config/**/*.ts',
'const.ts', 'const.ts',
'ha/**/*.ts',
'types.ts', 'types.ts',
'utils/action.ts', 'utils/action.ts',
'utils/audio.ts', 'utils/audio.ts',