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