Add initial keyboard shortcut support.
This commit is contained in:
@@ -92,6 +92,16 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface {
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const endTap = (_ev: Event): void => {
|
||||
this.holdTimer.stop();
|
||||
|
||||
if (this.started) {
|
||||
this.started = false;
|
||||
fireEvent(element, 'action', { action: 'end_tap' });
|
||||
}
|
||||
};
|
||||
|
||||
const end = (ev: Event): void => {
|
||||
const options = element.actionHandlerOptions;
|
||||
if (!options?.allowPropagation) {
|
||||
@@ -109,10 +119,7 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
this.holdTimer.stop();
|
||||
|
||||
this.started = false;
|
||||
fireEvent(element, 'action', { action: 'end_tap' });
|
||||
endTap(ev);
|
||||
|
||||
if (options?.hasHold && this.held) {
|
||||
fireEvent(element, 'action', { action: 'hold' });
|
||||
@@ -147,6 +154,9 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface {
|
||||
element.addEventListener('click', end);
|
||||
|
||||
element.addEventListener('keyup', handleEnter);
|
||||
|
||||
// If the mouse leaves the element, this is considered the end of the interaction.
|
||||
element.addEventListener('mouseleave', endTap);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { BrowseMediaCamera } from './camera';
|
||||
import { BrowseMediaViewMediaFactory } from './media';
|
||||
import { BrowseMediaMetadata } from './types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
/**
|
||||
* A utility method to determine if a browse media object matches against a
|
||||
@@ -154,6 +155,7 @@ export class BrowseMediaCameraManagerEngine
|
||||
seek: false,
|
||||
snapshots: true,
|
||||
substream: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
subscribeToTrigger,
|
||||
} from '../utils/ha';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { Capabilities } from './capabilities';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
import { CameraEventCallback } from './types';
|
||||
import { Capabilities } from './capabilities';
|
||||
|
||||
type DestroyCallback = () => Promise<void>;
|
||||
|
||||
|
||||
@@ -54,6 +54,18 @@ export class Capabilities {
|
||||
return this._capabilities.ptz ?? null;
|
||||
}
|
||||
|
||||
public hasPTZCapability(): boolean {
|
||||
return !!(
|
||||
this._capabilities.ptz?.down?.length ||
|
||||
this._capabilities.ptz?.up?.length ||
|
||||
this._capabilities.ptz?.left?.length ||
|
||||
this._capabilities.ptz?.right?.length ||
|
||||
this._capabilities.ptz?.zoomIn?.length ||
|
||||
this._capabilities.ptz?.zoomOut?.length ||
|
||||
this._capabilities.ptz?.presets?.length
|
||||
);
|
||||
}
|
||||
|
||||
public getRawCapabilities(): CapabilitiesRaw {
|
||||
return this._capabilities;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../config/types';
|
||||
import { CameraConfig, ActionPhase } from '../config/types';
|
||||
import { ExtendedHomeAssistant } from '../types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../view/media';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from './types';
|
||||
import { PTZAction } from '../config/ptz';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
|
||||
@@ -139,7 +140,7 @@ export interface CameraManagerEngine {
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
phase?: PTZPhase;
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
|
||||
@@ -3,7 +3,10 @@ import uniq from 'lodash-es/uniq';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import {
|
||||
errorToConsole,
|
||||
recursivelyMergeObjectsConcatenatingArraysUniquely,
|
||||
} from '../../utils/basic';
|
||||
import { subscribeToTrigger } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||
@@ -13,6 +16,7 @@ import { CameraInitializationError } from '../error';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getPTZInfo } from './requests';
|
||||
import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
const CAMERA_BIRDSEYE = 'birdseye' as const;
|
||||
|
||||
@@ -97,7 +101,18 @@ export class FrigateCamera extends Camera {
|
||||
|
||||
protected async _initializeCapabilities(hass: HomeAssistant): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
const ptz = await this._getPTZCapabilities(hass, config);
|
||||
|
||||
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
|
||||
const frigatePTZCapabilities = await this._getPTZCapabilities(hass, config);
|
||||
const combinedPTZCapabilities =
|
||||
configPTZCapabilities || frigatePTZCapabilities
|
||||
? recursivelyMergeObjectsConcatenatingArraysUniquely(
|
||||
{},
|
||||
configPTZCapabilities,
|
||||
frigatePTZCapabilities,
|
||||
)
|
||||
: null;
|
||||
|
||||
const birdseye = isBirdseye(config);
|
||||
this._capabilities = new Capabilities(
|
||||
{
|
||||
@@ -110,7 +125,7 @@ export class FrigateCamera extends Camera {
|
||||
live: true,
|
||||
menu: true,
|
||||
substream: true,
|
||||
...(ptz && { ptz: ptz }),
|
||||
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
|
||||
},
|
||||
{
|
||||
disable: config.capabilities?.disable,
|
||||
@@ -153,20 +168,25 @@ export class FrigateCamera extends Camera {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: The Frigate integration only supports continuous PTZ movements
|
||||
// (regardless of the actual underlying camera capability).
|
||||
const panTilt: PTZMovementType[] = [
|
||||
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []),
|
||||
...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []),
|
||||
];
|
||||
const zoom: PTZMovementType[] = [
|
||||
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []),
|
||||
...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []),
|
||||
];
|
||||
const presets = ptzInfo.presets;
|
||||
|
||||
if (panTilt.length || zoom.length || presets?.length) {
|
||||
return {
|
||||
...(panTilt && { panTilt: panTilt }),
|
||||
...(zoom && { zoom: zoom }),
|
||||
...(panTilt && {
|
||||
left: panTilt,
|
||||
right: panTilt,
|
||||
up: panTilt,
|
||||
down: panTilt,
|
||||
}),
|
||||
...(zoom && { zoomIn: zoom, zoomOut: zoom }),
|
||||
...(presets && { presets: presets }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
|
||||
import { PTZAction } from '../../config/ptz';
|
||||
import { ActionPhase, CameraConfig } from '../../config/types';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import {
|
||||
allPromises,
|
||||
@@ -19,8 +20,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import {
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
CameraManagerEngine,
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
} from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { DateRange } from '../range';
|
||||
@@ -61,12 +62,12 @@ import { FrigateCamera, isBirdseye } from './camera';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import {
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
getEventSummary,
|
||||
getEvents,
|
||||
getEventSummary,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
retainEvent,
|
||||
} from './requests';
|
||||
import {
|
||||
@@ -1015,7 +1016,7 @@ export class FrigateCameraManagerEngine
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
phase?: PTZPhase;
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import { PTZAction, PTZ_PAN_TILT_ACTIONS, PTZ_ZOOM_ACTIONS } from '../../config/ptz';
|
||||
import { ActionPhase, CameraConfig } from '../../config/types';
|
||||
import { ExtendedHomeAssistant, PTZCapabilities, PTZMovementType } from '../../types';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
protected _eventCallback?: CameraEventCallback;
|
||||
@@ -64,6 +66,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
@@ -222,7 +225,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
||||
_cameraConfig: CameraConfig,
|
||||
_action: PTZAction,
|
||||
_options?: {
|
||||
phase?: PTZPhase;
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
|
||||
@@ -3,7 +3,8 @@ import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import sum from 'lodash-es/sum';
|
||||
import PQueue from 'p-queue';
|
||||
import { CardCameraAPI } from '../card-controller/types.js';
|
||||
import { CameraConfig, CamerasConfig, PTZAction, PTZPhase } from '../config/types.js';
|
||||
import { PTZAction } from '../config/ptz.js';
|
||||
import { ActionPhase, CameraConfig, CamerasConfig } from '../config/types.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '../utils/basic.js';
|
||||
import { getCameraID } from '../utils/camera.js';
|
||||
import { log } from '../utils/debug.js';
|
||||
import { getConfiguredPTZAction } from './utils/ptz.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import { Capabilities } from './capabilities.js';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
@@ -771,18 +773,26 @@ export class CameraManager {
|
||||
public async executePTZAction(
|
||||
cameraID: string,
|
||||
action: PTZAction,
|
||||
options: {
|
||||
phase?: PTZPhase;
|
||||
options?: {
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
const engine = this._store.getEngineForCameraID(cameraID);
|
||||
const cameraConfig = this._store.getCameraConfig(cameraID);
|
||||
|
||||
if (!engine || !cameraConfig || !hass) {
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
return engine.executePTZAction(hass, cameraConfig, action, options);
|
||||
const configuredAction = getConfiguredPTZAction(cameraConfig, action, options);
|
||||
if (configuredAction) {
|
||||
return await this._api.getActionsManager().executeActions(configuredAction);
|
||||
}
|
||||
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
const engine = this._store.getEngineForCameraID(cameraID);
|
||||
|
||||
if (!engine || !hass) {
|
||||
return;
|
||||
}
|
||||
return await engine.executePTZAction(hass, cameraConfig, action, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { PTZAction, PTZBaseAction } from '../../config/ptz';
|
||||
import { ActionPhase, ActionType, CameraConfig } from '../../config/types';
|
||||
import { PTZCapabilities, PTZMovementType } from '../../types';
|
||||
|
||||
export const getConfiguredPTZAction = (
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZAction,
|
||||
options?: {
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): ActionType | ActionType[] | null => {
|
||||
if (action === 'preset') {
|
||||
return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null;
|
||||
}
|
||||
|
||||
if (options?.phase) {
|
||||
return cameraConfig.ptz[`actions_${action}_${options.phase}`] ?? null;
|
||||
}
|
||||
|
||||
return cameraConfig.ptz[`actions_${action}`] ?? null;
|
||||
};
|
||||
|
||||
const hasConfiguredPTZAction = (
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZBaseAction,
|
||||
options?: {
|
||||
phase?: ActionPhase;
|
||||
preset?: string;
|
||||
},
|
||||
): boolean => {
|
||||
return !!getConfiguredPTZAction(cameraConfig, action, options);
|
||||
};
|
||||
|
||||
export const getConfiguredPTZMovementType = (
|
||||
cameraConfig: CameraConfig,
|
||||
action: PTZBaseAction,
|
||||
): PTZMovementType[] | null => {
|
||||
const continuous =
|
||||
hasConfiguredPTZAction(cameraConfig, action, { phase: 'start' }) &&
|
||||
hasConfiguredPTZAction(cameraConfig, action, { phase: 'stop' });
|
||||
const relative = hasConfiguredPTZAction(cameraConfig, action);
|
||||
|
||||
return continuous || relative
|
||||
? [
|
||||
...(continuous ? ['continuous' as const] : []),
|
||||
...(relative ? ['relative' as const] : []),
|
||||
]
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getPTZCapabilitiesFromCameraConfig = (
|
||||
cameraConfig: CameraConfig,
|
||||
): PTZCapabilities | null => {
|
||||
const left = getConfiguredPTZMovementType(cameraConfig, 'left');
|
||||
const right = getConfiguredPTZMovementType(cameraConfig, 'right');
|
||||
const up = getConfiguredPTZMovementType(cameraConfig, 'up');
|
||||
const down = getConfiguredPTZMovementType(cameraConfig, 'down');
|
||||
const zoomIn = getConfiguredPTZMovementType(cameraConfig, 'zoom_in');
|
||||
const zoomOut = getConfiguredPTZMovementType(cameraConfig, 'zoom_out');
|
||||
const presets = cameraConfig.ptz.presets
|
||||
? Object.keys(cameraConfig.ptz.presets)
|
||||
: undefined;
|
||||
|
||||
return left?.length ||
|
||||
right?.length ||
|
||||
up?.length ||
|
||||
down?.length ||
|
||||
zoomIn?.length ||
|
||||
zoomOut?.length ||
|
||||
presets?.length
|
||||
? {
|
||||
left: left ?? undefined,
|
||||
right: right ?? undefined,
|
||||
up: up ?? undefined,
|
||||
down: down ?? undefined,
|
||||
zoomIn: zoomIn ?? undefined,
|
||||
zoomOut: zoomOut ?? undefined,
|
||||
presets: presets,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
@@ -1,285 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Actions,
|
||||
ActionsConfig,
|
||||
ActionType,
|
||||
FrigateCardCustomAction,
|
||||
} from '../config/types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
frigateCardHandleAction,
|
||||
frigateCardHandleActionConfig,
|
||||
getActionConfigGivenAction,
|
||||
} from '../utils/action.js';
|
||||
import { getStreamCameraID } from '../utils/substream.js';
|
||||
import { generateViewContextForZoomChange } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { CardActionsManagerAPI } from './types.js';
|
||||
|
||||
const interactionSchema = z.object({
|
||||
action: z.enum(['tap', 'double_tap', 'hold', 'start_tap', 'end_tap']),
|
||||
});
|
||||
export type Interaction = z.infer<typeof interactionSchema>;
|
||||
|
||||
const interactionEventSchema = z.object({
|
||||
detail: interactionSchema,
|
||||
});
|
||||
|
||||
export class ActionsManager {
|
||||
protected _api: CardActionsManagerAPI;
|
||||
|
||||
constructor(api: CardActionsManagerAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge card-wide and view-specific actions.
|
||||
* @returns A combined set of action.
|
||||
*/
|
||||
public getMergedActions(): ActionsConfig {
|
||||
const view = this._api.getViewManager().getView();
|
||||
if (this._api.getMessageManager().hasMessage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
let specificActions: Actions | undefined = undefined;
|
||||
if (view?.is('live')) {
|
||||
specificActions = config?.live.actions;
|
||||
} else if (view?.isGalleryView()) {
|
||||
specificActions = config?.media_gallery?.actions;
|
||||
} else if (view?.isViewerView()) {
|
||||
specificActions = config?.media_viewer.actions;
|
||||
} else if (view?.is('image')) {
|
||||
specificActions = config?.image?.actions;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return { ...config?.view.actions, ...specificActions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an human interaction called on an element (e.g. 'tap').
|
||||
*/
|
||||
public handleInteractionEvent = (ev: Event): void => {
|
||||
const result = interactionEventSchema.safeParse(ev);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
const interaction = result.data.detail.action;
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
const config = this.getMergedActions();
|
||||
const actionConfig = getActionConfigGivenAction(interaction, config);
|
||||
if (
|
||||
hass &&
|
||||
config &&
|
||||
interaction &&
|
||||
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
|
||||
// action defined (as it uses a default that is unhelpful for views that
|
||||
// have default tap/click actions).
|
||||
actionConfig
|
||||
) {
|
||||
frigateCardHandleActionConfig(
|
||||
this._api.getCardElementManager().getElement(),
|
||||
hass,
|
||||
config,
|
||||
interaction,
|
||||
actionConfig,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public handleActionEvent = (ev: Event): void => {
|
||||
if (!('detail' in ev)) {
|
||||
// The event may not be a CustomEvent object, see:
|
||||
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
|
||||
return;
|
||||
}
|
||||
|
||||
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
|
||||
if (frigateCardAction) {
|
||||
this.executeFrigateAction(frigateCardAction);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Small convenience method to call frigateCardHandleAction without the caller
|
||||
* needing hass or the element.
|
||||
*/
|
||||
public executeActions(actions: ActionType | ActionType[]): void {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
if (!hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
frigateCardHandleAction(
|
||||
this._api.getCardElementManager().getElement(),
|
||||
hass,
|
||||
{},
|
||||
actions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a card action.
|
||||
* @param frigateCardAction
|
||||
* @returns `true` if an action is executed.
|
||||
*/
|
||||
public async executeFrigateAction(
|
||||
frigateCardAction: FrigateCardCustomAction,
|
||||
): Promise<void> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager();
|
||||
|
||||
if (
|
||||
// Command not intended for this card (e.g. query string command).
|
||||
frigateCardAction.card_id &&
|
||||
config?.card_id !== frigateCardAction.card_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: This function needs to process (view-related) commands even when
|
||||
// _view has not yet been initialized (since it may be used to set a view
|
||||
// via the querystring).
|
||||
const view = this._api.getViewManager().getView();
|
||||
|
||||
const action = frigateCardAction.frigate_card_action;
|
||||
|
||||
switch (action) {
|
||||
case 'default':
|
||||
this._api.getViewManager().setViewDefault();
|
||||
break;
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'image':
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
viewName: action,
|
||||
cameraID: view?.camera,
|
||||
});
|
||||
break;
|
||||
case 'download':
|
||||
await this._api.getDownloadManager().downloadViewerMedia();
|
||||
break;
|
||||
case 'camera_ui':
|
||||
this._api.getCameraURLManager().openURL();
|
||||
break;
|
||||
case 'expand':
|
||||
this._api.getExpandManager().toggleExpanded();
|
||||
break;
|
||||
case 'fullscreen':
|
||||
this._api.getFullscreenManager().toggleFullscreen();
|
||||
break;
|
||||
case 'menu_toggle':
|
||||
// This is a rare code path: this would only be used if someone has a
|
||||
// menu toggle action configured outside of the menu itself (e.g.
|
||||
// picture elements).
|
||||
this._api.getCardElementManager().toggleMenu();
|
||||
break;
|
||||
case 'camera_select':
|
||||
const selectCameraID =
|
||||
frigateCardAction.camera ??
|
||||
(frigateCardAction.triggered
|
||||
? this._api.getTriggersManager().getMostRecentlyTriggeredCameraID()
|
||||
: null);
|
||||
if (selectCameraID && view) {
|
||||
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
||||
const targetViewName =
|
||||
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
||||
this._api.getViewManager().setViewByParameters({
|
||||
viewName: targetViewName,
|
||||
cameraID: selectCameraID,
|
||||
failSafe: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'live_substream_select': {
|
||||
this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera);
|
||||
break;
|
||||
}
|
||||
case 'live_substream_off': {
|
||||
this._api.getViewManager().setViewWithoutSubstream();
|
||||
break;
|
||||
}
|
||||
case 'live_substream_on': {
|
||||
this._api.getViewManager().setViewWithSubstream();
|
||||
break;
|
||||
}
|
||||
case 'media_player':
|
||||
const mediaPlayer = frigateCardAction.media_player;
|
||||
const mediaPlayerController = this._api.getMediaPlayerManager();
|
||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
||||
|
||||
if (frigateCardAction.media_player_action === 'stop') {
|
||||
await mediaPlayerController.stop(mediaPlayer);
|
||||
} else if (view?.is('live')) {
|
||||
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
|
||||
} else if (view?.isViewerView() && media) {
|
||||
await mediaPlayerController.playMedia(mediaPlayer, media);
|
||||
}
|
||||
break;
|
||||
case 'diagnostics':
|
||||
this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' });
|
||||
break;
|
||||
case 'microphone_mute':
|
||||
this._api.getMicrophoneManager().mute();
|
||||
break;
|
||||
case 'microphone_unmute':
|
||||
await this._api.getMicrophoneManager().unmute();
|
||||
break;
|
||||
case 'mute':
|
||||
await mediaLoadedInfoManager.get()?.player?.mute();
|
||||
break;
|
||||
case 'unmute':
|
||||
await mediaLoadedInfoManager.get()?.player?.unmute();
|
||||
break;
|
||||
case 'play':
|
||||
await mediaLoadedInfoManager.get()?.player?.play();
|
||||
break;
|
||||
case 'pause':
|
||||
await mediaLoadedInfoManager.get()?.player?.pause();
|
||||
break;
|
||||
case 'screenshot':
|
||||
await this._api.getDownloadManager().downloadScreenshot();
|
||||
break;
|
||||
case 'display_mode_select':
|
||||
this._api
|
||||
.getViewManager()
|
||||
.setViewWithNewDisplayMode(frigateCardAction.display_mode);
|
||||
break;
|
||||
case 'ptz':
|
||||
const cameraID = this._api.getViewManager().getView()?.camera;
|
||||
if (cameraID) {
|
||||
this._api
|
||||
.getCameraManager()
|
||||
.executePTZAction(cameraID, frigateCardAction.ptz_action, {
|
||||
phase: frigateCardAction.ptz_phase,
|
||||
preset: frigateCardAction.ptz_preset,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'show_ptz':
|
||||
this._api.getViewManager().setViewWithMergedContext({
|
||||
live: { ptzVisible: frigateCardAction.show_ptz },
|
||||
});
|
||||
break;
|
||||
case 'change_zoom':
|
||||
this._api.getViewManager().setViewWithMergedContext(
|
||||
generateViewContextForZoomChange(frigateCardAction.target_id, {
|
||||
zoom: {
|
||||
pan: frigateCardAction.pan,
|
||||
zoom: frigateCardAction.zoom,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from 'zod';
|
||||
import { Actions, ActionsConfig, ActionType } from '../../config/types.js';
|
||||
import { getActionConfigGivenAction } from '../../utils/action.js';
|
||||
import { ActionSet } from './actions/set.js';
|
||||
import { CardActionsManagerAPI } from '../types.js';
|
||||
import { ActionExecutionRequest, AuxillaryActionConfig } from './types.js';
|
||||
import { ActionContext } from 'action';
|
||||
|
||||
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
|
||||
export type InteractionName = (typeof INTERACTIONS)[number];
|
||||
|
||||
const interactionSchema = z.object({
|
||||
action: z.enum(INTERACTIONS),
|
||||
});
|
||||
export type Interaction = z.infer<typeof interactionSchema>;
|
||||
|
||||
const interactionEventSchema = z.object({
|
||||
detail: interactionSchema,
|
||||
});
|
||||
|
||||
export class ActionsManager {
|
||||
protected _api: CardActionsManagerAPI;
|
||||
protected _actionsInFlight: ActionSet[] = [];
|
||||
protected _actionContext: ActionContext = {};
|
||||
|
||||
constructor(api: CardActionsManagerAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge card-wide and view-specific actions.
|
||||
* @returns A combined set of action.
|
||||
*/
|
||||
public getMergedActions(): ActionsConfig {
|
||||
const view = this._api.getViewManager().getView();
|
||||
if (this._api.getMessageManager().hasMessage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
let specificActions: Actions | undefined = undefined;
|
||||
if (view?.is('live')) {
|
||||
specificActions = config?.live.actions;
|
||||
} else if (view?.isGalleryView()) {
|
||||
specificActions = config?.media_gallery?.actions;
|
||||
} else if (view?.isViewerView()) {
|
||||
specificActions = config?.media_viewer.actions;
|
||||
} else if (view?.is('image')) {
|
||||
specificActions = config?.image?.actions;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return { ...config?.view.actions, ...specificActions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an human interaction called on an element (e.g. 'tap').
|
||||
*/
|
||||
public handleInteractionEvent = (ev: Event): void => {
|
||||
const result = interactionEventSchema.safeParse(ev);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
const interaction = result.data.detail.action;
|
||||
const config = this.getMergedActions();
|
||||
const actionConfig = getActionConfigGivenAction(interaction, config);
|
||||
if (
|
||||
config &&
|
||||
interaction &&
|
||||
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
|
||||
// action defined (as it uses a default that is unhelpful for views that
|
||||
// have default tap/click actions).
|
||||
actionConfig
|
||||
) {
|
||||
this.executeActions(actionConfig, config);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is called when an ll-custom event is fired. This is used by
|
||||
* cards to fire custom actions. This card itself should not call this, but
|
||||
* embedded elements may.
|
||||
*/
|
||||
public handleCustomActionEvent = (ev: Event): void => {
|
||||
if (!('detail' in ev)) {
|
||||
// The event may not be a CustomEvent object, see:
|
||||
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
|
||||
return;
|
||||
}
|
||||
this.executeActions(ev.detail as ActionType);
|
||||
};
|
||||
|
||||
/**
|
||||
* This method handles actions requested by components of the Frigate card
|
||||
* itself (e.g. menu, PTZ controller).
|
||||
*/
|
||||
public handleActionExecutionRequestEvent = async (
|
||||
ev: CustomEvent<ActionExecutionRequest>,
|
||||
): Promise<void> => {
|
||||
await this.executeActions(ev.detail.action, ev.detail.config);
|
||||
};
|
||||
|
||||
public uninitialize(): void {
|
||||
// If there are any long-running actions, ensure they are stopped.
|
||||
this._actionsInFlight.forEach((actionSet) => actionSet.stop());
|
||||
}
|
||||
|
||||
public async executeActions(
|
||||
action: ActionType | ActionType[],
|
||||
config?: AuxillaryActionConfig,
|
||||
): Promise<void> {
|
||||
const actionSet = new ActionSet(this._actionContext, action, {
|
||||
config: config,
|
||||
cardID: this._api.getConfigManager().getConfig()?.card_id,
|
||||
});
|
||||
|
||||
this._actionsInFlight.push(actionSet);
|
||||
await actionSet.execute(this._api);
|
||||
this._actionsInFlight = this._actionsInFlight.filter((a) => a !== actionSet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ActionContext } from 'action';
|
||||
import { FrigateCardCustomAction } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { Action, AuxillaryActionConfig } from '../types';
|
||||
|
||||
export class BaseAction<T> implements Action {
|
||||
protected _context: ActionContext;
|
||||
protected _action: T;
|
||||
protected _config?: AuxillaryActionConfig;
|
||||
|
||||
constructor(context: ActionContext, action: T, config?: AuxillaryActionConfig) {
|
||||
this._context = context;
|
||||
this._action = action;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async execute(_api: CardActionsAPI): Promise<void> {
|
||||
// Pass.
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
// Pass.
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateCardAction<
|
||||
T extends FrigateCardCustomAction,
|
||||
> extends BaseAction<T> {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CameraSelectActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
const selectCameraID =
|
||||
this._action.camera ??
|
||||
(this._action.triggered
|
||||
? api.getTriggersManager().getMostRecentlyTriggeredCameraID()
|
||||
: null);
|
||||
const view = api.getViewManager().getView();
|
||||
const config = api.getConfigManager().getConfig();
|
||||
|
||||
if (selectCameraID && view) {
|
||||
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
|
||||
const targetViewName =
|
||||
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
|
||||
api.getViewManager().setViewByParameters({
|
||||
viewName: targetViewName,
|
||||
cameraID: selectCameraID,
|
||||
failSafe: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class CameraUIAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getCameraURLManager().openURL();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class DefaultAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DisplayModeActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class DownloadAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getDownloadManager().downloadViewerMedia();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class ExpandAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getExpandManager().toggleExpanded();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class FullscreenAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getFullscreenManager().toggleFullscreen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ActionConfig, handleActionConfig } from '@dermotduffy/custom-card-helpers';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { BaseAction } from './base';
|
||||
|
||||
/**
|
||||
* Handles generic HA (non-Frigate) 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { LogActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class LogAction extends FrigateCardAction<LogActionConfig> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async execute(_api: CardActionsAPI): Promise<void> {
|
||||
console[this._action.level](this._action.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MediaPlayerActionConfig } from '../../../config/types';
|
||||
import { getStreamCameraID } from '../../../utils/substream';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class MediaPlayerAction extends FrigateCardAction<MediaPlayerActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
const mediaPlayer = this._action.media_player;
|
||||
const mediaPlayerController = api.getMediaPlayerManager();
|
||||
const view = api.getViewManager().getView();
|
||||
const media = view?.queryResults?.getSelectedResult() ?? null;
|
||||
|
||||
if (this._action.media_player_action === 'stop') {
|
||||
await mediaPlayerController.stop(mediaPlayer);
|
||||
} else if (view?.is('live')) {
|
||||
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
|
||||
} else if (view?.isViewerView() && media) {
|
||||
await mediaPlayerController.playMedia(mediaPlayer, media);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class MenuToggleAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getCardElementManager().toggleMenu();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class MicrophoneMuteAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getMicrophoneManager().mute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class MicrophoneUnmuteAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getMicrophoneManager().unmute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class MuteAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getMediaLoadedInfoManager().get()?.player?.mute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class PauseAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getMediaLoadedInfoManager().get()?.player?.pause();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class PlayAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getMediaLoadedInfoManager().get()?.player?.play();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PTZControlsActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class PTZControlsAction extends FrigateCardAction<PTZControlsActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewWithMergedContext({
|
||||
ptzControls: { enabled: this._action.enabled },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import clamp from 'lodash-es/clamp';
|
||||
import {
|
||||
PartialZoomSettings,
|
||||
ZOOM_DEFAULT_PAN_X,
|
||||
ZOOM_DEFAULT_PAN_Y,
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
} from '../../../components-lib/zoom/types';
|
||||
import { generateViewContextForZoom } from '../../../components-lib/zoom/zoom-view-context';
|
||||
import { PTZDigitialActionConfig, ZOOM_MAX, ZOOM_MIN } from '../../../config/types';
|
||||
import { getPTZTarget } from '../../../utils/ptz';
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
import { TargetedActionContext } from '../types';
|
||||
import {
|
||||
setInProgressForThisTarget,
|
||||
stopInProgressForThisTarget,
|
||||
} from '../utils/action-state';
|
||||
|
||||
const STEP_DELAY_SECONDS = 0.1;
|
||||
const STEP_ZOOM = 0.1;
|
||||
const STEP_PAN = 5;
|
||||
|
||||
declare module 'action' {
|
||||
interface ActionContext {
|
||||
ptzDigital?: TargetedActionContext;
|
||||
}
|
||||
}
|
||||
|
||||
export class PTZDigitalAction extends FrigateCardAction<PTZDigitialActionConfig> {
|
||||
protected _timer = new Timer();
|
||||
|
||||
protected async _stepChange(api: CardActionsAPI, targetID: string): Promise<void> {
|
||||
api.getViewManager().setViewWithMergedContext(
|
||||
generateViewContextForZoom(targetID, {
|
||||
requested: this._convertActionToZoomSettings(
|
||||
api.getViewManager().getView()?.context?.zoom?.[targetID]?.observed,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID =
|
||||
this._action.target_id ??
|
||||
getPTZTarget(view, { type: 'digital', cameraManager: api.getCameraManager() })
|
||||
?.targetID;
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!this._action.absolute || !this._action.ptz_phase) {
|
||||
return await this._stepChange(api, targetID);
|
||||
}
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (this._action.ptz_phase === 'start') {
|
||||
stopInProgressForThisTarget(targetID, this._context.ptzDigital);
|
||||
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
|
||||
|
||||
await this._stepChange(api, targetID);
|
||||
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
|
||||
this._stepChange(api, targetID),
|
||||
);
|
||||
} else if (this._action.ptz_phase === 'stop') {
|
||||
stopInProgressForThisTarget(targetID, this._context.ptzDigital);
|
||||
delete this._context.ptzDigital?.[targetID];
|
||||
}
|
||||
}
|
||||
|
||||
protected _convertActionToZoomSettings(
|
||||
base?: PartialZoomSettings,
|
||||
): PartialZoomSettings {
|
||||
if (!this._action.absolute && !this._action.ptz_action) {
|
||||
// If neither an absolute position nor an action are specified, the request
|
||||
// is assumed to be to return to default.
|
||||
return {};
|
||||
}
|
||||
|
||||
if (this._action.absolute) {
|
||||
return this._action.absolute;
|
||||
}
|
||||
|
||||
const zoom = base?.zoom ?? ZOOM_DEFAULT_SCALE;
|
||||
const pan = {
|
||||
x: base?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
y: base?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
};
|
||||
|
||||
const zoomDelta =
|
||||
this._action.ptz_action === 'zoom_in'
|
||||
? STEP_ZOOM
|
||||
: this._action.ptz_action === 'zoom_out'
|
||||
? -STEP_ZOOM
|
||||
: 0;
|
||||
const xDelta =
|
||||
this._action.ptz_action === 'left'
|
||||
? -STEP_PAN
|
||||
: this._action.ptz_action === 'right'
|
||||
? STEP_PAN
|
||||
: 0;
|
||||
const yDelta =
|
||||
this._action.ptz_action === 'up'
|
||||
? -STEP_PAN
|
||||
: this._action.ptz_action === 'down'
|
||||
? STEP_PAN
|
||||
: 0;
|
||||
|
||||
return {
|
||||
zoom: clamp(zoom + zoomDelta, ZOOM_MIN, ZOOM_MAX),
|
||||
pan: {
|
||||
x: clamp(pan.x + xDelta, 0, 100),
|
||||
y: clamp(pan.y + yDelta, 0, 100),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PTZMultiActionConfig } from '../../../config/types';
|
||||
import { createPTZAction, createPTZDigitalAction } from '../../../utils/action';
|
||||
import { PTZType, getPTZTarget, hasCameraTruePTZ } from '../../../utils/ptz';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
import { PTZAction } from './ptz';
|
||||
import { PTZDigitalAction } from './ptz-digital';
|
||||
|
||||
export class PTZMultiAction extends FrigateCardAction<PTZMultiActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
const view = api.getViewManager().getView();
|
||||
let targetID: string | null = null;
|
||||
let type: PTZType | null = null;
|
||||
|
||||
if (this._action.target_id) {
|
||||
targetID = this._action.target_id;
|
||||
type = hasCameraTruePTZ(api.getCameraManager(), targetID) ? 'ptz' : 'digital';
|
||||
} else if (view) {
|
||||
const multiTarget = getPTZTarget(view, { cameraManager: api.getCameraManager() });
|
||||
targetID = multiTarget?.targetID ?? null;
|
||||
type = multiTarget?.type ?? null;
|
||||
}
|
||||
|
||||
if (!targetID || type === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(type === 'ptz'
|
||||
? this._toPTZAction(targetID)
|
||||
: this._toPTZDigitalAction(targetID)
|
||||
).execute(api);
|
||||
}
|
||||
|
||||
protected _toPTZAction(targetID: string): PTZAction {
|
||||
return new PTZAction(
|
||||
this._context,
|
||||
createPTZAction({
|
||||
cardID: this._action.card_id,
|
||||
cameraID: targetID,
|
||||
ptzAction: this._action.ptz_action,
|
||||
ptzPhase: this._action.ptz_phase,
|
||||
ptzPreset: this._action.ptz_preset,
|
||||
}),
|
||||
this._config,
|
||||
);
|
||||
}
|
||||
|
||||
protected _toPTZDigitalAction(targetID: string): PTZDigitalAction {
|
||||
return new PTZDigitalAction(
|
||||
this._context,
|
||||
createPTZDigitalAction({
|
||||
cardID: this._action.card_id,
|
||||
ptzPhase: this._action.ptz_phase,
|
||||
ptzAction: this._action.ptz_action,
|
||||
targetID: targetID,
|
||||
}),
|
||||
this._config,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { PTZActionConfig } from '../../../config/types';
|
||||
import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz';
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import {
|
||||
setInProgressForThisTarget,
|
||||
stopInProgressForThisTarget,
|
||||
} from '../utils/action-state';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
interface PTZContext {
|
||||
[cameraID: string]: {
|
||||
inProgressAction?: PTZAction;
|
||||
};
|
||||
}
|
||||
|
||||
declare module 'action' {
|
||||
interface ActionContext {
|
||||
ptz?: PTZContext;
|
||||
}
|
||||
}
|
||||
|
||||
export class PTZAction extends FrigateCardAction<PTZActionConfig> {
|
||||
protected _timer = new Timer();
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
const view = api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ptzCameraID =
|
||||
this._action.camera ??
|
||||
getPTZTarget(view, { type: 'ptz', cameraManager: api.getCameraManager() })
|
||||
?.targetID ??
|
||||
null;
|
||||
const ptzCapabilities = ptzCameraID
|
||||
? api
|
||||
.getCameraManager()
|
||||
.getCameraCapabilities(ptzCameraID)
|
||||
?.getPTZCapabilities()
|
||||
: null;
|
||||
const ptzConfiguration = ptzCameraID
|
||||
? api.getCameraManager().getStore().getCameraConfig(ptzCameraID)?.ptz
|
||||
: null;
|
||||
if (!ptzCameraID || !ptzCapabilities || !ptzConfiguration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._action.ptz_action) {
|
||||
if (ptzCapabilities.presets && ptzCapabilities.presets.length >= 1) {
|
||||
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
|
||||
phase: this._action.ptz_phase,
|
||||
preset: ptzCapabilities.presets[0],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilityKey = ptzActionToCapabilityKey(this._action.ptz_action);
|
||||
if (
|
||||
(capabilityKey &&
|
||||
ptzCapabilities[capabilityKey]?.includes(
|
||||
this._action.ptz_phase ? 'continuous' : 'relative',
|
||||
)) ||
|
||||
this._action.ptz_action === 'preset'
|
||||
) {
|
||||
// Scenario: Camera natively supports requested move type.
|
||||
return await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
phase: this._action.ptz_phase,
|
||||
preset: this._action.ptz_preset,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._action.ptz_phase === 'start') {
|
||||
// Scenario: Asked to start a continuous move, camera only supports relative moves natively.
|
||||
stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
|
||||
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
|
||||
|
||||
const singleStep = async (): Promise<void> => {
|
||||
this._action.ptz_action &&
|
||||
(await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
}));
|
||||
// Only start the timer for the next step after this step returns.
|
||||
this._timer.start(ptzConfiguration.r2c_delay_between_calls_seconds, singleStep);
|
||||
};
|
||||
|
||||
await singleStep();
|
||||
} else if (this._action.ptz_phase === 'stop') {
|
||||
// Scenario: Asked to stop continuous move, camera only supports relative moves natively.
|
||||
stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
|
||||
} else {
|
||||
// Relative move (but camera only supports continuous).
|
||||
await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
phase: 'start',
|
||||
});
|
||||
|
||||
this._timer.start(ptzConfiguration.c2r_delay_between_calls_seconds, async () => {
|
||||
this._action.ptz_action &&
|
||||
(await api
|
||||
.getCameraManager()
|
||||
.executePTZAction(ptzCameraID, this._action.ptz_action, {
|
||||
preset: this._action.ptz_preset,
|
||||
phase: 'stop',
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class ScreenshotAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getDownloadManager().downloadScreenshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ActionContext } from 'action';
|
||||
import { ActionType } from '../../../config/types';
|
||||
import { arrayify } from '../../../utils/basic';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { ActionFactory } from '../factory';
|
||||
import { Action, AuxillaryActionConfig } from '../types';
|
||||
|
||||
export class ActionSet implements Action {
|
||||
protected _context: ActionContext;
|
||||
protected _actions: Action[] = [];
|
||||
protected _factory = new ActionFactory();
|
||||
protected _stopped = false;
|
||||
|
||||
constructor(
|
||||
context: ActionContext,
|
||||
actions: ActionType | ActionType[],
|
||||
options?: {
|
||||
config?: AuxillaryActionConfig;
|
||||
cardID?: string;
|
||||
},
|
||||
) {
|
||||
this._context = context;
|
||||
for (const actionObj of arrayify(actions)) {
|
||||
const action = this._factory.createAction(context, actionObj, options);
|
||||
if (action) {
|
||||
this._actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
for (const action of this._actions) {
|
||||
if (this._stopped) {
|
||||
break;
|
||||
}
|
||||
|
||||
await action.execute(api);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this._stopped = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SleepActionConfig } from '../../../config/types';
|
||||
import { sleep } from '../../../utils/basic';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { timeDeltaToSeconds } from '../utils/time-delta';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class SleepAction extends FrigateCardAction<SleepActionConfig> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async execute(_api: CardActionsAPI): Promise<void> {
|
||||
await sleep(timeDeltaToSeconds(this._action.duration));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewWithoutSubstream();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewWithSubstream();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SubstreamSelectActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewWithSubstream(this._action.camera);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class UnmuteAction extends FrigateCardAction<GeneralActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
await api.getMediaLoadedInfoManager().get()?.player?.unmute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ViewActionConfig } from '../../../config/types';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { FrigateCardAction } from './base';
|
||||
|
||||
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
|
||||
public async execute(api: CardActionsAPI): Promise<void> {
|
||||
api.getViewManager().setViewByParameters({
|
||||
viewName: this._action.frigate_card_action,
|
||||
|
||||
// Note: This function needs to process (view-related) commands even when
|
||||
// _view has not yet been initialized (since it may be used to set a view
|
||||
// via the querystring).
|
||||
cameraID: api.getViewManager().getView()?.camera,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ActionConfig } from '@dermotduffy/custom-card-helpers';
|
||||
import { ActionContext } from 'action';
|
||||
import { ActionType } from '../../config/types';
|
||||
import { convertActionToCardCustomAction } from '../../utils/action';
|
||||
import { CameraSelectAction } from './actions/camera-select';
|
||||
import { CameraUIAction } from './actions/camera-ui';
|
||||
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 { LogAction } from './actions/log';
|
||||
import { MediaPlayerAction } from './actions/media-player';
|
||||
import { MenuToggleAction } from './actions/menu-toggle';
|
||||
import { MicrophoneMuteAction } from './actions/microphone-mute';
|
||||
import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
|
||||
import { MuteAction } from './actions/mute';
|
||||
import { PauseAction } from './actions/pause';
|
||||
import { PlayAction } from './actions/play';
|
||||
import { PTZAction } from './actions/ptz';
|
||||
import { PTZDigitalAction } from './actions/ptz-digital';
|
||||
import { PTZMultiAction } from './actions/ptz-multi';
|
||||
import { ScreenshotAction } from './actions/screenshot';
|
||||
import { PTZControlsAction } from './actions/ptz-controls';
|
||||
import { SleepAction } from './actions/sleep';
|
||||
import { SubstreamOffAction } from './actions/substream-off';
|
||||
import { SubstreamOnAction } from './actions/substream-on';
|
||||
import { SubstreamSelectAction } from './actions/substream-select';
|
||||
import { UnmuteAction } from './actions/unmute';
|
||||
import { ViewAction } from './actions/view';
|
||||
import { Action, AuxillaryActionConfig } from './types';
|
||||
|
||||
export class ActionFactory {
|
||||
public createAction(
|
||||
context: ActionContext,
|
||||
action: ActionType,
|
||||
options?: {
|
||||
config?: AuxillaryActionConfig;
|
||||
cardID?: string;
|
||||
},
|
||||
): Action | null {
|
||||
const frigateCardAction = convertActionToCardCustomAction(action);
|
||||
if (action.action !== 'fire-dom-event' || !frigateCardAction) {
|
||||
// * 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).
|
||||
frigateCardAction.card_id &&
|
||||
frigateCardAction.card_id !== options?.cardID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (frigateCardAction.frigate_card_action) {
|
||||
case 'default':
|
||||
return new DefaultAction(context, frigateCardAction, options?.config);
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'image':
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
case 'diagnostics':
|
||||
return new ViewAction(context, frigateCardAction, options?.config);
|
||||
case 'sleep':
|
||||
return new SleepAction(context, frigateCardAction, options?.config);
|
||||
case 'download':
|
||||
return new DownloadAction(context, frigateCardAction, options?.config);
|
||||
case 'camera_ui':
|
||||
return new CameraUIAction(context, frigateCardAction, options?.config);
|
||||
case 'expand':
|
||||
return new ExpandAction(context, frigateCardAction, options?.config);
|
||||
case 'fullscreen':
|
||||
return new FullscreenAction(context, frigateCardAction, options?.config);
|
||||
case 'menu_toggle':
|
||||
// This is a rare code path: this would only be used if someone has a
|
||||
// menu toggle action configured outside of the menu itself.
|
||||
return new MenuToggleAction(context, frigateCardAction, options?.config);
|
||||
case 'camera_select':
|
||||
return new CameraSelectAction(context, frigateCardAction, options?.config);
|
||||
case 'live_substream_select':
|
||||
return new SubstreamSelectAction(context, frigateCardAction, options?.config);
|
||||
case 'live_substream_off':
|
||||
return new SubstreamOffAction(context, frigateCardAction, options?.config);
|
||||
case 'live_substream_on':
|
||||
return new SubstreamOnAction(context, frigateCardAction, options?.config);
|
||||
case 'media_player':
|
||||
return new MediaPlayerAction(context, frigateCardAction, options?.config);
|
||||
case 'microphone_mute':
|
||||
return new MicrophoneMuteAction(context, frigateCardAction, options?.config);
|
||||
case 'microphone_unmute':
|
||||
return new MicrophoneUnmuteAction(context, frigateCardAction, options?.config);
|
||||
case 'mute':
|
||||
return new MuteAction(context, frigateCardAction, options?.config);
|
||||
case 'unmute':
|
||||
return new UnmuteAction(context, frigateCardAction, options?.config);
|
||||
case 'play':
|
||||
return new PlayAction(context, frigateCardAction, options?.config);
|
||||
case 'pause':
|
||||
return new PauseAction(context, frigateCardAction, options?.config);
|
||||
case 'screenshot':
|
||||
return new ScreenshotAction(context, frigateCardAction, options?.config);
|
||||
case 'display_mode_select':
|
||||
return new DisplayModeSelectAction(context, frigateCardAction, options?.config);
|
||||
case 'ptz':
|
||||
return new PTZAction(context, frigateCardAction, options?.config);
|
||||
case 'ptz_digital':
|
||||
return new PTZDigitalAction(context, frigateCardAction, options?.config);
|
||||
case 'ptz_multi':
|
||||
return new PTZMultiAction(context, frigateCardAction, options?.config);
|
||||
case 'ptz_controls':
|
||||
return new PTZControlsAction(context, frigateCardAction, options?.config);
|
||||
case 'log':
|
||||
return new LogAction(context, frigateCardAction, options?.config);
|
||||
}
|
||||
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
console.warn(
|
||||
`Frigate card received unknown card action: ${frigateCardAction['frigate_card_action']}`,
|
||||
);
|
||||
/* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ActionType } 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;
|
||||
config?: AuxillaryActionConfig;
|
||||
}
|
||||
|
||||
export interface TargetedActionContext {
|
||||
[targetID: string]: {
|
||||
inProgressAction?: Action;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import merge from 'lodash-es/merge';
|
||||
import { Action, TargetedActionContext } from '../types';
|
||||
import { ActionContext } from 'action';
|
||||
|
||||
export const stopInProgressForThisTarget = (
|
||||
targetID: string,
|
||||
context?: TargetedActionContext,
|
||||
): void => {
|
||||
context?.[targetID]?.inProgressAction?.stop();
|
||||
};
|
||||
|
||||
export const setInProgressForThisTarget = (
|
||||
targetID: string,
|
||||
context: ActionContext,
|
||||
contextKey: keyof ActionContext,
|
||||
action: Action,
|
||||
) => {
|
||||
merge(context, {
|
||||
[contextKey]: {
|
||||
[targetID]: {
|
||||
inProgressAction: action,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { dispatchFrigateCardEvent } from '../../../utils/basic';
|
||||
import { ActionExecutionRequest } from '../types';
|
||||
|
||||
export const dispatchActionExecutionRequest = (
|
||||
element: HTMLElement,
|
||||
request: ActionExecutionRequest,
|
||||
) => {
|
||||
dispatchFrigateCardEvent(element, 'action:execution-request', request);
|
||||
};
|
||||
|
||||
export interface ActionExecutionRequestEventTarget extends EventTarget {
|
||||
addEventListener(
|
||||
event: 'frigate-card:action:execution-request',
|
||||
listener: (
|
||||
this: ActionExecutionRequestEventTarget,
|
||||
ev: CustomEvent<ActionExecutionRequest>,
|
||||
) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'frigate-card:action:execution-request',
|
||||
listener: (
|
||||
this: ActionExecutionRequestEventTarget,
|
||||
ev: CustomEvent<ActionExecutionRequest>,
|
||||
) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TimeDelta } from '../../../config/types';
|
||||
|
||||
export const timeDeltaToSeconds = (timeDelta: TimeDelta): number => {
|
||||
return (
|
||||
(timeDelta.h ?? 0) * 3600 +
|
||||
(timeDelta.m ?? 0) * 60 +
|
||||
(timeDelta.s ?? 0) +
|
||||
(timeDelta.ms ?? 0) / 1000
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Automation, AutomationActions, Automations } from '../config/types.js';
|
||||
import { Automation, AutomationActions } from '../config/types.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { frigateCardHandleAction } from '../utils/action.js';
|
||||
import { CardAutomationsAPI } from './types.js';
|
||||
import { CardAutomationsAPI, TaggedAutomations } from './types.js';
|
||||
|
||||
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
|
||||
|
||||
export class AutomationsManager {
|
||||
protected _api: CardAutomationsAPI;
|
||||
|
||||
protected _automations: Automations;
|
||||
protected _automations: TaggedAutomations = [];
|
||||
protected _priorEvaluations: Map<Automation, boolean> = new Map();
|
||||
|
||||
// A counter to avoid infinite loops, increases every time actions are run,
|
||||
@@ -19,10 +18,12 @@ export class AutomationsManager {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public setAutomationsFromConfig() {
|
||||
this._automations = this._api
|
||||
.getConfigManager()
|
||||
.getNonOverriddenConfig()?.automations;
|
||||
public deleteAutomations(tag?: unknown) {
|
||||
this._automations = this._automations.filter((automation) => automation.tag !== tag);
|
||||
}
|
||||
|
||||
public addAutomations(automations: TaggedAutomations): void {
|
||||
this._automations.push(...automations);
|
||||
}
|
||||
|
||||
public execute(): void {
|
||||
@@ -34,8 +35,8 @@ export class AutomationsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionsToRun: AutomationActions[] = [];
|
||||
for (const automation of this._automations ?? []) {
|
||||
const actionsToRun: AutomationActions = [];
|
||||
for (const automation of this._automations) {
|
||||
const shouldExecute = this._api
|
||||
.getConditionsManager()
|
||||
.evaluateConditions(automation.conditions);
|
||||
@@ -43,27 +44,27 @@ export class AutomationsManager {
|
||||
const priorEvaluation = this._priorEvaluations.get(automation);
|
||||
this._priorEvaluations.set(automation, shouldExecute);
|
||||
if (shouldExecute !== priorEvaluation && actions) {
|
||||
actionsToRun.push(actions);
|
||||
actionsToRun.push(...actions);
|
||||
}
|
||||
}
|
||||
|
||||
++this._nestedAutomationExecutions;
|
||||
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
message: localize('error.too_many_automations'),
|
||||
});
|
||||
if (!actionsToRun.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionsToRun.forEach((actions) => {
|
||||
frigateCardHandleAction(
|
||||
this._api.getCardElementManager().getElement(),
|
||||
hass,
|
||||
{},
|
||||
actions,
|
||||
);
|
||||
});
|
||||
--this._nestedAutomationExecutions;
|
||||
const runActions = async (actions: AutomationActions): Promise<void> => {
|
||||
++this._nestedAutomationExecutions;
|
||||
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
message: localize('error.too_many_automations'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this._api.getActionsManager().executeActions(actions);
|
||||
--this._nestedAutomationExecutions;
|
||||
};
|
||||
runActions(actionsToRun);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { setOrRemoveAttribute } from '../utils/basic';
|
||||
import { isCardInPanel } from '../utils/ha';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { CardElementAPI } from './types';
|
||||
import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request';
|
||||
|
||||
export type ScrollCallback = () => void;
|
||||
export type MenuToggleCallback = () => void;
|
||||
|
||||
export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget;
|
||||
export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget & ActionExecutionRequestEventTarget;
|
||||
|
||||
export class CardElementManager {
|
||||
protected _api: CardElementAPI;
|
||||
@@ -62,19 +63,21 @@ export class CardElementManager {
|
||||
this._api.getExpandManager().initialize();
|
||||
this._api.getMediaLoadedInfoManager().initialize();
|
||||
this._api.getMicrophoneManager().initialize();
|
||||
this._api.getKeyboardStateManager().initialize();
|
||||
|
||||
// Whether or not the card is in panel mode on the dashboard.
|
||||
setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel');
|
||||
setOrRemoveAttribute(this._element, true, 'tabindex', '0');
|
||||
|
||||
this._api.getFullscreenManager().connect();
|
||||
|
||||
|
||||
this._element.addEventListener(
|
||||
'mousemove',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
);
|
||||
this._element.addEventListener(
|
||||
'll-custom',
|
||||
this._api.getActionsManager().handleActionEvent,
|
||||
this._api.getActionsManager().handleCustomActionEvent,
|
||||
);
|
||||
this._element.addEventListener(
|
||||
'action',
|
||||
@@ -84,6 +87,10 @@ export class CardElementManager {
|
||||
'action',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
);
|
||||
this._element.addEventListener(
|
||||
'frigate-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
|
||||
// Listen for HA `navigate` actions.
|
||||
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
|
||||
@@ -106,23 +113,25 @@ export class CardElementManager {
|
||||
|
||||
public elementDisconnected(): void {
|
||||
setOrRemoveAttribute(this._element, false, 'panel');
|
||||
setOrRemoveAttribute(this._element, false, 'tabindex');
|
||||
|
||||
// When the dashboard 'tab' is changed, the media is effectively unloaded.
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getFullscreenManager().disconnect();
|
||||
this._api.getKeyboardStateManager().uninitialize();
|
||||
this._api.getActionsManager().uninitialize();
|
||||
|
||||
// Uninitialize cameras to cause them to reinitialize on
|
||||
// reconnection, to ensure the state subscription/unsubscription works
|
||||
// correctly for triggers.
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
||||
|
||||
this._element.removeEventListener(
|
||||
'mousemove',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'mousemove',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'll-custom',
|
||||
this._api.getActionsManager().handleActionEvent,
|
||||
this._api.getActionsManager().handleCustomActionEvent,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'action',
|
||||
@@ -132,6 +141,10 @@ export class CardElementManager {
|
||||
'action',
|
||||
this._api.getInteractionManager().reportInteraction,
|
||||
);
|
||||
this._element.removeEventListener(
|
||||
'frigate-card:action:execution-request',
|
||||
this._api.getActionsManager().handleActionExecutionRequestEvent,
|
||||
);
|
||||
|
||||
window.removeEventListener(
|
||||
'location-changed',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Overrides,
|
||||
} from '../config/types';
|
||||
import { desparsifyArrays } from '../utils/basic';
|
||||
import { CardConditionAPI } from './types';
|
||||
import { CardConditionAPI, KeysState } from './types';
|
||||
|
||||
interface MicrophoneConditionState {
|
||||
connected?: boolean;
|
||||
@@ -35,6 +35,7 @@ interface ConditionState {
|
||||
interaction?: boolean;
|
||||
microphone?: MicrophoneConditionState;
|
||||
user?: CurrentUser;
|
||||
keys?: KeysState;
|
||||
}
|
||||
|
||||
export class ConditionsEvaluateRequestEvent extends Event {
|
||||
@@ -190,7 +191,9 @@ export class ConditionsManager {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const conditions: FrigateCardCondition[] = [];
|
||||
config?.overrides?.forEach((override) => conditions.push(...override.conditions));
|
||||
config?.automations?.forEach((automation) => conditions.push(...automation.conditions));
|
||||
config?.automations?.forEach((automation) =>
|
||||
conditions.push(...automation.conditions),
|
||||
);
|
||||
|
||||
// Element conditions can be arbitrarily nested underneath conditionals and
|
||||
// custom elements that this card may not known. Here we recursively parse
|
||||
@@ -323,6 +326,20 @@ export class ConditionsManager {
|
||||
(conditionObj.muted === undefined ||
|
||||
state.microphone?.muted === conditionObj.muted)
|
||||
);
|
||||
case 'key':
|
||||
return (
|
||||
!!state.keys &&
|
||||
conditionObj.key in state.keys &&
|
||||
(conditionObj.state ?? 'down') === state.keys[conditionObj.key].state &&
|
||||
(conditionObj.ctrl === undefined ||
|
||||
conditionObj.ctrl === !!state.keys[conditionObj.key].ctrl) &&
|
||||
(conditionObj.alt === undefined ||
|
||||
conditionObj.alt === !!state.keys[conditionObj.key].alt) &&
|
||||
(conditionObj.meta === undefined ||
|
||||
conditionObj.meta === !!state.keys[conditionObj.key].meta) &&
|
||||
(conditionObj.shift === undefined ||
|
||||
conditionObj.shift === !!state.keys[conditionObj.key].shift)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-9
@@ -1,17 +1,19 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { isConfigUpgradeable } from '../config/management';
|
||||
import { isConfigUpgradeable } from '../../config/management.js';
|
||||
import {
|
||||
CardWideConfig,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigSchema,
|
||||
RawFrigateCardConfig,
|
||||
} from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { setProfiles } from '../config/profiles';
|
||||
import { getParseErrorPaths } from '../utils/zod.js';
|
||||
import { getOverriddenConfig } from './conditions-manager';
|
||||
import { InitializationAspect } from './initialization-manager';
|
||||
import { CardConfigAPI } from './types';
|
||||
} from '../../config/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { setProfiles } from '../../config/profiles/index.js';
|
||||
import { getParseErrorPaths } from '../../utils/zod.js';
|
||||
import { getOverriddenConfig } from '../conditions-manager.js';
|
||||
import { InitializationAspect } from '../initialization-manager.js';
|
||||
import { CardConfigAPI } from '../types.js';
|
||||
import { setAutomationsFromConfig } from './load-automations.js';
|
||||
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
|
||||
|
||||
export class ConfigManager {
|
||||
protected _api: CardConfigAPI;
|
||||
@@ -92,9 +94,10 @@ export class ConfigManager {
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getViewManager().reset();
|
||||
this._api.getMessageManager().reset();
|
||||
this._api.getAutomationsManager().setAutomationsFromConfig();
|
||||
this._api.getStyleManager().setPerformance();
|
||||
this._api.getCardElementManager().update();
|
||||
setKeyboardShortcutsFromConfig(this._api, this);
|
||||
setAutomationsFromConfig(this._api);
|
||||
|
||||
this.computeOverrideConfig();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CardConfigLoaderAPI } from '../types';
|
||||
|
||||
export const setAutomationsFromConfig = (api: CardConfigLoaderAPI) => {
|
||||
api.getAutomationsManager().deleteAutomations();
|
||||
api
|
||||
.getAutomationsManager()
|
||||
.addAutomations(api.getConfigManager().getNonOverriddenConfig()?.automations ?? []);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
KeyboardShortcuts,
|
||||
PTZKeyboardShortcutName,
|
||||
} from '../../config/keyboard-shortcuts';
|
||||
import { PTZAction } from '../../config/ptz';
|
||||
import { CardConfigLoaderAPI, TaggedAutomations } from '../types';
|
||||
import { createPTZMultiAction } from '../../utils/action';
|
||||
|
||||
export const setKeyboardShortcutsFromConfig = (
|
||||
api: CardConfigLoaderAPI,
|
||||
tag: unknown,
|
||||
) => {
|
||||
api.getAutomationsManager().deleteAutomations(tag);
|
||||
|
||||
const shortcuts = api.getConfigManager().getConfig()?.view.keyboard_shortcuts;
|
||||
if (!shortcuts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const automations = convertKeyboardShortcutsToAutomations(tag, shortcuts);
|
||||
if (automations.length) {
|
||||
api.getAutomationsManager().addAutomations(automations);
|
||||
}
|
||||
};
|
||||
|
||||
const ptzKeyboardShortcutToPTZAction = (
|
||||
ptzKbs: PTZKeyboardShortcutName,
|
||||
): PTZAction | null => {
|
||||
switch (ptzKbs) {
|
||||
case 'ptz_left':
|
||||
return 'left';
|
||||
case 'ptz_right':
|
||||
return 'right';
|
||||
case 'ptz_up':
|
||||
return 'up';
|
||||
case 'ptz_down':
|
||||
return 'down';
|
||||
case 'ptz_zoom_in':
|
||||
return 'zoom_in';
|
||||
case 'ptz_zoom_out':
|
||||
return 'zoom_out';
|
||||
}
|
||||
/* istanbul ignore next: No (current) way to reach this code -- @preserve */
|
||||
return null;
|
||||
};
|
||||
|
||||
const convertKeyboardShortcutsToAutomations = (
|
||||
tag: unknown,
|
||||
shortcuts: KeyboardShortcuts,
|
||||
): TaggedAutomations => {
|
||||
if (!shortcuts.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const automations: TaggedAutomations = [];
|
||||
|
||||
for (const name of [
|
||||
'ptz_down',
|
||||
'ptz_left',
|
||||
'ptz_right',
|
||||
'ptz_up',
|
||||
'ptz_zoom_in',
|
||||
'ptz_zoom_out',
|
||||
] as const) {
|
||||
const shortcut = shortcuts[name];
|
||||
const ptzAction = ptzKeyboardShortcutToPTZAction(name);
|
||||
if (!shortcut || !ptzAction) {
|
||||
continue;
|
||||
}
|
||||
|
||||
automations.push({
|
||||
conditions: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
key: shortcut.key,
|
||||
state: 'down',
|
||||
shift: shortcut.shift,
|
||||
ctrl: shortcut.ctrl,
|
||||
alt: shortcut.alt,
|
||||
meta: shortcut.meta,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
createPTZMultiAction({
|
||||
ptzAction: ptzAction,
|
||||
ptzPhase: 'start',
|
||||
}),
|
||||
],
|
||||
tag: tag,
|
||||
});
|
||||
|
||||
automations.push({
|
||||
conditions: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
key: shortcut.key,
|
||||
state: 'up',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
createPTZMultiAction({
|
||||
ptzAction: ptzAction,
|
||||
ptzPhase: 'stop',
|
||||
}),
|
||||
],
|
||||
tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
const homeShortcut = shortcuts.ptz_home;
|
||||
if (homeShortcut) {
|
||||
automations.push({
|
||||
conditions: [
|
||||
{
|
||||
condition: 'key' as const,
|
||||
key: homeShortcut.key,
|
||||
state: 'down',
|
||||
shift: homeShortcut.shift,
|
||||
ctrl: homeShortcut.ctrl,
|
||||
alt: homeShortcut.alt,
|
||||
meta: homeShortcut.meta,
|
||||
},
|
||||
],
|
||||
actions: [createPTZMultiAction()],
|
||||
tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
return automations;
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { FrigateCardConfig } from '../config/types';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import { EntityCache } from '../utils/ha/entity-registry/cache';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { ActionsManager } from './actions-manager';
|
||||
import { ActionsManager } from './actions/actions-manager';
|
||||
import { AutoUpdateManager } from './auto-update-manager';
|
||||
import { AutomationsManager } from './automations-manager';
|
||||
import { CameraURLManager } from './camera-url-manager';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ScrollCallback,
|
||||
} from './card-element-manager';
|
||||
import { ConditionsManager, ConditionsManagerListener } from './conditions-manager';
|
||||
import { ConfigManager } from './config-manager';
|
||||
import { ConfigManager } from './config/config-manager';
|
||||
import { DownloadManager } from './download-manager';
|
||||
import { ExpandManager } from './expand-manager';
|
||||
import { FullscreenManager } from './fullscreen-manager';
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
CardHASSAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardKeyboardStateAPI,
|
||||
CardMediaLoadedAPI,
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
CardViewAPI,
|
||||
} from './types';
|
||||
import { ViewManager } from './view-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
|
||||
export class CardController
|
||||
implements
|
||||
@@ -72,6 +74,7 @@ export class CardController
|
||||
CardHASSAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardKeyboardStateAPI,
|
||||
CardMediaLoadedAPI,
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
@@ -101,6 +104,7 @@ export class CardController
|
||||
protected _hassManager = new HASSManager(this);
|
||||
protected _initializationManager = new InitializationManager(this);
|
||||
protected _interactionManager = new InteractionManager(this);
|
||||
protected _keyboardStateManager = new KeyboardStateManager(this);
|
||||
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||
protected _mediaPlayerManager = new MediaPlayerManager(this);
|
||||
protected _messageManager = new MessageManager(this);
|
||||
@@ -195,6 +199,10 @@ export class CardController
|
||||
return this._interactionManager;
|
||||
}
|
||||
|
||||
public getKeyboardStateManager(): KeyboardStateManager {
|
||||
return this._keyboardStateManager;
|
||||
}
|
||||
|
||||
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
|
||||
return this._mediaLoadedInfoManager;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CardKeyboardStateAPI, KeysState } from './types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
export class KeyboardStateManager {
|
||||
protected _api: CardKeyboardStateAPI;
|
||||
protected _state: KeysState = {};
|
||||
|
||||
constructor(api: CardKeyboardStateAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const element = this._api.getCardElementManager().getElement();
|
||||
element.addEventListener('keydown', this._handleKeydown);
|
||||
element.addEventListener('keyup', this._handleKeyup);
|
||||
element.addEventListener('blur', this._handleBlur);
|
||||
}
|
||||
|
||||
public uninitialize(): void {
|
||||
const element = this._api.getCardElementManager().getElement();
|
||||
element.removeEventListener('keydown', this._handleKeydown);
|
||||
element.removeEventListener('keyup', this._handleKeyup);
|
||||
element.removeEventListener('blur', this._handleBlur);
|
||||
}
|
||||
|
||||
protected _handleKeydown = (ev: KeyboardEvent): void => {
|
||||
const keyObj = {
|
||||
state: 'down' as const,
|
||||
ctrl: ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
meta: ev.metaKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
|
||||
if (!isEqual(this._state[ev.key], keyObj)) {
|
||||
this._state[ev.key] = keyObj;
|
||||
this._processStateChange();
|
||||
}
|
||||
};
|
||||
|
||||
protected _handleKeyup = (ev: KeyboardEvent): void => {
|
||||
if (ev.key in this._state && this._state[ev.key].state === 'down') {
|
||||
this._state[ev.key].state = 'up';
|
||||
this._processStateChange();
|
||||
}
|
||||
};
|
||||
|
||||
protected _handleBlur = (): void => {
|
||||
if (Object.keys(this._state).length) {
|
||||
// State is emptied if the element loses focus.
|
||||
this._state = {};
|
||||
this._processStateChange();
|
||||
}
|
||||
};
|
||||
|
||||
protected _processStateChange(): void {
|
||||
this._api.getConditionsManager().setState({ keys: this._state });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types';
|
||||
import {
|
||||
createFrigateCardCameraAction,
|
||||
createFrigateCardSimpleAction
|
||||
} from '../utils/action.js';
|
||||
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
|
||||
import { createCameraAction, createGeneralAction } from '../utils/action.js';
|
||||
import { CardQueryStringAPI } from './types';
|
||||
import { ViewManagerSetViewParameters } from './view-manager';
|
||||
|
||||
@@ -56,14 +53,15 @@ export class QueryStringManager {
|
||||
}
|
||||
|
||||
protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
|
||||
// Only execute non-view actions when the card has rendered at least once.
|
||||
if (!this._api.getCardElementManager().hasUpdated()) {
|
||||
if (
|
||||
// Only execute non-view actions when the card has rendered at least once.
|
||||
!this._api.getCardElementManager().hasUpdated() ||
|
||||
!intent.other?.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
intent.other?.forEach((action) =>
|
||||
this._api.getActionsManager().executeFrigateAction(action),
|
||||
);
|
||||
this._api.getActionsManager().executeActions(intent.other);
|
||||
}
|
||||
|
||||
protected _calculateIntent(): QueryStringViewIntent {
|
||||
@@ -105,7 +103,7 @@ export class QueryStringManager {
|
||||
case 'camera_select':
|
||||
case 'live_substream_select':
|
||||
if (value) {
|
||||
customAction = createFrigateCardCameraAction(action, value, {
|
||||
customAction = createCameraAction(action, value, {
|
||||
cardID: cardID,
|
||||
});
|
||||
}
|
||||
@@ -125,7 +123,7 @@ export class QueryStringManager {
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
customAction = createFrigateCardSimpleAction(action, {
|
||||
customAction = createGeneralAction(action, {
|
||||
cardID: cardID,
|
||||
});
|
||||
break;
|
||||
@@ -143,7 +141,7 @@ export class QueryStringManager {
|
||||
|
||||
protected _isViewAction = (
|
||||
action: FrigateCardCustomAction,
|
||||
): action is FrigateCardViewAction => {
|
||||
): action is ViewActionConfig => {
|
||||
switch (action.frigate_card_action) {
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { ConditionsManager } from './conditions-manager';
|
||||
import type { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import type { ActionsManager } from './actions-manager';
|
||||
import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutoUpdateManager } from './auto-update-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConfigManager } from './config-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
import type { DownloadManager } from './download-manager';
|
||||
import type { ExpandManager } from './expand-manager';
|
||||
import type { FullscreenManager } from './fullscreen-manager';
|
||||
@@ -22,17 +22,22 @@ import type { StyleManager } from './style-manager';
|
||||
import type { TriggersManager } from './triggers-manager';
|
||||
import type { ViewManager } from './view-manager';
|
||||
import type { QueryStringManager } from './query-string-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { Automation } from '../config/types';
|
||||
|
||||
/**
|
||||
* This defines a series of limited APIs that various manager helpers use to
|
||||
* control the card. Explicitly specifying them helps make coupling intentional
|
||||
* and avoids cyclic importing.
|
||||
*/
|
||||
// *************************************************************************
|
||||
// Manager APIs
|
||||
// This defines a series of limited APIs that various managers use to control
|
||||
// the card. Explicitly specifying them helps make coupling intentional and
|
||||
// reduce cyclic dependencies.
|
||||
// *************************************************************************
|
||||
|
||||
export interface CardActionsManagerAPI {
|
||||
export interface CardActionsAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getCameraURLManager(): CameraURLManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDownloadManager(): DownloadManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
@@ -45,11 +50,12 @@ export interface CardActionsManagerAPI {
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
export type CardActionsManagerAPI = CardActionsAPI;
|
||||
|
||||
export interface CardAutomationsAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMessageManager(): MessageManager;
|
||||
}
|
||||
@@ -62,6 +68,7 @@ export interface CardAutoRefreshAPI {
|
||||
}
|
||||
|
||||
export interface CardCameraAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
getHASSManager(): HASSManager;
|
||||
@@ -84,6 +91,7 @@ export interface CardConfigAPI {
|
||||
getAutomationsManager(): AutomationsManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMessageManager(): MessageManager;
|
||||
@@ -91,6 +99,11 @@ export interface CardConfigAPI {
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardConfigLoaderAPI {
|
||||
getConfigManager(): ConfigManager;
|
||||
getAutomationsManager(): AutomationsManager;
|
||||
}
|
||||
|
||||
export interface CardDownloadAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getHASSManager(): HASSManager;
|
||||
@@ -106,6 +119,7 @@ export interface CardElementAPI {
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getKeyboardStateManager(): KeyboardStateManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
@@ -159,6 +173,12 @@ export interface CardInteractionAPI {
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardKeyboardStateAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
}
|
||||
|
||||
export interface CardMediaLoadedAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
@@ -224,3 +244,21 @@ export interface CardViewAPI {
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
}
|
||||
|
||||
// *************************************************************************
|
||||
// Common Types
|
||||
// *************************************************************************
|
||||
|
||||
export interface KeysState {
|
||||
[key: string]: {
|
||||
state: 'down' | 'up';
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
}
|
||||
interface TaggedAutomation extends Automation {
|
||||
tag?: unknown;
|
||||
}
|
||||
export type TaggedAutomations = TaggedAutomation[];
|
||||
|
||||
@@ -284,6 +284,7 @@ class FrigateCard extends LitElement {
|
||||
@frigate-card:media:pause=${
|
||||
() => this.requestUpdate() /* Refresh play/pause menu button */
|
||||
}
|
||||
@frigate-card:focus=${() => this.focus()}
|
||||
>
|
||||
${renderMenuAbove ? this._renderMenu() : ''}
|
||||
<div ${ref(this._refMain)} class="${classMap(mainClasses)}">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { LitElement, ReactiveController } from 'lit';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { KeyboardShortcut } from '../config/keyboard-shortcuts';
|
||||
import { setOrRemoveAttribute } from '../utils/basic';
|
||||
|
||||
export class KeyAssignerController implements ReactiveController {
|
||||
protected _host: LitElement;
|
||||
protected _assigning = false;
|
||||
protected _value: KeyboardShortcut | null = null;
|
||||
|
||||
constructor(host: LitElement) {
|
||||
this._host = host;
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public setValue(value: KeyboardShortcut | null): void {
|
||||
if (!isEqual(value, this._value)) {
|
||||
this._value = value;
|
||||
this._host.requestUpdate();
|
||||
|
||||
this._host.dispatchEvent(
|
||||
new CustomEvent('value-changed', {
|
||||
detail: {
|
||||
value: this._value,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
public getValue(): KeyboardShortcut | null {
|
||||
return this._value;
|
||||
}
|
||||
public hasValue(): boolean {
|
||||
return !!this._value;
|
||||
}
|
||||
|
||||
public isAssigning(): boolean {
|
||||
return this._assigning;
|
||||
}
|
||||
public toggleAssigning(): void {
|
||||
this._setAssigning(!this._assigning);
|
||||
}
|
||||
protected _setAssigning(assigning: boolean): void {
|
||||
this._assigning = assigning;
|
||||
setOrRemoveAttribute(this._host, this._assigning, 'assigning');
|
||||
|
||||
if (this._assigning) {
|
||||
this._host.addEventListener('keydown', this._keydownEventHandler);
|
||||
} else {
|
||||
this._host.removeEventListener('keydown', this._keydownEventHandler);
|
||||
}
|
||||
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
protected _blurEventHandler = (): void => {
|
||||
this._setAssigning(false);
|
||||
};
|
||||
|
||||
protected _keydownEventHandler = (ev: KeyboardEvent): void => {
|
||||
// Don't allow _only_ a modifier.
|
||||
if (!ev.key || ['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setValue({
|
||||
key: ev.key,
|
||||
ctrl: ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
meta: ev.metaKey,
|
||||
});
|
||||
this._setAssigning(false);
|
||||
};
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.addEventListener('blur', this._blurEventHandler);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._host.removeEventListener('blur', this._blurEventHandler);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ interface LiveViewContext {
|
||||
// camera to be live rather than the camera selected in the view).
|
||||
overrides?: Map<string, string>;
|
||||
|
||||
ptzVisible?: boolean;
|
||||
fetchThumbnails?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,17 +14,17 @@ import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import {
|
||||
createFrigateCardCameraAction,
|
||||
createFrigateCardChangeZoomAction,
|
||||
createFrigateCardDisplayModeAction,
|
||||
createFrigateCardMediaPlayerAction,
|
||||
createFrigateCardShowPTZAction,
|
||||
createFrigateCardSimpleAction,
|
||||
createCameraAction,
|
||||
createPTZMultiAction,
|
||||
createDisplayModeAction,
|
||||
createMediaPlayerAction,
|
||||
createPTZControlsAction,
|
||||
createGeneralAction,
|
||||
} from '../utils/action';
|
||||
import { isTruthy } from '../utils/basic';
|
||||
import { getEntityIcon, getEntityTitle } from '../utils/ha';
|
||||
import { hasUsablePTZ } from '../utils/ptz';
|
||||
import { hasSubstream } from '../utils/substream';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName } from '../view/view-to-cameras';
|
||||
|
||||
@@ -95,8 +95,8 @@ export class MenuButtonController {
|
||||
this._getMuteUnmuteButton(config, options?.currentMediaLoadedInfo),
|
||||
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
|
||||
this._getDisplayModeButton(config, cameraManager, view),
|
||||
this._getPTZButton(config, cameraManager, view),
|
||||
this._getDefaultZoomButton(config, view),
|
||||
this._getPTZControlsButton(config, cameraManager, view),
|
||||
this._getPTZHomeButton(config, cameraManager, view),
|
||||
|
||||
...this._dynamicMenuButtons.map((button) => ({
|
||||
style: this._getStyleFromActions(config, view, button, options),
|
||||
@@ -115,11 +115,9 @@ export class MenuButtonController {
|
||||
title: localize('config.menu.buttons.frigate'),
|
||||
tap_action:
|
||||
config.menu?.style === 'hidden'
|
||||
? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
|
||||
: (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
|
||||
hold_action: createFrigateCardSimpleAction(
|
||||
'diagnostics',
|
||||
) as FrigateCardCustomAction,
|
||||
? (createGeneralAction('menu_toggle') as FrigateCardCustomAction)
|
||||
: (createGeneralAction('default') as FrigateCardCustomAction),
|
||||
hold_action: createGeneralAction('diagnostics') as FrigateCardCustomAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,7 +133,7 @@ export class MenuButtonController {
|
||||
const menuItems = Array.from(
|
||||
cameraManager.getStore().getCameraConfigEntries(menuCameraIDs),
|
||||
([cameraID, config]) => {
|
||||
const action = createFrigateCardCameraAction('camera_select', cameraID);
|
||||
const action = createCameraAction('camera_select', cameraID);
|
||||
const metadata = cameraManager.getCameraMetadata(cameraID);
|
||||
|
||||
return {
|
||||
@@ -175,7 +173,7 @@ export class MenuButtonController {
|
||||
(cameraID) => cameraID !== view.camera,
|
||||
);
|
||||
const streams = [view.camera, ...substreams];
|
||||
const substreamAwareCameraID = this._getSubstreamAwareCameraID(view);
|
||||
const substreamAwareCameraID = getStreamCameraID(view);
|
||||
|
||||
if (streams.length === 2) {
|
||||
// If there are only two dependencies (the main camera, and 1 other)
|
||||
@@ -187,16 +185,13 @@ export class MenuButtonController {
|
||||
title: localize('config.menu.buttons.substreams'),
|
||||
...config.menu.buttons.substreams,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
tap_action: createGeneralAction(
|
||||
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
|
||||
) as FrigateCardCustomAction,
|
||||
};
|
||||
} else if (streams.length > 2) {
|
||||
const menuItems = Array.from(streams, (streamID) => {
|
||||
const action = createFrigateCardCameraAction(
|
||||
'live_substream_select',
|
||||
streamID,
|
||||
);
|
||||
const action = createCameraAction('live_substream_select', streamID);
|
||||
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
|
||||
const cameraConfig = cameraManager.getStore().getCameraConfig(streamID);
|
||||
return {
|
||||
@@ -236,7 +231,7 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.live'),
|
||||
style: view.is('live') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('live') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -253,8 +248,8 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.clips'),
|
||||
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
|
||||
hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('clips') as FrigateCardCustomAction,
|
||||
hold_action: createGeneralAction('clip') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -271,12 +266,8 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.snapshots'),
|
||||
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'snapshots',
|
||||
) as FrigateCardCustomAction,
|
||||
hold_action: createFrigateCardSimpleAction(
|
||||
'snapshot',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('snapshots') as FrigateCardCustomAction,
|
||||
hold_action: createGeneralAction('snapshot') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -293,12 +284,8 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.recordings'),
|
||||
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'recordings',
|
||||
) as FrigateCardCustomAction,
|
||||
hold_action: createFrigateCardSimpleAction(
|
||||
'recording',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('recordings') as FrigateCardCustomAction,
|
||||
hold_action: createGeneralAction('recording') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -315,7 +302,7 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.image'),
|
||||
style: view?.is('image') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('image') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -332,9 +319,7 @@ export class MenuButtonController {
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.view.views.timeline'),
|
||||
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'timeline',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('timeline') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -358,7 +343,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.download,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.download'),
|
||||
tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('download') as FrigateCardCustomAction,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -374,9 +359,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.camera_ui,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.camera_ui'),
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'camera_ui',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('camera_ui') as FrigateCardCustomAction,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -402,16 +385,16 @@ export class MenuButtonController {
|
||||
style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
|
||||
...(!forbidden &&
|
||||
buttonType === 'momentary' && {
|
||||
start_tap_action: createFrigateCardSimpleAction(
|
||||
start_tap_action: createGeneralAction(
|
||||
'microphone_unmute',
|
||||
) as FrigateCardCustomAction,
|
||||
end_tap_action: createFrigateCardSimpleAction(
|
||||
end_tap_action: createGeneralAction(
|
||||
'microphone_mute',
|
||||
) as FrigateCardCustomAction,
|
||||
}),
|
||||
...(!forbidden &&
|
||||
buttonType === 'toggle' && {
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
tap_action: createGeneralAction(
|
||||
muted ? 'microphone_unmute' : 'microphone_mute',
|
||||
) as FrigateCardCustomAction,
|
||||
}),
|
||||
@@ -429,7 +412,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.expand,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.expand'),
|
||||
tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('expand') as FrigateCardCustomAction,
|
||||
style: inExpandedMode ? this._getEmphasizedStyle() : {},
|
||||
};
|
||||
}
|
||||
@@ -444,9 +427,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.fullscreen,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.fullscreen'),
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'fullscreen',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('fullscreen') as FrigateCardCustomAction,
|
||||
style: inFullscreenMode ? this._getEmphasizedStyle() : {},
|
||||
}
|
||||
: null;
|
||||
@@ -469,8 +450,8 @@ export class MenuButtonController {
|
||||
.map((playerEntityID) => {
|
||||
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
|
||||
const state = hass.states[playerEntityID];
|
||||
const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
|
||||
const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
|
||||
const playAction = createMediaPlayerAction(playerEntityID, 'play');
|
||||
const stopAction = createMediaPlayerAction(playerEntityID, 'stop');
|
||||
const disabled = !state || state.state === 'unavailable';
|
||||
|
||||
return {
|
||||
@@ -512,7 +493,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.play,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.play'),
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
tap_action: createGeneralAction(
|
||||
paused ? 'play' : 'pause',
|
||||
) as FrigateCardCustomAction,
|
||||
};
|
||||
@@ -535,7 +516,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.mute,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.mute'),
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
tap_action: createGeneralAction(
|
||||
muted ? 'unmute' : 'mute',
|
||||
) as FrigateCardCustomAction,
|
||||
};
|
||||
@@ -553,9 +534,7 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.screenshot,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.screenshot'),
|
||||
tap_action: createFrigateCardSimpleAction(
|
||||
'screenshot',
|
||||
) as FrigateCardCustomAction,
|
||||
tap_action: createGeneralAction('screenshot') as FrigateCardCustomAction,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -577,65 +556,75 @@ export class MenuButtonController {
|
||||
title: isGrid
|
||||
? localize('display_modes.single')
|
||||
: localize('display_modes.grid'),
|
||||
tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
|
||||
tap_action: createDisplayModeAction(isGrid ? 'single' : 'grid'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getSubstreamAwareCameraID(view: View): string {
|
||||
return view.is('live')
|
||||
? view.context?.live?.overrides?.get(view.camera) ?? view.camera
|
||||
: view.camera;
|
||||
}
|
||||
|
||||
protected _getPTZButton(
|
||||
protected _getPTZControlsButton(
|
||||
config: FrigateCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
view: View,
|
||||
): MenuItem | null {
|
||||
const substreamAwareCameraCapabilities = cameraManager.getCameraCapabilities(
|
||||
this._getSubstreamAwareCameraID(view),
|
||||
);
|
||||
const ptzConfig = view.is('live')
|
||||
? config.live.controls.ptz
|
||||
: view.isViewerView()
|
||||
? config.media_viewer.controls.ptz
|
||||
: null;
|
||||
|
||||
if (
|
||||
view.is('live') &&
|
||||
hasUsablePTZ(substreamAwareCameraCapabilities, config.live.controls.ptz)
|
||||
) {
|
||||
if (!ptzConfig || ptzConfig.mode === 'off') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ptzTarget = getPTZTarget(view, {
|
||||
cameraManager: cameraManager,
|
||||
...(ptzConfig.mode === 'auto' && { type: 'ptz' }),
|
||||
});
|
||||
|
||||
if (ptzTarget) {
|
||||
const isOn =
|
||||
view.context?.live?.ptzVisible === false
|
||||
? false
|
||||
: config.live.controls.ptz.mode === 'on';
|
||||
view.context?.ptzControls?.enabled !== false &&
|
||||
(ptzConfig.mode === 'on' ||
|
||||
(ptzConfig.mode === 'auto' && ptzTarget.type === 'ptz'));
|
||||
return {
|
||||
icon: 'mdi:pan',
|
||||
...config.menu.buttons.ptz,
|
||||
...config.menu.buttons.ptz_controls,
|
||||
style: isOn ? this._getEmphasizedStyle() : {},
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.ptz'),
|
||||
tap_action: createFrigateCardShowPTZAction(!isOn),
|
||||
title: localize('config.menu.buttons.ptz_controls'),
|
||||
tap_action: createPTZControlsAction(!isOn),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getDefaultZoomButton(
|
||||
protected _getPTZHomeButton(
|
||||
config: FrigateCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
view: View,
|
||||
): MenuItem | null {
|
||||
const targetID = view.isViewerView()
|
||||
? view.queryResults?.getSelectedResult()?.getID() ?? null
|
||||
: this._getSubstreamAwareCameraID(view);
|
||||
const target = getPTZTarget(view, {
|
||||
cameraManager: cameraManager,
|
||||
});
|
||||
|
||||
if (!targetID || (view.context?.zoom?.[targetID]?.isDefault ?? true)) {
|
||||
if (
|
||||
!target ||
|
||||
((target.type === 'digital' &&
|
||||
view.context?.zoom?.[target.targetID]?.observed?.isDefault) ??
|
||||
true)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'mdi:magnify-close',
|
||||
...config.menu.buttons.default_zoom,
|
||||
icon: 'mdi:home',
|
||||
...config.menu.buttons.ptz_home,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.default_zoom'),
|
||||
tap_action: createFrigateCardChangeZoomAction(targetID) as FrigateCardCustomAction,
|
||||
title: localize('config.menu.buttons.ptz_home'),
|
||||
tap_action: createPTZMultiAction({
|
||||
targetID: target.targetID,
|
||||
}) as FrigateCardCustomAction,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import type {
|
||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
|
||||
import { StateParameters } from '../types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
frigateCardHandleActionConfig,
|
||||
convertActionToCardCustomAction,
|
||||
getActionConfigGivenAction,
|
||||
} from '../utils/action';
|
||||
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
|
||||
import { refreshDynamicStateParameters } from '../utils/ha/index.js';
|
||||
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
|
||||
|
||||
export class MenuController {
|
||||
protected _host: LitElement;
|
||||
@@ -95,7 +95,6 @@ export class MenuController {
|
||||
}
|
||||
|
||||
public actionHandler(
|
||||
hass: HomeAssistant,
|
||||
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
|
||||
config?: ActionsConfig,
|
||||
): void {
|
||||
@@ -134,7 +133,10 @@ export class MenuController {
|
||||
}
|
||||
|
||||
if (toggleLessActions.length) {
|
||||
frigateCardHandleActionConfig(this._host, hass, config, interaction, actions);
|
||||
dispatchActionExecutionRequest(this._host, {
|
||||
action: actions,
|
||||
config: config,
|
||||
});
|
||||
}
|
||||
|
||||
if (this._isHidingMenu()) {
|
||||
@@ -209,7 +211,7 @@ export class MenuController {
|
||||
}
|
||||
|
||||
protected _isMenuToggleAction(action: ActionType): boolean {
|
||||
const frigateCardAction = convertActionToFrigateCardCustomAction(action);
|
||||
const frigateCardAction = convertActionToCardCustomAction(action);
|
||||
return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import {
|
||||
Actions,
|
||||
ActionsConfig,
|
||||
FrigateCardPTZAction,
|
||||
FrigateCardPTZActions,
|
||||
FrigateCardPTZConfig,
|
||||
PTZAction,
|
||||
PTZControlAction,
|
||||
PTZ_CONTROL_ACTIONS,
|
||||
} from '../config/types';
|
||||
import {
|
||||
frigateCardHandleActionConfig,
|
||||
getActionConfigGivenAction,
|
||||
} from '../utils/action';
|
||||
|
||||
export class PTZController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
private _config: FrigateCardPTZConfig | null = null;
|
||||
private _hass: HomeAssistant | null = null;
|
||||
private _cameraManager: CameraManager | null = null;
|
||||
private _cameraID: string | null = null;
|
||||
private _actions: FrigateCardPTZActions | null = null;
|
||||
private _forceVisibility?: boolean;
|
||||
|
||||
constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setConfig(config?: FrigateCardPTZConfig) {
|
||||
this._config = config ?? null;
|
||||
|
||||
this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal');
|
||||
this._host.setAttribute('data-position', config?.position ?? 'bottom-right');
|
||||
this._host.setAttribute(
|
||||
'style',
|
||||
Object.entries(config?.style ?? {})
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(';'),
|
||||
);
|
||||
}
|
||||
|
||||
public getConfig(): FrigateCardPTZConfig | null {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant) {
|
||||
this._hass = hass ?? null;
|
||||
}
|
||||
|
||||
public setCamera(cameraManager?: CameraManager, cameraID?: string) {
|
||||
this._cameraManager = cameraManager ?? null;
|
||||
this._cameraID = cameraID ?? null;
|
||||
|
||||
this._calculateActions();
|
||||
}
|
||||
|
||||
public setForceVisibility(forceVisibility?: boolean): void {
|
||||
this._forceVisibility = forceVisibility;
|
||||
}
|
||||
|
||||
public handleAction(
|
||||
ev: HASSDomEvent<{ action: string }>,
|
||||
config?: ActionsConfig | null,
|
||||
): void {
|
||||
// Nothing else has the configuration for this action, so don't let it
|
||||
// propagate further.
|
||||
ev.stopPropagation();
|
||||
|
||||
const interaction: string = ev.detail.action;
|
||||
const action = getActionConfigGivenAction(interaction, config);
|
||||
if (config && action && this._hass) {
|
||||
frigateCardHandleActionConfig(this._host, this._hass, config, interaction, action);
|
||||
}
|
||||
}
|
||||
|
||||
public getPTZActions(actionName: PTZControlAction): Actions | null {
|
||||
const propertyName = 'actions_' + actionName;
|
||||
return this._config?.[propertyName] ?? this._actions?.[propertyName] ?? null;
|
||||
}
|
||||
|
||||
private _hasAnyAction(): boolean {
|
||||
for (const actionName of PTZ_CONTROL_ACTIONS) {
|
||||
if ('actions_' + actionName in (this._actions ?? {})) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public shouldDisplay(): boolean {
|
||||
return this._forceVisibility === false
|
||||
? false
|
||||
: this._config?.mode === 'on' && this._hasAnyAction();
|
||||
}
|
||||
|
||||
private _calculateActions(): void {
|
||||
const getDefaultAction = (
|
||||
ptzAction: PTZAction,
|
||||
options?: {
|
||||
phase?: 'start' | 'stop';
|
||||
preset?: string;
|
||||
},
|
||||
): FrigateCardPTZAction => ({
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'ptz',
|
||||
ptz_action: ptzAction,
|
||||
...(options?.phase && { ptz_phase: options.phase }),
|
||||
...(options?.preset && { ptz_preset: options.preset }),
|
||||
});
|
||||
|
||||
const getDefaultActions = (
|
||||
ptzAction: PTZAction,
|
||||
continuous: boolean,
|
||||
preset?: string,
|
||||
): Actions =>
|
||||
continuous
|
||||
? {
|
||||
start_tap_action: getDefaultAction(ptzAction, {
|
||||
phase: 'start',
|
||||
preset: preset,
|
||||
}),
|
||||
end_tap_action: getDefaultAction(ptzAction, {
|
||||
phase: 'stop',
|
||||
preset: preset,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
tap_action: getDefaultAction(ptzAction, { preset: preset }),
|
||||
};
|
||||
|
||||
if (!this._cameraManager || !this._cameraID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ptzCapabilities = this._cameraManager.getCameraCapabilities(
|
||||
this._cameraID,
|
||||
)?.getPTZCapabilities();
|
||||
|
||||
const defaultActions: FrigateCardPTZActions = {};
|
||||
const panTilt = ptzCapabilities?.panTilt;
|
||||
const zoom = ptzCapabilities?.zoom;
|
||||
const presets = ptzCapabilities?.presets;
|
||||
|
||||
if (panTilt?.length) {
|
||||
const continuous = panTilt.includes('continuous');
|
||||
defaultActions.actions_up = getDefaultActions('up', continuous);
|
||||
defaultActions.actions_down = getDefaultActions('down', continuous);
|
||||
defaultActions.actions_left = getDefaultActions('left', continuous);
|
||||
defaultActions.actions_right = getDefaultActions('right', continuous);
|
||||
}
|
||||
|
||||
if (zoom?.length) {
|
||||
const continuous = zoom.includes('continuous');
|
||||
defaultActions.actions_zoom_in = getDefaultActions('zoom_in', continuous);
|
||||
defaultActions.actions_zoom_out = getDefaultActions('zoom_out', continuous);
|
||||
}
|
||||
|
||||
if (presets?.length) {
|
||||
defaultActions.actions_home = getDefaultActions('preset', false, presets[0]);
|
||||
}
|
||||
|
||||
this._actions = {
|
||||
...defaultActions,
|
||||
...this._config,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request';
|
||||
import { PTZAction } from '../../config/ptz';
|
||||
import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types';
|
||||
import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action';
|
||||
import { PTZActionNameToMultiAction, PTZActionPresence } from './types';
|
||||
|
||||
export class PTZController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
private _config: PTZControlsConfig | null = null;
|
||||
private _hass: HomeAssistant | null = null;
|
||||
private _cameraManager: CameraManager | null = null;
|
||||
private _cameraID: string | null = null;
|
||||
|
||||
private _forceVisibility?: boolean;
|
||||
|
||||
constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setConfig(config?: PTZControlsConfig) {
|
||||
this._config = config ?? null;
|
||||
|
||||
this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal');
|
||||
this._host.setAttribute('data-position', config?.position ?? 'bottom-right');
|
||||
this._host.setAttribute(
|
||||
'style',
|
||||
Object.entries(config?.style ?? {})
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(';'),
|
||||
);
|
||||
}
|
||||
|
||||
public getConfig(): PTZControlsConfig | null {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public setCamera(cameraManager?: CameraManager, cameraID?: string): void {
|
||||
this._cameraManager = cameraManager ?? null;
|
||||
this._cameraID = cameraID ?? null;
|
||||
}
|
||||
|
||||
public setForceVisibility(forceVisibility?: boolean): void {
|
||||
this._forceVisibility = forceVisibility;
|
||||
}
|
||||
|
||||
public handleAction(
|
||||
ev: HASSDomEvent<{ action: string }>,
|
||||
config?: ActionsConfig | null,
|
||||
): void {
|
||||
// Nothing else has the configuration for this action, so don't let it
|
||||
// propagate further.
|
||||
ev.stopPropagation();
|
||||
|
||||
const interaction: string = ev.detail.action;
|
||||
const action = getActionConfigGivenAction(interaction, config);
|
||||
if (action) {
|
||||
dispatchActionExecutionRequest(this._host, {
|
||||
action: action,
|
||||
...(config && { config: config }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public hasUsefulAction(): PTZActionPresence {
|
||||
const allUsefulActions = {
|
||||
pt: true,
|
||||
z: true,
|
||||
home: true,
|
||||
};
|
||||
if (!this._cameraID) {
|
||||
// Will use digital PTZ.
|
||||
return allUsefulActions;
|
||||
}
|
||||
const capabilities = this._cameraManager?.getCameraCapabilities(this._cameraID);
|
||||
if (!capabilities || !capabilities.hasPTZCapability()) {
|
||||
// Will use digital PTZ.
|
||||
return allUsefulActions;
|
||||
}
|
||||
|
||||
const ptzCapabilities = capabilities.getPTZCapabilities();
|
||||
return {
|
||||
pt:
|
||||
!!ptzCapabilities?.up ||
|
||||
!!ptzCapabilities?.down ||
|
||||
!!ptzCapabilities?.left ||
|
||||
!!ptzCapabilities?.right,
|
||||
z: !!ptzCapabilities?.zoomIn || !!ptzCapabilities?.zoomOut,
|
||||
home: !!ptzCapabilities?.presets?.length,
|
||||
};
|
||||
}
|
||||
|
||||
public shouldDisplay(): boolean {
|
||||
return this._forceVisibility !== undefined
|
||||
? this._forceVisibility
|
||||
: this._config?.mode === 'auto'
|
||||
? !!this._cameraID &&
|
||||
!!this._cameraManager?.getCameraCapabilities(this._cameraID)?.hasPTZCapability()
|
||||
: this._config?.mode === 'on';
|
||||
}
|
||||
|
||||
public getPTZActions(): PTZActionNameToMultiAction {
|
||||
const getDefaultActions = (options?: {
|
||||
ptzAction?: PTZAction;
|
||||
preset?: string;
|
||||
}): Actions => ({
|
||||
start_tap_action: createPTZMultiAction({
|
||||
ptzAction: options?.ptzAction,
|
||||
ptzPhase: 'start',
|
||||
ptzPreset: options?.preset,
|
||||
}),
|
||||
end_tap_action: createPTZMultiAction({
|
||||
ptzAction: options?.ptzAction,
|
||||
ptzPhase: 'stop',
|
||||
ptzPreset: options?.preset,
|
||||
}),
|
||||
});
|
||||
|
||||
const actions: PTZActionNameToMultiAction = {};
|
||||
actions.up = getDefaultActions({
|
||||
ptzAction: 'up',
|
||||
});
|
||||
actions.down = getDefaultActions({
|
||||
ptzAction: 'down',
|
||||
});
|
||||
actions.left = getDefaultActions({
|
||||
ptzAction: 'left',
|
||||
});
|
||||
actions.right = getDefaultActions({
|
||||
ptzAction: 'right',
|
||||
});
|
||||
actions.zoom_in = getDefaultActions({
|
||||
ptzAction: 'zoom_in',
|
||||
});
|
||||
actions.zoom_out = getDefaultActions({
|
||||
ptzAction: 'zoom_out',
|
||||
});
|
||||
actions.home = {
|
||||
tap_action: createPTZMultiAction(),
|
||||
};
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PTZControlAction } from '../../config/ptz';
|
||||
import { Actions } from '../../config/types';
|
||||
|
||||
interface PTZControlsViewContext {
|
||||
enabled?: boolean;
|
||||
}
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
ptzControls?: PTZControlsViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
export type PTZActionNameToMultiAction = {
|
||||
[K in PTZControlAction]?: Actions;
|
||||
};
|
||||
|
||||
export interface PTZActionPresence {
|
||||
pt: boolean;
|
||||
z: boolean;
|
||||
home: boolean;
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
export interface ZoomConfig {
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
import { PartialDeep } from 'type-fest';
|
||||
|
||||
export const ZOOM_DEFAULT_PAN_X = 50;
|
||||
export const ZOOM_DEFAULT_PAN_Y = 50;
|
||||
export const ZOOM_DEFAULT_SCALE = 1;
|
||||
export const ZOOM_PRECISION = 4;
|
||||
|
||||
export interface ZoomSettingsBase {
|
||||
pan: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
zoom?: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface ZoomDefault {
|
||||
export type PartialZoomSettings = PartialDeep<ZoomSettingsBase>;
|
||||
|
||||
export interface ZoomSettingsObserved extends ZoomSettingsBase {
|
||||
isDefault: boolean;
|
||||
unzoomed: boolean;
|
||||
}
|
||||
|
||||
export const isZoomEmpty = (settings?: PartialZoomSettings | null): boolean => {
|
||||
return (
|
||||
settings?.pan?.x === undefined &&
|
||||
settings?.pan?.y === undefined &&
|
||||
settings?.zoom === undefined
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import round from 'lodash-es/round';
|
||||
import {
|
||||
arefloatsApproximatelyEqual,
|
||||
dispatchFrigateCardEvent,
|
||||
isHoverableDevice,
|
||||
} from '../../utils/basic';
|
||||
import { ZoomConfig } from './types';
|
||||
|
||||
const ZOOM_DEFAULT_PAN_X = 50;
|
||||
const ZOOM_DEFAULT_PAN_Y = 50;
|
||||
const ZOOM_DEFAULT_SCALE = 1;
|
||||
const ZOOM_PRECISION = 4;
|
||||
import {
|
||||
PartialZoomSettings,
|
||||
ZOOM_DEFAULT_PAN_X,
|
||||
ZOOM_DEFAULT_PAN_Y,
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
ZOOM_PRECISION,
|
||||
ZoomSettingsObserved,
|
||||
isZoomEmpty,
|
||||
} from './types';
|
||||
|
||||
export class ZoomController {
|
||||
protected _element: HTMLElement;
|
||||
@@ -26,15 +29,14 @@ export class ZoomController {
|
||||
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _defaultConfig: ZoomConfig | null;
|
||||
protected _config: ZoomConfig | null;
|
||||
protected _defaultSettings: PartialZoomSettings | null;
|
||||
protected _settings: PartialZoomSettings | null;
|
||||
|
||||
// When the user pans/zooms changes may be created at a very high rate.
|
||||
protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200);
|
||||
|
||||
// Multiple calls to setConfig() or setDefaultConfig() or resizes should only
|
||||
// update once.
|
||||
protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200);
|
||||
// These values should be suitably less than the value of STEP_DELAY_SECONDS
|
||||
// in the ptz-digital action, in order to ensure smooth movements of the
|
||||
// digital PTZ actions.
|
||||
protected _debouncedChangeHandler = throttle(this._changeHandler.bind(this), 50);
|
||||
protected _debouncedUpdater = throttle(this._updateBasedOnConfig.bind(this), 50);
|
||||
|
||||
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
|
||||
|
||||
@@ -70,6 +72,11 @@ export class ZoomController {
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
|
||||
// Even though the click is stopped,the card still needs to gain focus so
|
||||
// that keyboard shortcuts will work immediately after the card is clicked
|
||||
// upon.
|
||||
dispatchFrigateCardEvent(this._element, 'focus');
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
@@ -97,11 +104,14 @@ export class ZoomController {
|
||||
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
|
||||
options?: {
|
||||
config?: PartialZoomSettings | null;
|
||||
defaultConfig?: PartialZoomSettings | null;
|
||||
},
|
||||
) {
|
||||
this._element = element;
|
||||
this._config = options?.config ?? null;
|
||||
this._defaultConfig = options?.defaultConfig ?? null;
|
||||
this._settings = options?.config ?? null;
|
||||
this._defaultSettings = options?.defaultConfig ?? null;
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
@@ -182,44 +192,47 @@ export class ZoomController {
|
||||
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||
}
|
||||
|
||||
public setDefaultConfig(config: ZoomConfig | null): void {
|
||||
this._defaultConfig = config;
|
||||
public setDefaultSettings(config: PartialZoomSettings | null): void {
|
||||
this._defaultSettings = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
public setConfig(config: ZoomConfig): void {
|
||||
this._config = config;
|
||||
public setSettings(config: PartialZoomSettings | null): void {
|
||||
this._settings = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
protected _changeHandler(ev: Event): void {
|
||||
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
|
||||
|
||||
const isUnzoomed = this._isUnzoomed(pz.scale);
|
||||
const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale);
|
||||
const unzoomed = this._isUnzoomed(pz.scale);
|
||||
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (isUnzoomed && this._zoomed) {
|
||||
if (unzoomed && this._zoomed) {
|
||||
this._zoomed = false;
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
} else if (!isUnzoomed && !this._zoomed) {
|
||||
} else if (!unzoomed && !this._zoomed) {
|
||||
this._zoomed = true;
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
|
||||
if (isAtDefault && !this._default) {
|
||||
this._default = true;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true });
|
||||
} else if (!isAtDefault && this._default) {
|
||||
this._default = false;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false });
|
||||
}
|
||||
const converted = this._convertXYPanToPercent(pz.x, pz.y, pz.scale);
|
||||
const observed: ZoomSettingsObserved = {
|
||||
pan: {
|
||||
x: converted?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
y: converted?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
},
|
||||
zoom: pz.scale,
|
||||
isDefault: this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale),
|
||||
unzoomed: unzoomed,
|
||||
};
|
||||
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:change', observed);
|
||||
}
|
||||
|
||||
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
|
||||
protected _isZoomEqual(a: PartialZoomSettings, b: PartialZoomSettings): boolean {
|
||||
// The ?? clauses below cannot be reached since this function is only ever
|
||||
// used fully specified by this object. It's kept as-is for completeness.
|
||||
return (
|
||||
@@ -247,16 +260,8 @@ export class ZoomController {
|
||||
);
|
||||
}
|
||||
|
||||
protected _isZoomEmpty(config?: ZoomConfig | null): boolean {
|
||||
return (
|
||||
config?.pan?.x === undefined &&
|
||||
config?.pan?.y === undefined &&
|
||||
config?.zoom === undefined
|
||||
);
|
||||
}
|
||||
|
||||
protected _getConfigToUse(): ZoomConfig | null {
|
||||
return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config;
|
||||
protected _getConfigToUse(): PartialZoomSettings | null {
|
||||
return isZoomEmpty(this._settings) ? this._defaultSettings : this._settings;
|
||||
}
|
||||
|
||||
protected _updateBasedOnConfig(): void {
|
||||
@@ -292,6 +297,9 @@ export class ZoomController {
|
||||
}
|
||||
|
||||
this._panzoom.zoom(desiredScale, {
|
||||
// Zoom is stepped, not animated. If it is animated, there is interaction
|
||||
// between the zoom and the pan below, and the pan would need to be
|
||||
// delayed until after the zoom is complete.
|
||||
animate: false,
|
||||
});
|
||||
|
||||
@@ -300,10 +308,12 @@ export class ZoomController {
|
||||
// situation where we need to ensure the zoom completes first. Using
|
||||
// `requestAnimationFrame` appears to reliably allow the zoom to finish
|
||||
// rendering first, before the pain is applied.
|
||||
//
|
||||
// See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom
|
||||
window.requestAnimationFrame(() => {
|
||||
this._panzoom?.pan(x, y, {
|
||||
animate: false,
|
||||
animate: true,
|
||||
duration: 100,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -332,6 +342,28 @@ export class ZoomController {
|
||||
};
|
||||
}
|
||||
|
||||
protected _convertXYPanToPercent(
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
): { x: number; y: number } | null {
|
||||
const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale());
|
||||
if (minMax === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x:
|
||||
((-x + Math.abs(minMax.minX)) /
|
||||
(Math.abs(minMax.maxX) + Math.abs(minMax.minX))) *
|
||||
100,
|
||||
y:
|
||||
((-y + Math.abs(minMax.minY)) /
|
||||
(Math.abs(minMax.maxY) + Math.abs(minMax.minY))) *
|
||||
100,
|
||||
};
|
||||
}
|
||||
|
||||
protected _getTransformMinMax(
|
||||
desiredScale: number,
|
||||
currentScale?: number,
|
||||
@@ -375,14 +407,14 @@ export class ZoomController {
|
||||
}
|
||||
|
||||
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
|
||||
if (!this._defaultConfig) {
|
||||
if (!this._defaultSettings) {
|
||||
return this._isUnzoomed(scale);
|
||||
}
|
||||
|
||||
const convertedDefault = this._convertPercentToXYPan(
|
||||
this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
this._defaultSettings.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
this._defaultSettings.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
this._defaultSettings.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
);
|
||||
if (!convertedDefault) {
|
||||
return true;
|
||||
@@ -393,7 +425,7 @@ export class ZoomController {
|
||||
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
scale,
|
||||
this._defaultConfig.zoom ??
|
||||
this._defaultSettings.zoom ??
|
||||
// The ZOOM_DEFAULT_SCALE clause below cannot be reached since when
|
||||
// this._defaultConfig.zoom is undefined, convertedDefault will end up
|
||||
// null above and this function will have already returned.
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { ZoomConfig, ZoomDefault } from './types.js';
|
||||
import { dispatchViewContextChangeEvent } from '../../view/view.js';
|
||||
import { ZoomSettingsObserved, PartialZoomSettings } from './types.js';
|
||||
|
||||
interface ZoomViewContext {
|
||||
observed?: ZoomSettingsObserved;
|
||||
|
||||
// Populate this to request zoom to a particular scale/x/y. An empty object
|
||||
// will reset to default, null will make no change.
|
||||
zoom?: ZoomConfig | null;
|
||||
|
||||
// This will be populated with whether or not the current zoom is at the
|
||||
// default level.
|
||||
isDefault?: boolean;
|
||||
requested?: PartialZoomSettings | null;
|
||||
}
|
||||
|
||||
interface ZoomsViewContext {
|
||||
@@ -22,36 +20,37 @@ declare module 'view' {
|
||||
}
|
||||
}
|
||||
|
||||
export const generateViewContextForZoomChange = (
|
||||
export const generateViewContextForZoom = (
|
||||
targetID: string,
|
||||
options?: {
|
||||
zoom?: ZoomConfig | null;
|
||||
isDefault?: boolean;
|
||||
observed?: ZoomSettingsObserved;
|
||||
requested?: PartialZoomSettings | null;
|
||||
},
|
||||
): ViewContext | null => {
|
||||
return {
|
||||
zoom: {
|
||||
[targetID]: {
|
||||
zoom: options?.zoom ?? null,
|
||||
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
|
||||
observed: options?.observed ?? undefined,
|
||||
requested: options?.requested ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience wrapper to convert a zoom default into a dispatched view context
|
||||
* Convenience wrapper to convert zoom settings into a dispatched view context
|
||||
* change.
|
||||
*/
|
||||
export const handleZoomDefaultEvent = (
|
||||
export const handleZoomSettingsObservedEvent = (
|
||||
element: EventTarget,
|
||||
ev: CustomEvent<ZoomDefault>,
|
||||
ev: CustomEvent<ZoomSettingsObserved>,
|
||||
targetID?: string,
|
||||
): void => {
|
||||
targetID && dispatchViewContextChangeEvent(
|
||||
element,
|
||||
generateViewContextForZoomChange(targetID, {
|
||||
isDefault: ev.detail.isDefault,
|
||||
}),
|
||||
);
|
||||
targetID &&
|
||||
dispatchViewContextChangeEvent(
|
||||
element,
|
||||
generateViewContextForZoom(targetID, {
|
||||
observed: ev.detail,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
+38
-62
@@ -9,13 +9,10 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { CameraConfig, ImageViewConfig } from '../config/types.js';
|
||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -31,6 +28,7 @@ import {
|
||||
} from '../utils/media-info.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
|
||||
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||
@@ -46,6 +44,9 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
// Using contentsChanged to ensure overridden configs (e.g. when the
|
||||
// 'show_image_during_load' option is true for live views, an overridden
|
||||
// config may be used here).
|
||||
@@ -155,10 +156,6 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
() => dispatchMediaPauseEvent(this),
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('imageConfig') && this.imageConfig?.zoomable) {
|
||||
import('./zoomer.js');
|
||||
}
|
||||
}
|
||||
|
||||
// If the camera or view changed, immediately discard the old value (view to
|
||||
@@ -279,67 +276,46 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
}
|
||||
}
|
||||
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
const targetID = this.cameraConfig?.id;
|
||||
const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined;
|
||||
|
||||
return this.imageConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${zoomConfig}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, targetID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
: template;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const src = this._cachedValueController?.value;
|
||||
// Note the use of live() below to ensure the update will restore the image
|
||||
// src if it's been changed via _forceSafeImage().
|
||||
return src
|
||||
? this._useZoomIfRequired(html` <img
|
||||
${ref(this._refImage)}
|
||||
src=${live(src)}
|
||||
@load=${(ev: Event) => {
|
||||
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
|
||||
player: this,
|
||||
capabilities: {
|
||||
supportsPause: !!this.imageConfig?.refresh_seconds,
|
||||
},
|
||||
});
|
||||
// Avoid the media being reported as repeatedly loading unless the
|
||||
// media info changes.
|
||||
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
||||
this._mediaLoadedInfo = mediaLoadedInfo;
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
||||
}
|
||||
}}
|
||||
@error=${() => {
|
||||
if (this.imageConfig?.mode === 'camera') {
|
||||
// In camera mode, the user has likely not made an error, but HA
|
||||
// may be unavailble, so show the stock image. Don't let the URL
|
||||
// override the stock image in this case, as this could create an
|
||||
// error loop if that URL subsequently failed to load.
|
||||
this._forceSafeImage(true);
|
||||
} else if (this.imageConfig?.mode === 'url') {
|
||||
// In url mode, the user likely specified a URL that cannot be
|
||||
// resolved. Show an error message.
|
||||
dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
|
||||
context: this.imageConfig,
|
||||
? html`
|
||||
<img
|
||||
${ref(this._refImage)}
|
||||
src=${live(src)}
|
||||
@load=${(ev: Event) => {
|
||||
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
|
||||
player: this,
|
||||
capabilities: {
|
||||
supportsPause: !!this.imageConfig?.refresh_seconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>`)
|
||||
// Avoid the media being reported as repeatedly loading unless the
|
||||
// media info changes.
|
||||
if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) {
|
||||
this._mediaLoadedInfo = mediaLoadedInfo;
|
||||
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
|
||||
}
|
||||
}}
|
||||
@error=${() => {
|
||||
if (this.imageConfig?.mode === 'camera') {
|
||||
// In camera mode, the user has likely not made an error, but HA
|
||||
// may be unavailble, so show the stock image. Don't let the URL
|
||||
// override the stock image in this case, as this could create an
|
||||
// error loop if that URL subsequently failed to load.
|
||||
this._forceSafeImage(true);
|
||||
} else if (this.imageConfig?.mode === 'url') {
|
||||
// In url mode, the user likely specified a URL that cannot be
|
||||
// resolved. Show an error message.
|
||||
dispatchErrorMessageEvent(this, localize('error.image_load_error'), {
|
||||
context: this.imageConfig,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
`
|
||||
: html``;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { KeyAssignerController } from '../components-lib/key-assigner-controller';
|
||||
import { KeyboardShortcut } from '../config/keyboard-shortcuts';
|
||||
import keyAssignerStyle from '../scss/key-assigner.scss';
|
||||
import { localize } from '../localize/localize';
|
||||
|
||||
@customElement('frigate-card-key-assigner')
|
||||
export class FrigateCardKeyAssigner extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public value?: KeyboardShortcut | null;
|
||||
|
||||
protected _controller = new KeyAssignerController(this);
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('value')) {
|
||||
this._controller.setValue(this.value ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderKey = (key: string) => {
|
||||
return html`<div class="key">
|
||||
<div class="key-inner">${key}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="label">${this.label}</div>
|
||||
<ha-button
|
||||
class="assign"
|
||||
@click=${() => {
|
||||
this._controller.toggleAssigning();
|
||||
}}
|
||||
>
|
||||
<ha-icon icon="mdi:keyboard-settings"></ha-icon>
|
||||
<span class="${classMap({
|
||||
dotdotdot: this._controller.isAssigning(),
|
||||
})}">
|
||||
${
|
||||
this._controller.isAssigning()
|
||||
? localize('key_assigner.assigning')
|
||||
: localize('key_assigner.assign')
|
||||
}
|
||||
</span>
|
||||
</ha-button>
|
||||
${
|
||||
this._controller.hasValue()
|
||||
? html`<ha-button
|
||||
@click=${() => {
|
||||
this._controller.setValue(null);
|
||||
}}
|
||||
>
|
||||
<ha-icon icon="mdi:keyboard-off"></ha-icon>
|
||||
<span> ${localize('key_assigner.unassign')} </span>
|
||||
</ha-button>`
|
||||
: ''
|
||||
}
|
||||
<div class="key-row">
|
||||
${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''}
|
||||
${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''}
|
||||
${this.value?.meta ? renderKey(localize('key_assigner.modifiers.meta')) : ''}
|
||||
${this.value?.alt ? renderKey(localize('key_assigner.modifiers.alt')) : ''}
|
||||
${this.value?.key ? renderKey(this.value.key) : ''}
|
||||
</div>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(keyAssignerStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-key-assigner': FrigateCardKeyAssigner;
|
||||
}
|
||||
}
|
||||
+27
-23
@@ -21,8 +21,6 @@ import {
|
||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||
import { LiveController } from '../../components-lib/live/live-controller.js';
|
||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||
import { ZoomConfig, ZoomDefault } from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -62,6 +60,12 @@ import {
|
||||
FrigateCardTitleControl,
|
||||
getDefaultTitleConfigForView,
|
||||
} from '../title-control.js';
|
||||
import {
|
||||
PartialZoomSettings,
|
||||
ZoomSettingsObserved,
|
||||
} from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import { getStreamCameraID } from '../../utils/substream.js';
|
||||
|
||||
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
|
||||
|
||||
@@ -503,9 +507,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.liveConfig=${liveConfig}
|
||||
.hass=${this.hass}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.zoomConfig=${this.view?.context?.zoom?.[cameraID]?.zoom}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, cameraID)}
|
||||
.zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested}
|
||||
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||
handleZoomSettingsObservedEvent(this, ev, cameraID)}
|
||||
>
|
||||
</frigate-card-live-provider>
|
||||
</div>
|
||||
@@ -633,11 +637,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
</frigate-card-next-previous-control>
|
||||
</frigate-card-carousel>
|
||||
<frigate-card-ptz
|
||||
.hass=${this.hass}
|
||||
.config=${this.overriddenLiveConfig.controls.ptz}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameraID=${cameraID}
|
||||
.forceVisibility=${this._mediaHasLoaded && this.view.context?.live?.ptzVisible}
|
||||
.cameraID=${getStreamCameraID(this.view, cameraID)}
|
||||
.forceVisibility=${this._mediaHasLoaded &&
|
||||
this.view.context?.ptzControls?.enabled}
|
||||
>
|
||||
</frigate-card-ptz>
|
||||
${cameraMetadataCurrent && titleConfig
|
||||
@@ -694,7 +698,7 @@ export class FrigateCardLiveProvider
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
@property({ attribute: false })
|
||||
public zoomConfig?: ZoomConfig | null;
|
||||
public zoomSettings?: PartialZoomSettings | null;
|
||||
|
||||
@state()
|
||||
protected _isVideoMediaLoaded = false;
|
||||
@@ -857,7 +861,7 @@ export class FrigateCardLiveProvider
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
return this.liveConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
.defaultSettings=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
@@ -865,7 +869,7 @@ export class FrigateCardLiveProvider
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${this.zoomConfig}
|
||||
.settings=${this.zoomSettings}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
>
|
||||
@@ -944,17 +948,17 @@ export class FrigateCardLiveProvider
|
||||
</frigate-card-live-ha>`
|
||||
: provider === 'go2rtc'
|
||||
? html`<frigate-card-live-go2rtc
|
||||
${ref(this._refProvider)}
|
||||
class=${classMap(providerClasses)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.cameraEndpoints=${this.cameraEndpoints}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
?controls=${this.liveConfig.controls.builtin}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-webrtc-card>`
|
||||
${ref(this._refProvider)}
|
||||
class=${classMap(providerClasses)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.cameraEndpoints=${this.cameraEndpoints}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
?controls=${this.liveConfig.controls.builtin}
|
||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-live-go2rtc>`
|
||||
: provider === 'webrtc-card'
|
||||
? html`<frigate-card-live-webrtc-card
|
||||
${ref(this._refProvider)}
|
||||
@@ -995,7 +999,7 @@ export class FrigateCardLiveProvider
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
|
||||
'frigate-card-live-provider': FrigateCardLiveProvider;
|
||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||
'frigate-card-live-grid': FrigateCardLiveGrid;
|
||||
'frigate-card-live': FrigateCardLive;
|
||||
|
||||
+5
-10
@@ -46,7 +46,7 @@ export class FrigateCardMenu extends LitElement {
|
||||
return html` <frigate-card-submenu
|
||||
.hass=${this.hass}
|
||||
.submenu=${button}
|
||||
@action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
|
||||
@action=${(ev) => this._controller.actionHandler(ev)}
|
||||
>
|
||||
</frigate-card-submenu>`;
|
||||
} else if (button.type === 'custom:frigate-card-menu-submenu-select') {
|
||||
@@ -54,7 +54,7 @@ export class FrigateCardMenu extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.submenuSelect=${button}
|
||||
.entityRegistryManager=${this.entityRegistryManager}
|
||||
@action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
|
||||
@action=${(ev) => this._controller.actionHandler(ev)}
|
||||
>
|
||||
</frigate-card-submenu-select>`;
|
||||
}
|
||||
@@ -84,8 +84,7 @@ export class FrigateCardMenu extends LitElement {
|
||||
hasDoubleClick: frigateCardHasAction(button.double_tap_action),
|
||||
})}
|
||||
.label=${buttonState.title || ''}
|
||||
@action=${(ev) =>
|
||||
this.hass && this._controller.actionHandler(this.hass, ev, button)}
|
||||
@action=${(ev) => this._controller.actionHandler(ev, button)}
|
||||
>
|
||||
${svgPath
|
||||
? html`<ha-svg-icon .path="${svgPath}"></ha-svg-icon>`
|
||||
@@ -106,17 +105,13 @@ export class FrigateCardMenu extends LitElement {
|
||||
|
||||
return html` <div
|
||||
class="matching"
|
||||
style="${styleMap({
|
||||
flex: String(matchingButtons.length),
|
||||
})}"
|
||||
style="${styleMap({ flex: String(matchingButtons.length) })}"
|
||||
>
|
||||
${matchingButtons.map((button) => this._renderButton(button))}
|
||||
</div>
|
||||
<div
|
||||
class="opposing"
|
||||
style="${styleMap({
|
||||
flex: String(opposingButtons.length),
|
||||
})}"
|
||||
style="${styleMap({ flex: String(opposingButtons.length) })}"
|
||||
>
|
||||
${opposingButtons.map((button) => this._renderButton(button))}
|
||||
</div>`;
|
||||
|
||||
+38
-52
@@ -1,18 +1,19 @@
|
||||
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { HASSDomEvent } from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS
|
||||
html,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { PTZController } from '../components-lib/ptz-controller.js';
|
||||
import { Actions, FrigateCardPTZConfig } from '../config/types.js';
|
||||
import { PTZController } from '../components-lib/ptz/ptz-controller.js';
|
||||
import { PTZActionPresence } from '../components-lib/ptz/types.js';
|
||||
import { Actions, PTZControlsConfig } from '../config/types.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import ptzStyle from '../scss/ptz.scss';
|
||||
import { frigateCardHasAction } from '../utils/action.js';
|
||||
@@ -20,10 +21,7 @@ import { frigateCardHasAction } from '../utils/action.js';
|
||||
@customElement('frigate-card-ptz')
|
||||
export class FrigateCardPTZ extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: FrigateCardPTZConfig;
|
||||
public config?: PTZControlsConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
@@ -35,20 +33,22 @@ export class FrigateCardPTZ extends LitElement {
|
||||
public forceVisibility?: boolean;
|
||||
|
||||
protected _controller = new PTZController(this);
|
||||
protected _actions = this._controller.getPTZActions();
|
||||
protected _actionPresence: PTZActionPresence | null = null;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('config')) {
|
||||
this._controller.setConfig(this.config);
|
||||
}
|
||||
if (changedProps.has('hass')) {
|
||||
this._controller.setHASS(this.hass);
|
||||
}
|
||||
if (changedProps.has('cameraManager') || changedProps.has('cameraID')) {
|
||||
this._controller.setCamera(this.cameraManager, this.cameraID);
|
||||
}
|
||||
if (changedProps.has('forceVisibility')) {
|
||||
this._controller.setForceVisibility(this.forceVisibility);
|
||||
}
|
||||
if (changedProps.has('cameraID') || changedProps.has('cameraManager')) {
|
||||
this._actionPresence = this._controller.hasUsefulAction();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
@@ -59,62 +59,48 @@ export class FrigateCardPTZ extends LitElement {
|
||||
const renderIcon = (
|
||||
name: string,
|
||||
icon: string,
|
||||
actions: Actions | null,
|
||||
actions?: Actions | null,
|
||||
): TemplateResult => {
|
||||
const classes = {
|
||||
[name]: true,
|
||||
disabled: !actions,
|
||||
};
|
||||
|
||||
return html`<ha-icon
|
||||
class=${classMap(classes)}
|
||||
icon=${icon}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: frigateCardHasAction(actions?.hold_action),
|
||||
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
|
||||
})}
|
||||
.title=${localize(`elements.ptz.${name}`)}
|
||||
@action=${(ev: HASSDomEvent<{ action: string }>) =>
|
||||
this._controller.handleAction(ev, actions)}
|
||||
></ha-icon>`;
|
||||
return actions
|
||||
? html`<ha-icon
|
||||
class=${classMap(classes)}
|
||||
icon=${icon}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: frigateCardHasAction(actions?.hold_action),
|
||||
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
|
||||
})}
|
||||
.title=${localize(`elements.ptz.${name}`)}
|
||||
@action=${(ev: HASSDomEvent<{ action: string }>) =>
|
||||
this._controller.handleAction(ev, actions)}
|
||||
></ha-icon>`
|
||||
: html``;
|
||||
};
|
||||
|
||||
const config = this._controller.getConfig();
|
||||
const actionsZoomIn = this._controller.getPTZActions('zoom_in');
|
||||
const actionsZoomOut = this._controller.getPTZActions('zoom_out');
|
||||
const actionsHome = this._controller.getPTZActions('home');
|
||||
|
||||
return html` <div class="ptz">
|
||||
${!config?.hide_pan_tilt
|
||||
${!config?.hide_pan_tilt && this._actionPresence?.pt
|
||||
? html`<div class="ptz-move">
|
||||
${renderIcon(
|
||||
'right',
|
||||
'mdi:arrow-right',
|
||||
this._controller.getPTZActions('right'),
|
||||
)}
|
||||
${renderIcon(
|
||||
'left',
|
||||
'mdi:arrow-left',
|
||||
this._controller.getPTZActions('left'),
|
||||
)}
|
||||
${renderIcon('up', 'mdi:arrow-up', this._controller.getPTZActions('up'))}
|
||||
${renderIcon(
|
||||
'down',
|
||||
'mdi:arrow-down',
|
||||
this._controller.getPTZActions('down'),
|
||||
)}
|
||||
${renderIcon('right', 'mdi:arrow-right', this._actions.right)}
|
||||
${renderIcon('left', 'mdi:arrow-left', this._actions.left)}
|
||||
${renderIcon('up', 'mdi:arrow-up', this._actions.up)}
|
||||
${renderIcon('down', 'mdi:arrow-down', this._actions.down)}
|
||||
</div>`
|
||||
: ''}
|
||||
${!config?.hide_zoom && (actionsZoomIn || actionsZoomOut)
|
||||
${!config?.hide_zoom && this._actionPresence?.z
|
||||
? html` <div class="ptz-zoom">
|
||||
${renderIcon('zoom_in', 'mdi:plus', actionsZoomIn)}
|
||||
${renderIcon('zoom_out', 'mdi:minus', actionsZoomOut)}
|
||||
${renderIcon('zoom_in', 'mdi:plus', this._actions.zoom_in)}
|
||||
${renderIcon('zoom_out', 'mdi:minus', this._actions.zoom_out)}
|
||||
</div>`
|
||||
: html``}
|
||||
${!config?.hide_home && actionsHome
|
||||
? html`
|
||||
<div class="ptz-home">${renderIcon('home', 'mdi:home', actionsHome)}</div>
|
||||
`
|
||||
${!config?.hide_home && this._actionPresence?.home
|
||||
? html`<div class="ptz-home">
|
||||
${renderIcon('home', 'mdi:home', this._actions.home)}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
dispatchMessageEvent,
|
||||
renderMessage,
|
||||
@@ -76,6 +76,7 @@ import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||
import { View } from '../view/view.js';
|
||||
import type { EmblaCarouselPlugins } from './carousel.js';
|
||||
import './next-prev-control.js';
|
||||
import './ptz';
|
||||
import './surround.js';
|
||||
import './title-control.js';
|
||||
import {
|
||||
@@ -501,6 +502,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</frigate-card-carousel>
|
||||
${this.view
|
||||
? html` <frigate-card-ptz
|
||||
.config=${this.viewerConfig?.controls.ptz}
|
||||
.forceVisibility=${this.view?.context?.ptzControls?.enabled}
|
||||
>
|
||||
</frigate-card-ptz>`
|
||||
: ''}
|
||||
<div class="seek-warning">
|
||||
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
|
||||
</ha-icon>
|
||||
@@ -876,7 +884,7 @@ export class FrigateCardViewerProvider
|
||||
|
||||
return this.viewerConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([cameraConfig?.dimensions?.layout], () =>
|
||||
.defaultSettings=${guard([cameraConfig?.dimensions?.layout], () =>
|
||||
cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: cameraConfig.dimensions.layout.pan,
|
||||
@@ -884,11 +892,13 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${mediaID ? this.view?.context?.zoom?.[mediaID]?.zoom : undefined}
|
||||
.settings=${mediaID
|
||||
? this.view?.context?.zoom?.[mediaID]?.requested
|
||||
: undefined}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, mediaID)}
|
||||
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||
handleZoomSettingsObservedEvent(this, ev, mediaID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
@@ -995,6 +1005,6 @@ declare global {
|
||||
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
||||
'frigate-card-viewer': FrigateCardViewer;
|
||||
'frigate-card-viewer-grid': FrigateCardViewerGrid;
|
||||
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
|
||||
'frigate-card-viewer-provider': FrigateCardViewerProvider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class FrigateCardViews extends LitElement {
|
||||
.view=${this.view}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${cameraConfig}
|
||||
.supportZoom=${true}
|
||||
.cameraManager=${this.cameraManager}
|
||||
>
|
||||
</frigate-card-image>`
|
||||
: ``}
|
||||
|
||||
@@ -7,19 +7,19 @@ import {
|
||||
TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ZoomConfig } from '../components-lib/zoom/types.js';
|
||||
import { ZoomController } from '../components-lib/zoom/zoom-controller.js';
|
||||
import { setOrRemoveAttribute } from '../utils/basic.js';
|
||||
import { PartialZoomSettings } from '../components-lib/zoom/types.js';
|
||||
|
||||
@customElement('frigate-card-zoomer')
|
||||
export class FrigateCardZoomer extends LitElement {
|
||||
protected _zoom: ZoomController | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public defaultConfig?: ZoomConfig;
|
||||
public defaultSettings?: PartialZoomSettings;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: ZoomConfig | null;
|
||||
public settings?: PartialZoomSettings | null;
|
||||
|
||||
@state()
|
||||
protected _zoomed = false;
|
||||
@@ -48,19 +48,19 @@ export class FrigateCardZoomer extends LitElement {
|
||||
}
|
||||
|
||||
if (this._zoom) {
|
||||
if (changedProps.has('defaultConfig')) {
|
||||
this._zoom.setDefaultConfig(this.defaultConfig ?? null);
|
||||
if (changedProps.has('defaultSettings')) {
|
||||
this._zoom.setDefaultSettings(this.defaultSettings ?? null);
|
||||
}
|
||||
// If config is null, make no change to the zoom.
|
||||
if (changedProps.has('config') && this.config) {
|
||||
this._zoom.setConfig(this.config);
|
||||
if (changedProps.has('settings') && this.settings) {
|
||||
this._zoom.setSettings(this.settings);
|
||||
}
|
||||
} else {
|
||||
// Ensure that the configuration will be set before activation (vs
|
||||
// activating in `connectedCallback`).
|
||||
this._zoom = new ZoomController(this, {
|
||||
config: this.config,
|
||||
defaultConfig: this.defaultConfig,
|
||||
config: this.settings,
|
||||
defaultConfig: this.defaultSettings,
|
||||
});
|
||||
this._zoom.activate();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const keyboardShortcut = z.object({
|
||||
key: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
alt: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
});
|
||||
export type KeyboardShortcut = z.infer<typeof keyboardShortcut>;
|
||||
|
||||
export const keyboardShortcutsDefault = {
|
||||
enabled: true,
|
||||
ptz_left: { key: 'ArrowLeft' },
|
||||
ptz_right: { key: 'ArrowRight' },
|
||||
ptz_up: { key: 'ArrowUp' },
|
||||
ptz_down: { key: 'ArrowDown' },
|
||||
ptz_zoom_in: { key: '+' },
|
||||
ptz_zoom_out: { key: '-' },
|
||||
ptz_home: { key: 'h' },
|
||||
};
|
||||
|
||||
export const keyboardShortcutsSchema = z.object({
|
||||
enabled: z.boolean().default(keyboardShortcutsDefault.enabled),
|
||||
ptz_left: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_left),
|
||||
ptz_right: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_right),
|
||||
ptz_up: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_up),
|
||||
ptz_down: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_down),
|
||||
ptz_zoom_in: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_zoom_in),
|
||||
ptz_zoom_out: keyboardShortcut
|
||||
.nullable()
|
||||
.default(keyboardShortcutsDefault.ptz_zoom_out),
|
||||
ptz_home: keyboardShortcut.nullable().default(keyboardShortcutsDefault.ptz_home),
|
||||
});
|
||||
export type KeyboardShortcuts = z.infer<typeof keyboardShortcutsSchema>;
|
||||
|
||||
|
||||
const KEYBOARD_SHORTCUT_PTZ_NAMES = [
|
||||
'ptz_down',
|
||||
'ptz_home',
|
||||
'ptz_left',
|
||||
'ptz_right',
|
||||
'ptz_up',
|
||||
'ptz_zoom_in',
|
||||
'ptz_zoom_out',
|
||||
] as const;
|
||||
export type PTZKeyboardShortcutName = (typeof KEYBOARD_SHORTCUT_PTZ_NAMES)[number];
|
||||
+117
-1
@@ -14,6 +14,7 @@ import {
|
||||
CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT,
|
||||
CONF_CAMERAS_GLOBAL_IMAGE,
|
||||
CONF_CAMERAS_GLOBAL_JSMPEG,
|
||||
CONF_CAMERAS_GLOBAL_PTZ,
|
||||
CONF_CAMERAS_GLOBAL_WEBRTC_CARD,
|
||||
CONF_ELEMENTS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
|
||||
@@ -545,6 +546,116 @@ const upgradePTZElementsToLive = function (): (data: unknown) => boolean {
|
||||
};
|
||||
};
|
||||
|
||||
const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const NON_PRESET_DATA_KEYS = [
|
||||
'data_left',
|
||||
'data_right',
|
||||
'data_up',
|
||||
'data_down',
|
||||
'data_zoom_in',
|
||||
'data_zoom_out',
|
||||
'service',
|
||||
];
|
||||
|
||||
const NON_PRESET_ACTION_KEYS = [
|
||||
// 'actions_' will overwrite 'data_*' if there's duplication.
|
||||
'actions_left',
|
||||
'actions_right',
|
||||
'actions_up',
|
||||
'actions_down',
|
||||
'actions_zoom_in',
|
||||
'actions_zoom_out',
|
||||
];
|
||||
|
||||
const PRESET_TRANSFORM_KEYS = ['data_home', 'actions_home'];
|
||||
const TRANSFORM_KEYS = [
|
||||
...NON_PRESET_DATA_KEYS,
|
||||
...NON_PRESET_ACTION_KEYS,
|
||||
...PRESET_TRANSFORM_KEYS,
|
||||
];
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const hasTransformable = keys.some((key) => TRANSFORM_KEYS.includes(key));
|
||||
if (!hasTransformable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const output = {};
|
||||
|
||||
NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => {
|
||||
obj[key] = data[key];
|
||||
return obj;
|
||||
}, output);
|
||||
|
||||
NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => {
|
||||
if (typeof data[key] === 'object' && 'tap_action' in data[key]) {
|
||||
obj[key] = data[key]['tap_action'];
|
||||
}
|
||||
return obj;
|
||||
}, output);
|
||||
|
||||
const createPresets = () => {
|
||||
output['presets'] =
|
||||
'presets' in data && typeof data['presets'] === 'object' && !!data['presets']
|
||||
? data['presets']
|
||||
: {};
|
||||
};
|
||||
|
||||
if (
|
||||
'actions_home' in data &&
|
||||
typeof data['actions_home'] === 'object' &&
|
||||
data['actions_home'] &&
|
||||
'tap_action' in data['actions_home']
|
||||
) {
|
||||
createPresets();
|
||||
output['presets']['home'] = data['actions_home']['tap_action'];
|
||||
} else if (
|
||||
'data_home' in data &&
|
||||
typeof data['data_home'] === 'object' &&
|
||||
data['data_home'] &&
|
||||
typeof data['service'] === 'string'
|
||||
) {
|
||||
createPresets();
|
||||
output['presets']['service'] = data['service'];
|
||||
output['presets']['data_home'] = data['data_home'];
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const ptzControlSettingsTransform = (data: unknown): unknown => {
|
||||
if (typeof data !== 'object' || !data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const TRANSFORM_KEYS = [
|
||||
'mode',
|
||||
'position',
|
||||
'orientation',
|
||||
'hide_pan_tilt',
|
||||
'hide_zoom',
|
||||
'hide_home',
|
||||
'style',
|
||||
];
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const hasSomethingToFilter = keys.some((key) => !TRANSFORM_KEYS.includes(key));
|
||||
if (!hasSomethingToFilter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return keys
|
||||
.filter((key) => TRANSFORM_KEYS.includes(key))
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = data[key];
|
||||
return obj;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const UPGRADES = [
|
||||
// v4.0.0 -> v4.1.0
|
||||
upgradeArrayOfObjects(
|
||||
@@ -681,7 +792,7 @@ const UPGRADES = [
|
||||
upgradeArrayOfObjects(
|
||||
CONF_CAMERAS,
|
||||
upgradeMoveToWithOverrides('hide', 'capabilities', {
|
||||
transform: (val) => (val === true ? { disable_except: 'substream' } : null),
|
||||
transform: (val) => (val === true ? { disable_except: ['substream'] } : null),
|
||||
}),
|
||||
),
|
||||
upgradeMoveToWithOverrides('performance.profile', CONF_PROFILES, {
|
||||
@@ -689,4 +800,9 @@ const UPGRADES = [
|
||||
transform: (val) => (val === 'low' ? ['low-performance'] : null),
|
||||
}),
|
||||
upgradeArrayOfObjects(CONF_OVERRIDES, upgradeMoveTo('overrides', 'merge')),
|
||||
upgradeMoveToWithOverrides('live.controls.ptz', CONF_CAMERAS_GLOBAL_PTZ, {
|
||||
transform: ptzActionsToCamerasGlobalTransform,
|
||||
keepOriginal: true,
|
||||
}),
|
||||
upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const;
|
||||
export const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const;
|
||||
const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const;
|
||||
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
|
||||
|
||||
// PTZ actions as used by the PTZ control (includes a 'home' button).
|
||||
const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const;
|
||||
export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number];
|
||||
|
||||
// PTZ actions as used by the camera manager (includes generic presets).
|
||||
export const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const;
|
||||
export type PTZAction = (typeof PTZ_ACTIONS)[number];
|
||||
+263
-128
@@ -11,6 +11,11 @@ import { z } from 'zod';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js';
|
||||
import { capabilityKeys } from '../types.js';
|
||||
import { deepRemoveDefaults } from '../utils/zod.js';
|
||||
import {
|
||||
keyboardShortcutsDefault,
|
||||
keyboardShortcutsSchema,
|
||||
} from './keyboard-shortcuts.js';
|
||||
import { PTZ_ACTIONS } from './ptz';
|
||||
|
||||
// *************************************************************************
|
||||
// Common Configuration Constants
|
||||
@@ -23,6 +28,7 @@ const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
|
||||
export const FRIGATE_MENU_PRIORITY_MAX = 100;
|
||||
|
||||
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||
'diagnostics',
|
||||
'live',
|
||||
'clip',
|
||||
'clips',
|
||||
@@ -67,18 +73,8 @@ export const MEDIA_MUTE_CONDITIONS = [
|
||||
] as const;
|
||||
export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number];
|
||||
|
||||
const PTZ_BASE_ACTIONS = ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out'] as const;
|
||||
|
||||
// PTZ actions as used by the PTZ control (includes a 'home' button).
|
||||
export const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const;
|
||||
export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number];
|
||||
|
||||
// PTZ actions as used by the camera manager (includes generic presets).
|
||||
const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const;
|
||||
export type PTZAction = (typeof PTZ_ACTIONS)[number];
|
||||
|
||||
const PTZ_PHASES = ['start', 'stop'] as const;
|
||||
export type PTZPhase = (typeof PTZ_PHASES)[number];
|
||||
const ACTION_PHASES = ['start', 'stop'] as const;
|
||||
export type ActionPhase = (typeof ACTION_PHASES)[number];
|
||||
|
||||
const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
// An event whether or not it has any media yet associated with it.
|
||||
@@ -90,15 +86,20 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
] as const;
|
||||
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
||||
|
||||
const cardIDRegex = /^[-\w]+$/;
|
||||
|
||||
// *************************************************************************
|
||||
// Pan / Zoom
|
||||
// *************************************************************************
|
||||
|
||||
export const ZOOM_MIN = 1;
|
||||
export const ZOOM_MAX = 10;
|
||||
|
||||
const panSchema = z.object({
|
||||
x: z.number().min(0).max(100).optional(),
|
||||
y: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
const zoomSchema = z.number().min(1).max(10);
|
||||
const zoomSchema = z.number().min(ZOOM_MIN).max(ZOOM_MAX);
|
||||
|
||||
// *************************************************************************
|
||||
// View Display Mode
|
||||
@@ -224,7 +225,7 @@ export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({
|
||||
// Card this command is intended for.
|
||||
card_id: z
|
||||
.string()
|
||||
.regex(/^\w+$/, 'card_id parameter can only contain [a-z][A-Z][0-9_]')
|
||||
.regex(cardIDRegex, 'card_id parameter can only contain [a-z][A-Z][0-9_]-')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -235,7 +236,6 @@ export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({
|
||||
const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
'camera_ui',
|
||||
'default',
|
||||
'diagnostics',
|
||||
'download',
|
||||
'expand',
|
||||
'fullscreen',
|
||||
@@ -252,70 +252,120 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
] as const;
|
||||
export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number];
|
||||
|
||||
const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
const viewActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED),
|
||||
});
|
||||
export type FrigateCardViewAction = z.infer<typeof frigateCardViewActionSchema>;
|
||||
export type ViewActionConfig = z.infer<typeof viewActionConfigSchema>;
|
||||
|
||||
const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
const generalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
|
||||
});
|
||||
export type GeneralActionConfig = z.infer<typeof generalActionConfigSchema>;
|
||||
|
||||
const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
const cameraSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('camera_select'),
|
||||
camera: z.string().optional(),
|
||||
triggered: z.boolean().optional(),
|
||||
});
|
||||
export type CameraSelectActionConfig = z.infer<typeof cameraSelectActionConfigSchema>;
|
||||
|
||||
const frigateCardLiveDependencySelectActionSchema =
|
||||
frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('live_substream_select'),
|
||||
camera: z.string(),
|
||||
});
|
||||
const substreamSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('live_substream_select'),
|
||||
camera: z.string(),
|
||||
});
|
||||
export type SubstreamSelectActionConfig = z.infer<
|
||||
typeof substreamSelectActionConfigSchema
|
||||
>;
|
||||
|
||||
const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
const mediaPlayerActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('media_player'),
|
||||
media_player: z.string(),
|
||||
media_player_action: z.enum(['play', 'stop']),
|
||||
});
|
||||
export type MediaPlayerActionConfig = z.infer<typeof mediaPlayerActionConfigSchema>;
|
||||
|
||||
const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend(
|
||||
{
|
||||
frigate_card_action: z.literal('display_mode_select'),
|
||||
display_mode: viewDisplayModeSchema,
|
||||
},
|
||||
);
|
||||
const viewDisplayModeActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('display_mode_select'),
|
||||
display_mode: viewDisplayModeSchema,
|
||||
});
|
||||
export type DisplayModeActionConfig = z.infer<typeof viewDisplayModeActionConfigSchema>;
|
||||
|
||||
const frigateCardPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
const ptzActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('ptz'),
|
||||
ptz_action: z.enum(PTZ_ACTIONS),
|
||||
ptz_phase: z.enum(PTZ_PHASES).optional(),
|
||||
camera: z.string().optional(),
|
||||
ptz_action: z.enum(PTZ_ACTIONS).optional(),
|
||||
ptz_phase: z.enum(ACTION_PHASES).optional(),
|
||||
ptz_preset: z.string().optional(),
|
||||
});
|
||||
export type FrigateCardPTZAction = z.infer<typeof frigateCardPTZActionSchema>;
|
||||
export type PTZActionConfig = z.infer<typeof ptzActionConfigSchema>;
|
||||
|
||||
const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('show_ptz'),
|
||||
show_ptz: z.boolean(),
|
||||
const ptzDigitalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('ptz_digital'),
|
||||
target_id: z.string().optional(),
|
||||
absolute: z
|
||||
.object({
|
||||
zoom: zoomSchema.optional(),
|
||||
pan: panSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
ptz_action: z.enum(PTZ_ACTIONS).optional(),
|
||||
ptz_phase: z.enum(ACTION_PHASES).optional(),
|
||||
});
|
||||
export type PTZDigitialActionConfig = z.infer<typeof ptzDigitalActionConfigSchema>;
|
||||
|
||||
const frigateCardChangeZoomActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('change_zoom'),
|
||||
target_id: z.string(),
|
||||
zoom: zoomSchema.optional(),
|
||||
pan: panSchema.optional(),
|
||||
const ptzMultiActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('ptz_multi'),
|
||||
target_id: z.string().optional(),
|
||||
|
||||
ptz_action: z.enum(PTZ_ACTIONS).optional(),
|
||||
ptz_phase: z.enum(ACTION_PHASES).optional(),
|
||||
ptz_preset: z.string().optional(),
|
||||
});
|
||||
export type PTZMultiActionConfig = z.infer<typeof ptzMultiActionSchema>;
|
||||
|
||||
const ptzControlsActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('ptz_controls'),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type PTZControlsActionConfig = z.infer<typeof ptzControlsActionConfigSchema>;
|
||||
|
||||
const timeDeltaSchema = z.object({
|
||||
ms: z.number().optional(),
|
||||
s: z.number().optional(),
|
||||
m: z.number().optional(),
|
||||
h: z.number().optional(),
|
||||
});
|
||||
export type TimeDelta = z.infer<typeof timeDeltaSchema>;
|
||||
|
||||
const sleepActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('sleep'),
|
||||
duration: timeDeltaSchema.optional().default({ s: 1 }),
|
||||
});
|
||||
export type SleepActionConfig = z.infer<typeof sleepActionConfigSchema>;
|
||||
|
||||
const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const;
|
||||
export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number];
|
||||
|
||||
const logActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('log'),
|
||||
message: z.string(),
|
||||
level: z.enum(LOG_ACTIONS_LEVELS).default('info'),
|
||||
});
|
||||
export type LogActionConfig = z.infer<typeof logActionConfigSchema>;
|
||||
|
||||
export const frigateCardCustomActionSchema = z.union([
|
||||
frigateCardCameraSelectActionSchema,
|
||||
frigateCardChangeZoomActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardLiveDependencySelectActionSchema,
|
||||
frigateCardMediaPlayerActionSchema,
|
||||
frigateCardPTZActionSchema,
|
||||
frigateCardShowPTZActionSchema,
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
cameraSelectActionConfigSchema,
|
||||
generalActionConfigSchema,
|
||||
substreamSelectActionConfigSchema,
|
||||
logActionConfigSchema,
|
||||
mediaPlayerActionConfigSchema,
|
||||
ptzActionConfigSchema,
|
||||
ptzDigitalActionConfigSchema,
|
||||
ptzMultiActionSchema,
|
||||
ptzControlsActionConfigSchema,
|
||||
viewActionConfigSchema,
|
||||
viewDisplayModeActionConfigSchema,
|
||||
sleepActionConfigSchema,
|
||||
]);
|
||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||
|
||||
@@ -572,6 +622,15 @@ const microphoneConditionSchema = z.object({
|
||||
connected: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
});
|
||||
const keyConditionSchema = z.object({
|
||||
condition: z.literal('key'),
|
||||
key: z.string(),
|
||||
state: z.enum(['down', 'up']).optional(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
alt: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const frigateCardConditionSchema = z.discriminatedUnion('condition', [
|
||||
// Stock conditions:
|
||||
@@ -590,6 +649,7 @@ export const frigateCardConditionSchema = z.discriminatedUnion('condition', [
|
||||
triggeredConditionSchema,
|
||||
interactionConditionSchema,
|
||||
microphoneConditionSchema,
|
||||
keyConditionSchema,
|
||||
]);
|
||||
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
|
||||
|
||||
@@ -664,6 +724,129 @@ const aspectRatioSchema = z
|
||||
.transform((input) => input.split(/[:\/]/).map((d) => Number(d))),
|
||||
);
|
||||
|
||||
// *************************************************************************
|
||||
// PTZ Configuration
|
||||
// *************************************************************************
|
||||
|
||||
const ptzCameraConfigDefaults = {
|
||||
r2c_delay_between_calls_seconds: 0.5,
|
||||
c2r_delay_between_calls_seconds: 0.2,
|
||||
};
|
||||
|
||||
// To avoid lots of YAML duplication, provide an easy way to just specify the
|
||||
// service data as actions for each PTZ action, and it will be preprocessed
|
||||
// into the full form. This also provides compatability with the AlexIT/WebRTC
|
||||
// PTZ configuration.
|
||||
const dataPTZFormatToFullFormat = function (
|
||||
suffix: string,
|
||||
): (data: unknown) => unknown {
|
||||
return (data) => {
|
||||
if (!data || typeof data !== 'object' || !data['service']) {
|
||||
return data;
|
||||
}
|
||||
const out = { ...data };
|
||||
Object.keys(data).forEach((key) => {
|
||||
const match = key.match(/^data_(.+)$/);
|
||||
const name = match?.[1];
|
||||
if (name && !(`${suffix}${name}` in data)) {
|
||||
out[`${suffix}${name}`] = {
|
||||
action: 'call-service',
|
||||
service: data['service'],
|
||||
data: data[key],
|
||||
};
|
||||
delete out[key];
|
||||
delete out['service'];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
};
|
||||
|
||||
const ptzCameraConfigSchema = z.preprocess(
|
||||
dataPTZFormatToFullFormat('actions_'),
|
||||
z
|
||||
.object({
|
||||
actions_left: callServiceActionSchema.optional(),
|
||||
actions_left_start: callServiceActionSchema.optional(),
|
||||
actions_left_stop: callServiceActionSchema.optional(),
|
||||
|
||||
actions_right: callServiceActionSchema.optional(),
|
||||
actions_right_start: callServiceActionSchema.optional(),
|
||||
actions_right_stop: callServiceActionSchema.optional(),
|
||||
|
||||
actions_up: callServiceActionSchema.optional(),
|
||||
actions_up_start: callServiceActionSchema.optional(),
|
||||
actions_up_stop: callServiceActionSchema.optional(),
|
||||
|
||||
actions_down: callServiceActionSchema.optional(),
|
||||
actions_down_start: callServiceActionSchema.optional(),
|
||||
actions_down_stop: callServiceActionSchema.optional(),
|
||||
|
||||
actions_zoom_in: callServiceActionSchema.optional(),
|
||||
actions_zoom_in_start: callServiceActionSchema.optional(),
|
||||
actions_zoom_in_stop: callServiceActionSchema.optional(),
|
||||
|
||||
actions_zoom_out: callServiceActionSchema.optional(),
|
||||
actions_zoom_out_start: callServiceActionSchema.optional(),
|
||||
actions_zoom_out_stop: callServiceActionSchema.optional(),
|
||||
|
||||
// The number of seconds between subsequent relative calls when converting a
|
||||
// relative request into a continuous request.
|
||||
r2c_delay_between_calls_seconds: z
|
||||
.number()
|
||||
.default(ptzCameraConfigDefaults.r2c_delay_between_calls_seconds),
|
||||
|
||||
// The number of seconds between the start/stop call when converting a
|
||||
// continuous request into a relative request.
|
||||
c2r_delay_between_calls_seconds: z
|
||||
.number()
|
||||
.default(ptzCameraConfigDefaults.c2r_delay_between_calls_seconds),
|
||||
|
||||
presets: z
|
||||
.preprocess(
|
||||
dataPTZFormatToFullFormat(''),
|
||||
z.union([
|
||||
z.record(callServiceActionSchema),
|
||||
|
||||
// This is used by the data_ style of action.
|
||||
z.object({ service: z.string().optional() }),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
// This is used by the data_ style of action.
|
||||
service: z.string().optional(),
|
||||
})
|
||||
// We allow passthrough as there may be user-configured presets as "actions_<preset>" .
|
||||
.passthrough(),
|
||||
);
|
||||
|
||||
const ptzControlsDefaults = {
|
||||
orientation: 'horizontal' as const,
|
||||
mode: 'auto' as const,
|
||||
hide_pan_tilt: false,
|
||||
hide_zoom: false,
|
||||
hide_home: false,
|
||||
position: 'bottom-right' as const,
|
||||
};
|
||||
|
||||
export const ptzControlsConfigSchema = z.object({
|
||||
mode: z.enum(['off', 'auto', 'on']).default(ptzControlsDefaults.mode),
|
||||
position: z
|
||||
.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right'])
|
||||
.default(ptzControlsDefaults.position),
|
||||
orientation: z
|
||||
.enum(['vertical', 'horizontal'])
|
||||
.default(ptzControlsDefaults.orientation),
|
||||
|
||||
hide_pan_tilt: z.boolean().default(ptzControlsDefaults.hide_pan_tilt),
|
||||
hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom),
|
||||
hide_home: z.boolean().default(ptzControlsDefaults.hide_home),
|
||||
|
||||
style: z.object({}).passthrough().optional(),
|
||||
});
|
||||
export type PTZControlsConfig = z.infer<typeof ptzControlsConfigSchema>;
|
||||
|
||||
// *************************************************************************
|
||||
// Image Configuration
|
||||
// Image config base options are used both for the `image` live provider and the
|
||||
@@ -683,13 +866,15 @@ const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
||||
const imageConfigDefault = {
|
||||
mode: 'url' as const,
|
||||
zoomable: true,
|
||||
controls: {
|
||||
ptz: ptzControlsDefaults,
|
||||
},
|
||||
...imageBaseConfigDefault,
|
||||
};
|
||||
|
||||
const imageConfigSchema = imageBaseConfigSchema
|
||||
.extend({
|
||||
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
|
||||
zoomable: z.boolean().default(imageConfigDefault.zoomable),
|
||||
})
|
||||
.merge(actionsSchema)
|
||||
.default(imageConfigDefault);
|
||||
@@ -929,70 +1114,6 @@ const jsmpegConfigSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const frigateCardPTZActions = z.object({
|
||||
actions_left: actionsBaseSchema.optional(),
|
||||
actions_right: actionsBaseSchema.optional(),
|
||||
actions_up: actionsBaseSchema.optional(),
|
||||
actions_down: actionsBaseSchema.optional(),
|
||||
actions_zoom_in: actionsBaseSchema.optional(),
|
||||
actions_zoom_out: actionsBaseSchema.optional(),
|
||||
actions_home: actionsBaseSchema.optional(),
|
||||
});
|
||||
export type FrigateCardPTZActions = z.infer<typeof frigateCardPTZActions>;
|
||||
|
||||
const livePTZControlsDefaults = {
|
||||
orientation: 'horizontal' as const,
|
||||
mode: 'on' as const,
|
||||
hide_pan_tilt: false,
|
||||
hide_zoom: false,
|
||||
hide_home: false,
|
||||
position: 'bottom-right' as const,
|
||||
};
|
||||
|
||||
export const frigateCardPTZSchema = z.preprocess(
|
||||
// To avoid lots of YAML duplication, provide an easy way to just specify the
|
||||
// service data as actions for each PTZ icon, and it will be preprocessed into
|
||||
// the full form. This also provides compatability with the AlexIT/WebRTC PTZ
|
||||
// configuration.
|
||||
(data) => {
|
||||
if (!data || typeof data !== 'object' || !data['service']) {
|
||||
return data;
|
||||
}
|
||||
const out = { ...data };
|
||||
PTZ_CONTROL_ACTIONS.forEach((name) => {
|
||||
if (`data_${name}` in data && !(`actions_${name}` in data)) {
|
||||
out[`actions_${name}`] = {
|
||||
tap_action: {
|
||||
action: 'call-service',
|
||||
service: data['service'],
|
||||
data: data[`data_${name}`],
|
||||
},
|
||||
};
|
||||
delete out[`data_${name}`];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
},
|
||||
frigateCardPTZActions.extend({
|
||||
mode: z.enum(['off', 'on']).default(livePTZControlsDefaults.mode),
|
||||
position: z
|
||||
.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right'])
|
||||
.default(livePTZControlsDefaults.position),
|
||||
|
||||
orientation: z
|
||||
.enum(['vertical', 'horizontal'])
|
||||
.default(livePTZControlsDefaults.orientation),
|
||||
|
||||
hide_pan_tilt: z.boolean().default(livePTZControlsDefaults.hide_pan_tilt),
|
||||
hide_zoom: z.boolean().default(livePTZControlsDefaults.hide_zoom),
|
||||
hide_home: z.boolean().default(livePTZControlsDefaults.hide_home),
|
||||
|
||||
service: z.string().optional(),
|
||||
style: z.object({}).passthrough().optional(),
|
||||
}),
|
||||
);
|
||||
export type FrigateCardPTZConfig = z.infer<typeof frigateCardPTZSchema>;
|
||||
|
||||
const liveThumbnailControlsDefaults = {
|
||||
...thumbnailControlsDefaults,
|
||||
media_type: 'events' as const,
|
||||
@@ -1018,7 +1139,7 @@ const liveConfigDefault = {
|
||||
size: 48,
|
||||
style: 'chevrons' as const,
|
||||
},
|
||||
ptz: livePTZControlsDefaults,
|
||||
ptz: ptzControlsDefaults,
|
||||
thumbnails: liveThumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
},
|
||||
@@ -1068,7 +1189,7 @@ const liveConfigSchema = z
|
||||
),
|
||||
})
|
||||
.default(liveConfigDefault.controls.next_previous),
|
||||
ptz: frigateCardPTZSchema.default(liveConfigDefault.controls.ptz),
|
||||
ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz),
|
||||
thumbnails: livethumbnailsControlSchema.default(
|
||||
liveConfigDefault.controls.thumbnails,
|
||||
),
|
||||
@@ -1154,6 +1275,7 @@ const cameraConfigDefault = {
|
||||
file_pattern: '%H-%M-%S' as const,
|
||||
},
|
||||
},
|
||||
ptz: ptzCameraConfigDefaults,
|
||||
triggers: {
|
||||
motion: false,
|
||||
occupancy: false,
|
||||
@@ -1251,6 +1373,8 @@ export const cameraConfigSchema = z
|
||||
|
||||
cast: castSchema.optional(),
|
||||
|
||||
ptz: ptzCameraConfigSchema.default(cameraConfigDefault.ptz),
|
||||
|
||||
dimensions: z
|
||||
.object({
|
||||
aspect_ratio: aspectRatioSchema.optional(),
|
||||
@@ -1289,6 +1413,7 @@ const viewConfigDefault = {
|
||||
},
|
||||
untrigger_seconds: 0,
|
||||
},
|
||||
keyboard_shortcuts: keyboardShortcutsDefault,
|
||||
};
|
||||
|
||||
export const triggersSchema = z.object({
|
||||
@@ -1335,6 +1460,9 @@ const viewConfigSchema = z
|
||||
render_entities: z.string().array().optional(),
|
||||
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
|
||||
triggers: triggersSchema.default(viewConfigDefault.triggers),
|
||||
keyboard_shortcuts: keyboardShortcutsSchema.default(
|
||||
viewConfigDefault.keyboard_shortcuts,
|
||||
),
|
||||
})
|
||||
.merge(actionsSchema)
|
||||
.default(viewConfigDefault);
|
||||
@@ -1371,7 +1499,7 @@ const menuConfigDefault = {
|
||||
camera_ui: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
default_zoom: visibleButtonDefault,
|
||||
ptz_home: visibleButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
expand: hiddenButtonDefault,
|
||||
@@ -1386,7 +1514,7 @@ const menuConfigDefault = {
|
||||
},
|
||||
mute: hiddenButtonDefault,
|
||||
play: hiddenButtonDefault,
|
||||
ptz: hiddenButtonDefault,
|
||||
ptz_controls: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
@@ -1417,8 +1545,8 @@ export const menuConfigSchema = z
|
||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
default_zoom: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.default_zoom,
|
||||
ptz_home: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.ptz_home,
|
||||
),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
@@ -1441,7 +1569,7 @@ export const menuConfigSchema = z
|
||||
.default(menuConfigDefault.buttons.microphone),
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
|
||||
ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
@@ -1477,6 +1605,10 @@ const viewerConfigDefault = {
|
||||
},
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
ptz: {
|
||||
...ptzControlsDefaults,
|
||||
mode: 'off' as const,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1526,6 +1658,10 @@ const viewerConfigSchema = z
|
||||
next_previous: viewerNextPreviousControlConfigSchema.default(
|
||||
viewerConfigDefault.controls.next_previous,
|
||||
),
|
||||
ptz: ptzControlsConfigSchema.extend({
|
||||
// The media_viewer ptz has no 'auto' mode.
|
||||
mode: z.enum(['off', 'on']).default(viewerConfigDefault.controls.ptz.mode),
|
||||
}).default(viewerConfigDefault.controls.ptz),
|
||||
thumbnails: thumbnailsControlSchema.default(
|
||||
viewerConfigDefault.controls.thumbnails,
|
||||
),
|
||||
@@ -1634,8 +1770,7 @@ const automationSchema = z.object({
|
||||
});
|
||||
export type Automation = z.infer<typeof automationSchema>;
|
||||
|
||||
const automationsSchema = automationSchema.array().optional();
|
||||
export type Automations = z.infer<typeof automationsSchema>;
|
||||
const automationsSchema = automationSchema.array();
|
||||
|
||||
// *************************************************************************
|
||||
// Performance Configuration
|
||||
@@ -1727,7 +1862,7 @@ export const frigateCardConfigSchema = z.object({
|
||||
timeline: timelineConfigSchema,
|
||||
performance: performanceConfigSchema,
|
||||
debug: debugConfigSchema,
|
||||
automations: automationsSchema,
|
||||
automations: automationsSchema.optional(),
|
||||
|
||||
profiles: profilesSchema,
|
||||
|
||||
@@ -1739,7 +1874,7 @@ export const frigateCardConfigSchema = z.object({
|
||||
|
||||
// Card ID (used for query string commands). Restrict contents to only values
|
||||
// that be easily used in a URL.
|
||||
card_id: z.string().regex(/^\w+$/).optional(),
|
||||
card_id: z.string().regex(cardIDRegex).optional(),
|
||||
|
||||
// Stock lovelace card config.
|
||||
type: z.string(),
|
||||
|
||||
+19
-1
@@ -57,7 +57,7 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
|
||||
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const;
|
||||
@@ -97,6 +97,7 @@ export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS =
|
||||
`${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const;
|
||||
export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT =
|
||||
`${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const;
|
||||
export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const;
|
||||
|
||||
export const CONF_ELEMENTS = 'elements' as const;
|
||||
|
||||
@@ -105,6 +106,23 @@ export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
|
||||
export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const;
|
||||
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
|
||||
export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED =
|
||||
`${CONF_VIEW}.keyboard_shortcuts.enabled` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_left` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_right` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_up` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_down` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_in` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME =
|
||||
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const;
|
||||
export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const;
|
||||
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
|
||||
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
|
||||
|
||||
Vendored
+4
@@ -5,3 +5,7 @@ declare module 'view' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface ViewContext {}
|
||||
}
|
||||
declare module 'action' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface ActionContext {}
|
||||
}
|
||||
|
||||
+69
-5
@@ -192,6 +192,15 @@ import {
|
||||
CONF_VIEW_DARK_MODE,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
|
||||
CONF_VIEW_RESET_AFTER_INTERACTION,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
@@ -215,6 +224,8 @@ import {
|
||||
getEntityTitle,
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
import './components/key-assigner.js';
|
||||
import { KeyboardShortcut } from './config/keyboard-shortcuts.js';
|
||||
|
||||
const MENU_BUTTONS = 'buttons';
|
||||
const MENU_CAMERAS = 'cameras';
|
||||
@@ -251,6 +262,7 @@ const MENU_OPTIONS = 'options';
|
||||
const MENU_PERFORMANCE_FEATURES = 'performance.features';
|
||||
const MENU_PERFORMANCE_STYLE = 'performance.style';
|
||||
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
||||
const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts';
|
||||
const MENU_VIEW_TRIGGERS = 'view.triggers';
|
||||
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
|
||||
|
||||
@@ -1111,6 +1123,60 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
);
|
||||
}
|
||||
|
||||
protected _renderKeyAssigner(configPath: string, defaultValue: KeyboardShortcut): TemplateResult {
|
||||
return html` <frigate-card-key-assigner
|
||||
.label=${localize(`config.${configPath}`)}
|
||||
.value=${this._config ? getConfigValue(this._config, configPath, defaultValue) : null}
|
||||
@value-changed=${(ev) => this._valueChangedHandler(configPath, ev)}
|
||||
></frigate-card-key-assigner>`;
|
||||
}
|
||||
|
||||
protected _renderViewKeyboardShortcutMenu(): TemplateResult {
|
||||
return this._putInSubmenu(
|
||||
MENU_VIEW_KEYBOARD_SHORTCUTS,
|
||||
true,
|
||||
`config.${CONF_VIEW_KEYBOARD_SHORTCUTS}.editor_label`,
|
||||
{ name: 'mdi:keyboard' },
|
||||
html`
|
||||
${this._renderSwitch(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
||||
this._defaults.view.keyboard_shortcuts.enabled,
|
||||
{
|
||||
label: localize(`config.${CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED}`),
|
||||
},
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_left,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_right,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_up,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_down,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_zoom_in,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_zoom_out,
|
||||
)}
|
||||
${this._renderKeyAssigner(
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME,
|
||||
this._defaults.view.keyboard_shortcuts.ptz_home,
|
||||
)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an editor menu for the card menu buttons.
|
||||
* @param button The name of the button.
|
||||
@@ -2272,6 +2338,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
this._defaults.view.update_cycle_camera,
|
||||
)}
|
||||
${this._renderViewTriggersMenu()}
|
||||
${this._renderViewKeyboardShortcutMenu()}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -2311,8 +2378,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderMenuButton('mute') /* */}
|
||||
${this._renderMenuButton('screenshot')}
|
||||
${this._renderMenuButton('display_mode')}
|
||||
${this._renderMenuButton('ptz')}
|
||||
${this._renderMenuButton('default_zoom')}
|
||||
${this._renderMenuButton('ptz_controls')}
|
||||
${this._renderMenuButton('ptz_home')}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -2787,9 +2854,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
this._updateConfig(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(frigate_card_editor_style);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,6 @@
|
||||
"camera_ui": "Interfície d'usuari de la càmera",
|
||||
"cameras": "Càmeres",
|
||||
"clips": "Clips",
|
||||
"default_zoom": "",
|
||||
"display_mode": "Mode de visualització",
|
||||
"download": "Descarregar",
|
||||
"enabled": "Botó habilitat",
|
||||
@@ -377,7 +376,8 @@
|
||||
"mute": "Silenciar / Activar el so",
|
||||
"play": "Reproduir / Pausa",
|
||||
"priority": "Prioritat",
|
||||
"ptz": "",
|
||||
"ptz_controls": "",
|
||||
"ptz_home": "",
|
||||
"recordings": "Enregistraments",
|
||||
"screenshot": "Captura de pantalla",
|
||||
"snapshots": "Imatges instantànies",
|
||||
@@ -437,6 +437,10 @@
|
||||
},
|
||||
"default": "Vista per defecte",
|
||||
"interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "",
|
||||
"enabled": ""
|
||||
},
|
||||
"reset_after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +584,17 @@
|
||||
"what": "Què",
|
||||
"where": "On"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "",
|
||||
"assigning": "",
|
||||
"modifiers": {
|
||||
"alt": "",
|
||||
"ctrl": "",
|
||||
"meta": "",
|
||||
"shift": ""
|
||||
},
|
||||
"unassign": ""
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "Tots",
|
||||
"camera": "Càmera",
|
||||
@@ -630,4 +645,4 @@
|
||||
"timeline": {
|
||||
"select_date": "Escolliu la data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@
|
||||
"camera_ui": "Camera user interface",
|
||||
"cameras": "Cameras",
|
||||
"clips": "Clips",
|
||||
"default_zoom": "Zoom to default",
|
||||
"ptz_home": "PTZ Home",
|
||||
"display_mode": "Display mode",
|
||||
"download": "Download",
|
||||
"enabled": "Button enabled",
|
||||
@@ -377,7 +377,7 @@
|
||||
"mute": "Mute / Unmute",
|
||||
"play": "Play / Pause",
|
||||
"priority": "Priority",
|
||||
"ptz": "Show PTZ controls",
|
||||
"ptz_controls": "Show PTZ controls",
|
||||
"recordings": "Recordings",
|
||||
"screenshot": "Screenshot",
|
||||
"snapshots": "Snapshots",
|
||||
@@ -437,6 +437,17 @@
|
||||
},
|
||||
"default": "Default view",
|
||||
"interaction_seconds": "Seconds after user action to remain interacted with (0=never)",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "Keyboard shortcuts",
|
||||
"enabled": "Keyboard shortcuts enabled",
|
||||
"ptz_down": "PTZ Down",
|
||||
"ptz_home": "PTZ Home",
|
||||
"ptz_left": "PTZ Left",
|
||||
"ptz_right": "PTZ Right",
|
||||
"ptz_up": "PTZ Up",
|
||||
"ptz_zoom_in": "PTZ Zoom In",
|
||||
"ptz_zoom_out": "PTZ Zoom Out"
|
||||
},
|
||||
"reset_after_interaction": "Reset to the default view after user interaction",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +591,17 @@
|
||||
"what": "What",
|
||||
"where": "Where"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "Assign",
|
||||
"assigning": "Assigning",
|
||||
"modifiers": {
|
||||
"alt": "Alt",
|
||||
"ctrl": "Ctrl",
|
||||
"meta": "Meta",
|
||||
"shift": "Shift"
|
||||
},
|
||||
"unassign": "Unassign"
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "All",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -362,7 +362,6 @@
|
||||
"camera_ui": "Interface utilisateur de la caméra",
|
||||
"cameras": "Appareils photo",
|
||||
"clips": "Extraits",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Télécharger",
|
||||
"enabled": "Bouton activé",
|
||||
@@ -377,7 +376,8 @@
|
||||
"mute": "Désactiver/Réactiver le son",
|
||||
"play": "Jouer / Pause",
|
||||
"priority": "Priorité",
|
||||
"ptz": "",
|
||||
"ptz_controls": "",
|
||||
"ptz_home": "",
|
||||
"recordings": "Enregistrements",
|
||||
"screenshot": "Capture d'écran",
|
||||
"snapshots": "Instantanés",
|
||||
@@ -437,6 +437,10 @@
|
||||
},
|
||||
"default": "Vue par défaut",
|
||||
"interaction_seconds": "",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "",
|
||||
"enabled": ""
|
||||
},
|
||||
"reset_after_interaction": "",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +584,17 @@
|
||||
"what": "Quoi",
|
||||
"where": "Où"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "",
|
||||
"assigning": "",
|
||||
"modifiers": {
|
||||
"alt": "",
|
||||
"ctrl": "",
|
||||
"meta": "",
|
||||
"shift": ""
|
||||
},
|
||||
"unassign": ""
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "Tous",
|
||||
"camera": "Caméra",
|
||||
@@ -630,4 +645,4 @@
|
||||
"timeline": {
|
||||
"select_date": "Choisir une date"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,6 @@
|
||||
"camera_ui": "Interfaccia utente della fotocamera",
|
||||
"cameras": "Telecamere",
|
||||
"clips": "Clip",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Download",
|
||||
"enabled": "Pulsante abilitato",
|
||||
@@ -377,7 +376,8 @@
|
||||
"mute": "",
|
||||
"play": "",
|
||||
"priority": "Priorità",
|
||||
"ptz": "",
|
||||
"ptz_controls": "",
|
||||
"ptz_home": "",
|
||||
"recordings": "",
|
||||
"screenshot": "",
|
||||
"snapshots": "Istantanee",
|
||||
@@ -437,6 +437,10 @@
|
||||
},
|
||||
"default": "Visualizzazione predefinita",
|
||||
"interaction_seconds": "",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "",
|
||||
"enabled": ""
|
||||
},
|
||||
"reset_after_interaction": "",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +584,17 @@
|
||||
"what": "Che cosa",
|
||||
"where": "Dove"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "",
|
||||
"assigning": "",
|
||||
"modifiers": {
|
||||
"alt": "",
|
||||
"ctrl": "",
|
||||
"meta": "",
|
||||
"shift": ""
|
||||
},
|
||||
"unassign": ""
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "Tutto",
|
||||
"camera": "Telecamera",
|
||||
@@ -630,4 +645,4 @@
|
||||
"timeline": {
|
||||
"select_date": "Scegli la data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,6 @@
|
||||
"camera_ui": "Interface de usuário da câmera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Baixe a mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
@@ -377,7 +376,8 @@
|
||||
"mute": "",
|
||||
"play": "",
|
||||
"priority": "Prioridade",
|
||||
"ptz": "",
|
||||
"ptz_controls": "",
|
||||
"ptz_home": "",
|
||||
"recordings": "Gravações",
|
||||
"screenshot": "",
|
||||
"snapshots": "Instantâneos",
|
||||
@@ -437,6 +437,10 @@
|
||||
},
|
||||
"default": "Visualização padrão",
|
||||
"interaction_seconds": "",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "",
|
||||
"enabled": ""
|
||||
},
|
||||
"reset_after_interaction": "",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +584,17 @@
|
||||
"what": "O que",
|
||||
"where": "Onde"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "",
|
||||
"assigning": "",
|
||||
"modifiers": {
|
||||
"alt": "",
|
||||
"ctrl": "",
|
||||
"meta": "",
|
||||
"shift": ""
|
||||
},
|
||||
"unassign": ""
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "Todos",
|
||||
"camera": "Câmera",
|
||||
@@ -630,4 +645,4 @@
|
||||
"timeline": {
|
||||
"select_date": "Escolha a data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,6 @@
|
||||
"camera_ui": "Camera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Descarregar mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
@@ -377,7 +376,8 @@
|
||||
"mute": "",
|
||||
"play": "",
|
||||
"priority": "Prioridade",
|
||||
"ptz": "",
|
||||
"ptz_controls": "",
|
||||
"ptz_home": "",
|
||||
"recordings": "",
|
||||
"screenshot": "",
|
||||
"snapshots": "Instantâneos",
|
||||
@@ -437,6 +437,10 @@
|
||||
},
|
||||
"default": "Visualização padrão",
|
||||
"interaction_seconds": "",
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "",
|
||||
"enabled": ""
|
||||
},
|
||||
"reset_after_interaction": "",
|
||||
"triggers": {
|
||||
"actions": {
|
||||
@@ -580,6 +584,17 @@
|
||||
"what": "O quê",
|
||||
"where": "Onde"
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "",
|
||||
"assigning": "",
|
||||
"modifiers": {
|
||||
"alt": "",
|
||||
"ctrl": "",
|
||||
"meta": "",
|
||||
"shift": ""
|
||||
},
|
||||
"unassign": ""
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "Todos",
|
||||
"camera": "Camera",
|
||||
@@ -630,4 +645,4 @@
|
||||
"timeline": {
|
||||
"select_date": "Selecionar a data"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
max-height: var(--frigate-card-max-height);
|
||||
min-height: var(--frigate-card-min-height);
|
||||
|
||||
// Ensure all clicks at the top level work.
|
||||
pointer-events: all;
|
||||
|
||||
// The standard HA header is 56 pixels tall, so that much off the top (header)
|
||||
// and bottom (to maintain center), before doing the calculation of
|
||||
// max-height. This matters on small mobile devices in landscape orientation.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@use 'dotdotdot.scss';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
// Values match other HA editor components.
|
||||
padding: 10px;
|
||||
height: 56px;
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
:host([assigning]) ha-button.assign span,
|
||||
:host([assigning]) ha-button.assign ha-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
div.label {
|
||||
width: 100px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
div.key-row {
|
||||
flex: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
div.key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
height: 90%;
|
||||
width: min-content;
|
||||
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
div.key-inner {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
|
||||
border: 2px;
|
||||
border-radius: 4px;
|
||||
border-style: outset;
|
||||
border-color: var(--divider-color);
|
||||
|
||||
font-family: monospace;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
div.unassigned {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.key + div.key:before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin-right: 5px;
|
||||
|
||||
content: ' + ';
|
||||
}
|
||||
+7
-2
@@ -93,8 +93,13 @@ export interface CardHelpers {
|
||||
export type PTZMovementType = 'relative' | 'continuous';
|
||||
|
||||
export interface PTZCapabilities {
|
||||
panTilt?: PTZMovementType[];
|
||||
zoom?: PTZMovementType[];
|
||||
left?: PTZMovementType[];
|
||||
right?: PTZMovementType[];
|
||||
up?: PTZMovementType[];
|
||||
down?: PTZMovementType[];
|
||||
zoomIn?: PTZMovementType[];
|
||||
zoomOut?: PTZMovementType[];
|
||||
|
||||
presets?: string[];
|
||||
}
|
||||
|
||||
|
||||
+86
-93
@@ -1,24 +1,28 @@
|
||||
import { ActionConfig, hasAction } from '@dermotduffy/custom-card-helpers';
|
||||
import { ZoomSettingsBase } from '../components-lib/zoom/types.js';
|
||||
import { PTZAction } from '../config/ptz.js';
|
||||
import {
|
||||
ActionConfig,
|
||||
handleActionConfig,
|
||||
hasAction,
|
||||
HomeAssistant,
|
||||
} from '@dermotduffy/custom-card-helpers';
|
||||
import {
|
||||
Actions,
|
||||
ActionPhase,
|
||||
ActionType,
|
||||
Actions,
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
FrigateCardGeneralAction,
|
||||
FrigateCardUserSpecifiedView,
|
||||
LogActionConfig,
|
||||
LogActionLevel,
|
||||
PTZActionConfig,
|
||||
PTZDigitialActionConfig,
|
||||
PTZMultiActionConfig,
|
||||
frigateCardCustomActionSchema,
|
||||
} from '../config/types.js';
|
||||
import { arrayify } from './basic.js';
|
||||
|
||||
/**
|
||||
* Convert a generic Action to a FrigateCardCustomAction if it parses correctly.
|
||||
* @param action The generic action configuration.
|
||||
* @returns A FrigateCardCustomAction or null if it cannot be converted.
|
||||
*/
|
||||
export function convertActionToFrigateCardCustomAction(
|
||||
export function convertActionToCardCustomAction(
|
||||
action: unknown,
|
||||
): FrigateCardCustomAction | null {
|
||||
if (!action) {
|
||||
@@ -30,7 +34,7 @@ export function convertActionToFrigateCardCustomAction(
|
||||
return parseResult.success ? parseResult.data : null;
|
||||
}
|
||||
|
||||
export function createFrigateCardSimpleAction(
|
||||
export function createGeneralAction(
|
||||
action: FrigateCardGeneralAction | FrigateCardUserSpecifiedView,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
@@ -43,7 +47,7 @@ export function createFrigateCardSimpleAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardCameraAction(
|
||||
export function createCameraAction(
|
||||
action: 'camera_select' | 'live_substream_select',
|
||||
camera: string,
|
||||
options?: {
|
||||
@@ -58,7 +62,7 @@ export function createFrigateCardCameraAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardMediaPlayerAction(
|
||||
export function createMediaPlayerAction(
|
||||
mediaPlayer: string,
|
||||
mediaPlayerAction: 'play' | 'stop',
|
||||
options?: {
|
||||
@@ -74,7 +78,7 @@ export function createFrigateCardMediaPlayerAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardDisplayModeAction(
|
||||
export function createDisplayModeAction(
|
||||
displayMode: 'single' | 'grid',
|
||||
options?: {
|
||||
cardID?: string;
|
||||
@@ -88,38 +92,87 @@ export function createFrigateCardDisplayModeAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardShowPTZAction(
|
||||
showPTZ: boolean,
|
||||
export function createPTZControlsAction(
|
||||
enabled: boolean,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
},
|
||||
): FrigateCardCustomAction {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'show_ptz',
|
||||
show_ptz: showPTZ,
|
||||
frigate_card_action: 'ptz_controls',
|
||||
enabled: enabled,
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardChangeZoomAction(
|
||||
targetID: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
zoom?: number;
|
||||
},
|
||||
): FrigateCardCustomAction {
|
||||
export function createPTZAction(options?: {
|
||||
cardID?: string;
|
||||
ptzAction?: PTZAction;
|
||||
ptzPhase?: ActionPhase;
|
||||
ptzPreset?: string;
|
||||
cameraID?: string;
|
||||
}): PTZActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: targetID,
|
||||
frigate_card_action: 'ptz',
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.ptzAction && { ptz_action: options.ptzAction }),
|
||||
...(options?.ptzPhase && { ptz_phase: options.ptzPhase }),
|
||||
...(options?.ptzPreset && { ptz_preset: options.ptzPreset }),
|
||||
...(options?.cameraID && { camera: options.cameraID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPTZDigitalAction(options?: {
|
||||
cardID?: string;
|
||||
ptzPhase?: ActionPhase;
|
||||
ptzAction?: PTZAction;
|
||||
absolute?: ZoomSettingsBase;
|
||||
targetID?: string;
|
||||
}): PTZDigitialActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'ptz_digital',
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.ptzAction && { ptz_action: options.ptzAction }),
|
||||
...(options?.ptzPhase && { ptz_phase: options.ptzPhase }),
|
||||
...(options?.absolute && { absolute: options.absolute }),
|
||||
...(options?.targetID && { target_id: options.targetID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPTZMultiAction(options?: {
|
||||
cardID?: string;
|
||||
ptzAction?: PTZAction;
|
||||
ptzPhase?: ActionPhase;
|
||||
ptzPreset?: string;
|
||||
targetID?: string;
|
||||
}): PTZMultiActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'ptz_multi',
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.ptzAction && { ptz_action: options.ptzAction }),
|
||||
...(options?.ptzPhase && { ptz_phase: options.ptzPhase }),
|
||||
...(options?.ptzPreset && { ptz_preset: options.ptzPreset }),
|
||||
...(options?.targetID && { target_id: options.targetID }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLogAction(
|
||||
message: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
level?: LogActionLevel;
|
||||
},
|
||||
): LogActionConfig {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'log',
|
||||
message: message,
|
||||
level: options?.level ?? 'info',
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.pan && { pan: options.pan }),
|
||||
...(options?.zoom && { zoom: options.zoom }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,63 +203,6 @@ export function getActionConfigGivenAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frigate card custom version of handleAction
|
||||
* (https://github.com/custom-cards/custom-card-helpers/blob/master/src/handle-action.ts)
|
||||
* that handles the custom action events the card supports.
|
||||
* @param node The node that fired the event.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param actionConfig A single action config, array of action configs or
|
||||
* undefined for the default action config for 'tap'.
|
||||
* @param action The action string (e.g. 'hold')
|
||||
* @returns Whether or not an action was executed.
|
||||
*/
|
||||
export const frigateCardHandleActionConfig = (
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
config: {
|
||||
camera_image?: string;
|
||||
entity?: string;
|
||||
},
|
||||
action: string,
|
||||
actionConfig?: ActionType | ActionType[] | null,
|
||||
): boolean => {
|
||||
// Only allow a tap action to use a default non-config (the more-info config).
|
||||
if (actionConfig || action == 'tap') {
|
||||
frigateCardHandleAction(node, hass, config, actionConfig);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const frigateCardHandleAction = (
|
||||
node: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
config: {
|
||||
camera_image?: string;
|
||||
entity?: string;
|
||||
},
|
||||
actionConfig?: ActionType | ActionType[] | null,
|
||||
): void => {
|
||||
// ActionConfig vs ActionType:
|
||||
// * 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.
|
||||
if (Array.isArray(actionConfig)) {
|
||||
actionConfig.forEach((action) =>
|
||||
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
|
||||
);
|
||||
} else {
|
||||
handleActionConfig(
|
||||
node,
|
||||
hass,
|
||||
config,
|
||||
(actionConfig ?? undefined) as ActionConfig | undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if an action config has a real action. A modified version of
|
||||
* custom-card-helpers hasAction to also work with arrays of action configs.
|
||||
@@ -216,10 +212,7 @@ export const frigateCardHandleAction = (
|
||||
export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
|
||||
// See note above on 'ActionConfig vs ActionType' for why this cast is
|
||||
// necessary and harmless.
|
||||
if (Array.isArray(config)) {
|
||||
return !!config.find((item) => hasAction(item as ActionConfig | undefined));
|
||||
}
|
||||
return hasAction(config as ActionConfig | undefined);
|
||||
return arrayify(config).some((item) => hasAction(item as ActionConfig | undefined));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+12
-1
@@ -5,9 +5,10 @@ import {
|
||||
format,
|
||||
} from 'date-fns';
|
||||
import { StyleInfo } from 'lit/directives/style-map';
|
||||
import { round } from 'lodash-es';
|
||||
import isEqualWith from 'lodash-es/isEqualWith';
|
||||
import mergeWith from 'lodash-es/mergeWith';
|
||||
import round from 'lodash-es/round';
|
||||
import uniq from 'lodash-es/uniq';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
|
||||
@@ -254,6 +255,16 @@ export const recursivelyMergeObjectsNotArrays = <T>(target: T, src1: T, src2: T)
|
||||
return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
|
||||
};
|
||||
|
||||
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
|
||||
target: T,
|
||||
src1: T,
|
||||
src2: T,
|
||||
): T => {
|
||||
return mergeWith(target, src1, src2, (a, b) =>
|
||||
Array.isArray(a) ? uniq(a.concat(b)) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
export const aspectRatioToString = (options?: {
|
||||
ratio?: number[];
|
||||
defaultStatic?: boolean;
|
||||
|
||||
+11
-10
@@ -301,17 +301,18 @@ export function getEntityIcon(
|
||||
*/
|
||||
export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
|
||||
const neededElements = [
|
||||
'ha-selector',
|
||||
'ha-menu-button',
|
||||
'ha-camera-stream',
|
||||
'ha-hls-player',
|
||||
'ha-web-rtc-player',
|
||||
'ha-icon',
|
||||
'ha-circular-progress',
|
||||
'ha-icon-button',
|
||||
'ha-card',
|
||||
'ha-svg-icon',
|
||||
'ha-button-menu',
|
||||
'ha-button',
|
||||
'ha-camera-stream',
|
||||
'ha-card',
|
||||
'ha-circular-progress',
|
||||
'ha-hls-player',
|
||||
'ha-icon-button',
|
||||
'ha-icon',
|
||||
'ha-menu-button',
|
||||
'ha-selector',
|
||||
'ha-svg-icon',
|
||||
'ha-web-rtc-player',
|
||||
];
|
||||
|
||||
if (neededElements.every((element) => customElements.get(element))) {
|
||||
|
||||
+71
-15
@@ -1,19 +1,75 @@
|
||||
import { Capabilities } from '../camera-manager/capabilities';
|
||||
import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { PTZAction } from '../config/ptz';
|
||||
import { PTZCapabilities } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import { getStreamCameraID } from './substream';
|
||||
|
||||
export const hasUsablePTZ = (
|
||||
capabilities: Capabilities | null,
|
||||
config: FrigateCardPTZConfig,
|
||||
): boolean => {
|
||||
for (const actionName of PTZ_CONTROL_ACTIONS) {
|
||||
if ('actions_' + actionName in config) {
|
||||
return true;
|
||||
export type PTZType = 'digital' | 'ptz';
|
||||
interface PTZTarget {
|
||||
targetID: string;
|
||||
type: PTZType;
|
||||
}
|
||||
|
||||
export const getPTZTarget = (
|
||||
view: View,
|
||||
options?: {
|
||||
type?: PTZType;
|
||||
cameraManager?: CameraManager;
|
||||
},
|
||||
): PTZTarget | null => {
|
||||
if (view.isViewerView()) {
|
||||
const targetID = view.queryResults?.getSelectedResult()?.getID() ?? null;
|
||||
return options?.type === 'ptz' || !targetID
|
||||
? null
|
||||
: {
|
||||
targetID: targetID,
|
||||
type: 'digital',
|
||||
};
|
||||
} else if (view.is('live')) {
|
||||
const substreamAwareCameraID = getStreamCameraID(view);
|
||||
let type: PTZType = 'digital';
|
||||
|
||||
if (options?.type !== 'digital' && options?.cameraManager) {
|
||||
if (hasCameraTruePTZ(options.cameraManager, substreamAwareCameraID)) {
|
||||
type = 'ptz';
|
||||
}
|
||||
if (type !== 'ptz' && options?.type === 'ptz') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetID: substreamAwareCameraID,
|
||||
type: type,
|
||||
};
|
||||
}
|
||||
const ptzCapabilities = capabilities?.getPTZCapabilities();
|
||||
return (
|
||||
!!ptzCapabilities?.panTilt?.length ||
|
||||
!!ptzCapabilities?.zoom?.length ||
|
||||
!!ptzCapabilities?.presets?.length
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const hasCameraTruePTZ = (
|
||||
cameraManager: CameraManager,
|
||||
cameraID: string,
|
||||
): boolean => {
|
||||
return !!cameraManager
|
||||
.getStore()
|
||||
.getCamera(cameraID)
|
||||
?.getCapabilities()
|
||||
?.hasPTZCapability();
|
||||
};
|
||||
|
||||
export const ptzActionToCapabilityKey = (
|
||||
action: PTZAction,
|
||||
): keyof PTZCapabilities | null => {
|
||||
switch (action) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
case 'up':
|
||||
case 'down':
|
||||
return action;
|
||||
case 'zoom_in':
|
||||
return 'zoomIn';
|
||||
case 'zoom_out':
|
||||
return 'zoomOut';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View } from '../view/view';
|
||||
|
||||
export const getStreamCameraID = (view: View): string => {
|
||||
return view?.context?.live?.overrides?.get(view.camera) ?? view.camera;
|
||||
export const getStreamCameraID = (view: View, cameraID?: string): string => {
|
||||
return view.context?.live?.overrides?.get(cameraID ?? view.camera) ?? view.camera;
|
||||
};
|
||||
|
||||
export const hasSubstream = (view: View): boolean => {
|
||||
|
||||
+3
-9
@@ -64,7 +64,7 @@ export class View {
|
||||
}
|
||||
|
||||
public static adoptFromViewIfAppropriate(next: View, curr?: View | null): void {
|
||||
if (!curr) {
|
||||
if (!curr) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,10 +273,7 @@ export class View {
|
||||
export interface FrigateCardViewChangeEventTarget extends EventTarget {
|
||||
addEventListener(
|
||||
event: 'frigate-card:view:change',
|
||||
listener: (
|
||||
this: FrigateCardViewChangeEventTarget,
|
||||
ev: CustomEvent<View>,
|
||||
) => void,
|
||||
listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent<View>) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
@@ -286,10 +283,7 @@ export interface FrigateCardViewChangeEventTarget extends EventTarget {
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'frigate-card:view:change',
|
||||
listener: (
|
||||
this: FrigateCardViewChangeEventTarget,
|
||||
ev: CustomEvent<View>,
|
||||
) => void,
|
||||
listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent<View>) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
|
||||
Reference in New Issue
Block a user